From 9832085230b5af7a539f63db595b3a2126e4e971 Mon Sep 17 00:00:00 2001 From: Bretton Date: Mon, 2 Feb 2026 15:38:03 -0800 Subject: [PATCH] feat(auth): migrate authentication to ATProto OAuth flow Replace Lemmy username/password authentication with ATProto's OAuth flow for the Coves migration. This removes legacy Lemmy-specific code and simplifies the auth model for ATProto's identity system. Changes: - Add /oauth/callback route for handling OAuth redirects - Rewrite login page for handle-based OAuth initiation - Update ProfileInfo to store ATProto fields (did, sessionId, handle) - Add automatic token refresh with 401 retry in API client - Remove Lemmy-specific code (cookie migration, donation polling) - Stub legacy subscription/favorites APIs with migration TODOs - Remove inbox notification badges pending Coves API - Fix localStorage error handling and type safety Co-Authored-By: Claude Opus 4.5 --- .claude/settings.json | 3 +- CLAUDE.md | 2 + src/lib/api/client.svelte.ts | 23 +- src/lib/app/auth.svelte.ts | 483 ++++++------------ src/lib/feature/comment/CommentActions.svelte | 22 +- src/lib/feature/comment/CommentForm.svelte | 1 - .../feature/community/CommunityCard.svelte | 28 +- .../feature/community/CommunityForm.svelte | 26 +- .../feature/community/CommunityHeader.svelte | 4 +- src/lib/feature/filter/Location.svelte | 13 +- src/lib/feature/filter/Sort.svelte | 29 +- src/lib/feature/inbox/PrivateMessage.svelte | 18 +- src/lib/feature/legacy/ProfileAvatar.svelte | 2 +- src/lib/feature/legacy/item.ts | 17 +- src/lib/feature/moderation/BanModal.svelte | 2 +- .../moderation/CommentModerationMenu.svelte | 4 +- .../feature/moderation/ModerationMenu.svelte | 2 +- src/lib/feature/post/Post.svelte | 7 +- .../post/actions/PostActionsMenu.svelte | 51 +- src/lib/feature/post/form/PostForm.svelte | 96 ++-- src/lib/feature/user/ProfileButton.svelte | 2 +- src/lib/feature/user/ProfileSelection.svelte | 19 +- src/lib/feature/user/UserAutocomplete.svelte | 8 +- src/lib/feature/user/index.ts | 49 +- src/lib/ui/navbar/Navbar.svelte | 11 +- src/lib/ui/navbar/Profile.svelte | 25 +- src/lib/ui/navbar/commands/actions.svelte.ts | 16 +- src/lib/ui/sidebar/Sidebar.svelte | 77 +-- src/routes/accounts/+page.svelte | 12 +- src/routes/admin/+layout.svelte | 13 +- .../admin/applications/Application.svelte | 4 +- src/routes/c/[name]/+page.svelte | 42 +- src/routes/inbox/+page.svelte | 4 +- src/routes/inbox/+page.ts | 16 +- src/routes/inbox/InboxItem.svelte | 7 +- src/routes/inbox/messages/+page.svelte | 42 +- .../messages/[user_id=integer]/+page.svelte | 9 +- src/routes/login/+page.svelte | 222 +++----- src/routes/login/guest/+page.svelte | 3 +- src/routes/moderation/Report.svelte | 8 +- .../moderation/communities/+page.svelte | 63 +-- src/routes/oauth/callback/+page.svelte | 99 ++++ .../post/[instance]/[id=integer]/+page.svelte | 6 +- .../blocks/communities/+page.svelte | 4 +- .../blocks/instances/+page.svelte | 4 +- .../(local_user)/blocks/users/+page.svelte | 4 +- .../(local_user)/password/change/+page.svelte | 5 +- src/routes/profile/+layout.ts | 7 +- src/routes/saved/+page.ts | 5 +- src/routes/search/+page.ts | 6 +- src/routes/signup/[instance]/+page.svelte | 29 +- src/routes/u/[name]/UserActions.svelte | 47 +- 52 files changed, 653 insertions(+), 1048 deletions(-) create mode 100644 src/routes/oauth/callback/+page.svelte diff --git a/.claude/settings.json b/.claude/settings.json index 9460e97a..5b77917c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,6 +13,7 @@ ] }, "enabledPlugins": { - "svelte@svelte": true + "svelte@svelte": true, + "pr-review-toolkit@claude-plugins-official": true } } diff --git a/CLAUDE.md b/CLAUDE.md index a9452695..d1b98bfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ **Project**: Coves Frontend (Kelp) - A fork of Photon, building the web frontend for Coves, a forum-like atproto social media platform. +> **Note**: This is a **Coves-only frontend** (forked from Photon for UI/data models). There is no need to maintain Lemmy/PiFed compatibility - we are migrating entirely to Coves/ATProto. + **Related Projects**: - Backend: `/home/bretton/Code/Coves` - Mobile: `/home/bretton/Code/coves-mobile` diff --git a/src/lib/api/client.svelte.ts b/src/lib/api/client.svelte.ts index db9f26ce..9c78e69a 100644 --- a/src/lib/api/client.svelte.ts +++ b/src/lib/api/client.svelte.ts @@ -31,6 +31,7 @@ async function customFetch( input: RequestInfo | URL, init?: RequestInit | undefined, auth?: string, + _retried = false, ): Promise { const f = func ? func : fetch @@ -47,6 +48,20 @@ async function customFetch( } const res = await f(input, init) + + // Handle 401 with token refresh (only retry once) + if (res.status === 401 && auth && !_retried && profile.current?.did) { + const refreshed = await profile.refreshToken() + if (refreshed && profile.current?.jwt) { + // Retry with new token + return customFetch(func, input, init, profile.current.jwt, true) + } + // Log if refresh was attempted but failed + if (profile.current?.did) { + console.warn('Token refresh failed, request will return 401') + } + } + if (!res.ok) error(res.status, await res.text()) return res } @@ -69,10 +84,11 @@ export function client({ instanceURL = profile.current.instance || DEFAULT_INSTANCE_URL if (!clientType) { - clientType = profile.current.client ?? DEFAULT_CLIENT_TYPE + // TODO(coves-migration): Replace with Coves client when ready + clientType = DEFAULT_CLIENT_TYPE } - // we use nullish coealsiaihsa something so that + // we use nullish coalescing so that // we can set auth = '' to remove it const jwt = auth ?? profile.current?.jwt @@ -80,7 +96,8 @@ export function client({ // but not here, so that if jwt == '', it doesnt put a bearer const headers = jwt ? { authorization: `Bearer ${jwt}` } : {} - return new (clientType.name == 'piefed' ? PiefedClient : LemmyClient)( + // TODO(coves-migration): Replace with CovesClient when implemented + return new (clientType?.name == 'piefed' ? PiefedClient : LemmyClient)( instanceToURL(instanceURL), { fetchFunction: (input, init) => customFetch(func, input, init, jwt), diff --git a/src/lib/app/auth.svelte.ts b/src/lib/app/auth.svelte.ts index c61d9ef3..9aa7ccd0 100644 --- a/src/lib/app/auth.svelte.ts +++ b/src/lib/app/auth.svelte.ts @@ -1,12 +1,6 @@ import { browser } from '$app/environment' -import { env } from '$env/dynamic/public' -import { DEFAULT_CLIENT_TYPE, type ClientType } from '$lib/api/base' -import { client, site } from '$lib/api/client.svelte' -import type { Community, GetSiteResponse, MyUserInfo } from '$lib/api/types' -import { publishedToDate } from '$lib/ui/util/date' import { toast } from 'mono-svelte' import { errorMessage } from './error' -import { t } from './i18n' import { DEFAULT_INSTANCE_URL } from './instance.svelte' import { instanceToURL, moveItem } from './util.svelte' @@ -15,28 +9,32 @@ function getFromStorage(key: string): T | undefined { const lc = localStorage.getItem(key) if (!lc) return undefined - return JSON.parse(lc) + try { + return JSON.parse(lc) + } catch (err) { + console.warn(`Failed to parse localStorage key "${key}":`, err) + localStorage.removeItem(key) // Clear corrupted data + return undefined + } } -function setFromStorage(key: string, item: any, stringify: boolean = true) { +function setFromStorage(key: string, item: unknown, stringify: boolean = true) { if (!browser) return - return localStorage.setItem(key, stringify ? JSON.stringify(item) : item) + return localStorage.setItem(key, stringify ? JSON.stringify(item) : String(item)) } export interface ProfileInfo { id: number instance: string - jwt?: string - user?: MyUserInfo - username?: string + jwt?: string // Sealed token (for API requests) + did?: string // ATProto DID (e.g., did:plc:xxx) + sessionId?: string // For token refresh + handle?: string // User's ATProto handle avatar?: string - favorites?: Community[] - color?: string - client: ClientType } /** - * What gets stored in localstorage. + * What gets stored in localStorage. */ interface ProfileData { profiles: ProfileInfo[] @@ -44,213 +42,164 @@ interface ProfileData { profile: number } -interface Notifications { - inbox: number - reports: number - applications: number +interface OAuthProfileData { + instance: string + token: string // sealed token + did: string + sessionId: string + handle: string + avatar?: string } -const getCookie = (key: string): string | undefined => { - if (!browser) return undefined - - return document?.cookie - ?.split(';') - .map((c) => c.trim()) - .find((c) => c.split('=')?.[0] == key) - ?.split('=')?.[1] +interface RefreshTokenResponse { + sealed_token: string + access_token: string } class Profile { - private static readonly DONATION_CHECK_TIMEOUT = 3 * 1000 - private static readonly DONATION_REMINDER_INTERVAL = 375 * 24 * 60 * 60 * 1000 - meta = $state( getFromStorage('profileData') ?? { profiles: [ { id: 1, instance: DEFAULT_INSTANCE_URL, - username: 'Guest', - color: '#505050', - client: DEFAULT_CLIENT_TYPE, + handle: 'Guest', }, ], profile: 1, }, ) + #current = $derived( this.meta.profiles.find((i) => i.id == this.meta.profile) ?? this.getDefaultProfile(), ) - client = $derived( - client({ - auth: this.#current.jwt, - clientType: this.#current.client, - instanceURL: this.#current.instance, - }), - ) - inbox: InboxService = $state(new InboxService(this)) getDefaultProfile(): ProfileInfo { return { id: -1, instance: DEFAULT_INSTANCE_URL, - client: DEFAULT_CLIENT_TYPE, } } - constructor() { - this.initCookieMigrate() - this.donationPoll(Profile.DONATION_CHECK_TIMEOUT) - } - get current() { return this.#current } + set current(value) { if (!value) return const index = this.meta.profiles.findLastIndex((i) => i.id === value.id) if (index != -1) this.meta.profiles[index] = value } - private async initCookieMigrate() { - if ( - !( - env.PUBLIC_MIGRATE_COOKIE && - this.meta.profiles.length == 0 && - env.PUBLIC_INSTANCE_URL - ) - ) - return - - const jwt = getCookie('jwt') - if (!jwt) return - const result = await this.add( - jwt, - env.PUBLIC_INSTANCE_URL ?? '', - DEFAULT_CLIENT_TYPE, - ) - - if (result) - toast({ - content: - 'Your instance migrated frontends, and your account was transferred.', - type: 'success', - }) - } - - private donationPoll(delay: number) { - return setTimeout(() => { - if ( - profile.current.user?.local_user_view.local_user - .last_donation_notification - ) { - const donationDate = publishedToDate( - profile.current.user?.local_user_view.local_user - .last_donation_notification, - ) - if ( - Date.now() - donationDate.getTime() > - Profile.DONATION_REMINDER_INTERVAL - ) { - toast({ - content: t.get('toast.lemmyDonate'), - duration: 3600 * 1000, - long: true, - }) - - // lemmy js client donation dialog is broken - fetch( - `${instanceToURL(profile.current.instance)}/api/v3/user/donation_dialog_shown`, - { - method: 'POST', - headers: { - authorization: `Bearer ${profile.current.jwt}`, - }, - }, - ) - } - } - }, delay) - } - - async fetchUserData() { - const startId = this.#current.id - if (this.#current.jwt) { - site.data = undefined - - const res = await userFromJwt( - this.#current.jwt, - this.#current.instance, - this.#current.client, - ) - if (!res?.user) - toast({ - content: - "Your account's instance did not return your user data. Your login may have expired.", - type: 'error', - }) - - // TODO update authentication handling to not be this dynamic - if (this.#current.id != startId) { - console.error('profile was switched too fast, ID mismatch') - return - } - - site.data = res?.site - this.#current.user = res?.user - if (profile.current.user) { - this.#current.avatar = res?.user?.local_user_view.person.avatar - this.#current.username = res?.user?.local_user_view.person.name - } - this.inbox.init() - } else { - if (browser) { - site.data = undefined - client({ instanceURL: this.#current.instance }) - .getSite() - .then((res) => (site.data = res)) - } - } - - return this - } - - async add(jwt: string, instance: string, type: ClientType) { + /** + * Add a new profile from OAuth authentication data. + */ + async addOAuthProfile(data: OAuthProfileData): Promise { try { - const user = await userFromJwt(jwt, instance, type) - if (!user?.user) { - throw new Error('No user data received') - } - const id = Math.max(...this.meta.profiles.map((p) => p.id), 0) + 1 + this.meta.profiles.unshift({ id, - instance, - jwt, - username: user.user.local_user_view.person.name, - avatar: user.user.local_user_view.person.avatar, - client: type, + instance: data.instance, + jwt: data.token, + did: data.did, + sessionId: data.sessionId, + handle: data.handle, + avatar: data.avatar, }) + this.meta.profile = id - return user + return true } catch (err) { toast({ content: errorMessage(err as string), type: 'error', }) - return null + return false } } - remove(id: number) { + /** + * Remove a profile and attempt to logout from the backend. + */ + async remove(id: number): Promise { + const profileToRemove = this.meta.profiles.find((p) => p.id === id) + + // Best-effort logout - don't block on failure + if (profileToRemove?.jwt && profileToRemove?.did && profileToRemove?.sessionId) { + fetch(`${instanceToURL(profileToRemove.instance)}/oauth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: profileToRemove.did, + session_id: profileToRemove.sessionId, + sealed_token: profileToRemove.jwt, + }), + }).catch((err) => { + console.warn('OAuth logout failed (session may remain active on server):', err) + }) + } + this.meta.profiles.splice( this.meta.profiles.findIndex((p) => p.id == id), 1, ) + if (id == this.meta.profile) this.meta.profile = -1 } + /** + * Refresh the current profile's sealed token by calling the OAuth refresh endpoint. + * Called automatically on 401 responses, or can be called manually. + * @returns `true` if the token was successfully refreshed, `false` otherwise + */ + async refreshToken(): Promise { + const current = this.current + if (!current?.jwt || !current?.did || !current?.sessionId) { + return false + } + + try { + const response = await fetch( + `${instanceToURL(current.instance)}/oauth/refresh`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + did: current.did, + session_id: current.sessionId, + sealed_token: current.jwt, + }), + }, + ) + + if (!response.ok) return false + + const data: RefreshTokenResponse = await response.json() + this.updateToken(data.sealed_token) + return true + } catch (err) { + console.warn('Token refresh failed:', err) + return false + } + } + + /** + * Update the current profile's JWT/sealed token in-place. + * Used after token refresh to persist the new token. + * @param token - The new sealed token to store + */ + updateToken(token: string): void { + const index = this.meta.profiles.findIndex((p) => p.id === this.meta.profile) + if (index !== -1) { + this.meta.profiles[index] = { + ...this.meta.profiles[index], + jwt: token, + } + } + } + move(id: number, up: boolean) { try { const index = this.meta.profiles.findIndex((i) => i.id == id) @@ -259,33 +208,57 @@ class Profile { index, index + (up ? -1 : 1), ) - } catch { - /* empty */ + } catch (err) { + console.warn('Failed to move profile:', err) } } - isMod(community?: Community): boolean { - if (community) - return ( - (this.#current.user?.moderates.some( - (i) => i.community.id == community.id, - ) || - (community.local && this.isAdmin)) ?? - false - ) - else return (this.#current.user?.moderates.length ?? 0) > 0 + get isDefaultProfile(): boolean { + return !this.#current.jwt && this.#current.instance == DEFAULT_INSTANCE_URL + } + + /** + * Check if the current profile is authenticated with valid credentials. + * @returns `true` if the profile has both a JWT and a DID + */ + get isAuthenticated(): boolean { + return !!this.#current.jwt && !!this.#current.did } + // TODO(coves-migration): Remove these legacy compatibility stubs when migrating to Coves API + // These are placeholders to allow the codebase to compile during transition + + #warnedIsMod = false + #warnedIsAdmin = false + + /** + * @deprecated Legacy Lemmy compatibility - will be replaced with Coves roles + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isMod(_community?: unknown): boolean { + if (!this.#warnedIsMod) { + console.warn('isMod() is a stub - TODO(coves-migration): implement Coves role checks') + this.#warnedIsMod = true + } + return false + } + + /** + * @deprecated Legacy Lemmy compatibility - will be replaced with Coves roles + */ get isAdmin(): boolean { - return ( - site.data?.admins.some( - (i) => i.person.id == this.#current.user?.local_user_view.person.id, - ) ?? false - ) + if (!this.#warnedIsAdmin) { + console.warn('isAdmin is a stub - TODO(coves-migration): implement Coves role checks') + this.#warnedIsAdmin = true + } + return false } - get isDefaultProfile(): boolean { - return !this.#current.jwt && this.#current.instance == DEFAULT_INSTANCE_URL + /** + * @deprecated Legacy Lemmy compatibility - no longer used in Coves + */ + get client(): null { + return null } mainEffect = $effect.root(() => { @@ -293,7 +266,7 @@ class Profile { $effect(() => { const serialized = { ...this.meta, - profiles: this.meta.profiles.map((p) => serializeUser(p)), + profiles: this.meta.profiles.map((p) => serializeProfile(p)), } setFromStorage('profileData', serialized) @@ -304,142 +277,20 @@ class Profile { this.meta.profile = 1 } }) - - $effect(() => { - this.fetchUserData() - }) }) } -class InboxService { - private readonly POLL_INTERVAL = 4 * 60 * 1000 - #pollInterval: NodeJS.Timeout | null = null - - #profile: Profile - - notifications = $state({ - applications: 0, - inbox: 0, - reports: 0, - }) - - constructor(profile: Profile) { - this.#profile = profile - } - - async init(): Promise { - this.cleanup() - - this.notifications = await this.checkInbox() - - this.#pollInterval = setInterval(async () => { - this.notifications = await this.checkInbox() - }, this.POLL_INTERVAL) - } - - cleanup(): void { - if (this.#pollInterval) clearInterval(this.#pollInterval) - - this.#pollInterval = null - } - - clear(): Notifications { - this.notifications = { - applications: 0, - inbox: 0, - reports: 0, - } - return this.notifications - } - - async checkInbox(): Promise { - if (!this.#profile.current.user || !this.#profile.current.jwt) - return this.clear() - - const unreadsPromise = client() - .getUnreadCount() - .then((res) => res.mentions + res.private_messages + res.replies) - .catch(() => 0) - - const reportsPromise = this.#profile.isMod() - ? client() - .getReportCount({}) - .then( - (res) => - res.comment_reports + - res.post_reports + - (res.private_message_reports ?? 0), - ) - .catch(() => 0) - : new Promise((res) => res(0)) - - const applicationsPromise = this.#profile.isAdmin - ? client() - .getUnreadRegistrationApplicationCount() - .then((res) => res.registration_applications) - .catch(() => 0) - : new Promise((res) => res(0)) - - const [unreads, reports, applications] = await Promise.all([ - unreadsPromise, - reportsPromise, - applicationsPromise, - ]) - - return { - inbox: unreads, - reports: reports, - applications: applications, - } - } -} - export const profile = new Profile() -// this is all garbage legacy code, remove later -async function userFromJwt( - jwt: string, - instance: string, - type: ClientType, -): Promise<{ user?: MyUserInfo; site: GetSiteResponse } | undefined> { - const sitePromise = client({ - instanceURL: instance, - auth: jwt, - clientType: type, - }).getSite() - - const timer = setTimeout( - () => - toast({ - content: `Still loading your user data...`, - type: 'warning', - loading: true, - }), - 5000, - ) - - const site = await sitePromise - .then((r) => { - clearTimeout(timer) - return r - }) - .catch((e) => { - toast({ content: `Failed to contact the instance. ${e}` }) - }) - - if (!site) return - - const myUser = site.my_user - - return { - user: myUser, - site: site, - } -} - -function serializeUser(user: ProfileInfo): ProfileInfo { +function serializeProfile(profileInfo: ProfileInfo): ProfileInfo { + // Return a clean copy without any runtime-only data return { - ...user, - user: undefined, + id: profileInfo.id, + instance: profileInfo.instance, + jwt: profileInfo.jwt, + did: profileInfo.did, + sessionId: profileInfo.sessionId, + handle: profileInfo.handle, + avatar: profileInfo.avatar, } } diff --git a/src/lib/feature/comment/CommentActions.svelte b/src/lib/feature/comment/CommentActions.svelte index 89244dfc..924a86b5 100644 --- a/src/lib/feature/comment/CommentActions.svelte +++ b/src/lib/feature/comment/CommentActions.svelte @@ -58,7 +58,7 @@ > {$t('comment.reply')} - {#if profile.current?.user && (profile.isMod(comment.community) || profile.isAdmin)} + {#if profile.current?.jwt && (profile.isMod(comment.community) || profile.isAdmin)} {#snippet target(attachment)}