diff --git a/README.md b/README.md index da39ba0..c46fe3c 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,9 @@ Layout: - `src/lib/detection.ts` — well-known probing and verification - `src/lib/icon.ts` — state and badge mapping - `src/lib/atproto.ts` — DID/handle resolution, public record fetches +- `src/lib/session-store.ts` — the session mirror in chrome.storage.local; + the worker writes it at sign-in (the offscreen document has no + chrome.storage) - `src/lib/oauth.ts` — OAuth client; DOM contexts only, never the worker (the browser OAuth client needs `window`/`localStorage`) - `src/lib/authflow.ts` — pure sign-in helpers (redirect URI, redirect diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index c74ee75..a621dcb 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -9,18 +9,15 @@ import { BrowserOAuthClient, type OAuthSession } from '@atproto/oauth-client-bro import clientMetadata from '../../oauth/client-metadata.json' import { profileAvatarUrl, resolveDid } from './atproto' import { oauthRedirectUri } from './authflow' +import { + SUB_KEY, + clearSessionMirror, + getStoredSession, + markSessionExpired, + saveSessionMirror, +} from './session-store' import type { SessionInfo } from './types' -const SUB_KEY = 'oauth.sub' -const SESSION_KEY = 'session' - -/** - * storage.local marker set when a session is dropped without the user asking - * (refresh token expired or revoked remotely). The popup shows it once, so - * being signed out has an explanation instead of looking like amnesia. - */ -export const SESSION_EXPIRED_KEY = 'sessionExpired' - let client: BrowserOAuthClient | undefined function getClient(): BrowserOAuthClient { @@ -34,12 +31,6 @@ function getClient(): BrowserOAuthClient { return client } -/** The did/handle mirror that the popup and service worker read synchronously. */ -export async function getStoredSession(): Promise { - const stored = await chrome.storage.local.get(SESSION_KEY) - return stored[SESSION_KEY] as SessionInfo | undefined -} - /** * First half of interactive sign-in: resolve the handle, push the * authorization request, and return the consent URL to open. The client @@ -53,14 +44,18 @@ export async function startAuthorization(handle: string): Promise { return url.href } -/** Second half: exchange the callback redirect URL for a stored session. */ +/** + * Second half: exchange the callback redirect URL for the session's profile + * info. Deliberately no storage write — this runs in the offscreen document, + * which has no chrome.storage; the worker persists the mirror (src/signin.ts). + */ export async function completeAuthorization(callbackUrl: string): Promise { const params = new URL(callbackUrl).searchParams const { session } = await getClient().callback(params) - return storeSession(session) + return buildSessionInfo(session) } -async function storeSession(session: OAuthSession): Promise { +async function buildSessionInfo(session: OAuthSession): Promise { const did = session.sub let handle: string | undefined let avatarUrl: string | undefined @@ -71,10 +66,7 @@ async function storeSession(session: OAuthSession): Promise { } catch { // handle and avatar are cosmetic; the did is enough } - const info: SessionInfo = { did, handle, avatarUrl } - await chrome.storage.local.set({ [SUB_KEY]: did, [SESSION_KEY]: info }) - await chrome.storage.local.remove(SESSION_EXPIRED_KEY) - return info + return { did, handle, avatarUrl } } /** @@ -93,7 +85,7 @@ export async function refreshStoredSession(): Promise { avatarUrl: await profileAvatarUrl(resolved.pds, current.did), } if (next.handle === current.handle && next.avatarUrl === current.avatarUrl) return undefined - await chrome.storage.local.set({ [SESSION_KEY]: next }) + await saveSessionMirror(next) console.debug('[substandard] refreshed session profile', next) return next } catch (err) { @@ -113,8 +105,7 @@ export async function restoreAgent(): Promise { } catch (err) { // refresh token expired or session revoked remotely console.debug('[substandard] session restore failed, dropping session', err) - await chrome.storage.local.remove([SUB_KEY, SESSION_KEY]) - await chrome.storage.local.set({ [SESSION_EXPIRED_KEY]: true }) + await markSessionExpired() return undefined } } @@ -129,5 +120,5 @@ export async function signOut(): Promise { // best-effort; clear local state regardless }) } - await chrome.storage.local.remove([SUB_KEY, SESSION_KEY, SESSION_EXPIRED_KEY]) + await clearSessionMirror() } diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts new file mode 100644 index 0000000..3f19b9d --- /dev/null +++ b/src/lib/session-store.ts @@ -0,0 +1,40 @@ +// The {did, handle, avatar} mirror of the OAuth session, kept in +// chrome.storage.local for contexts that cannot run the OAuth client (the +// worker) or want it without one (the popup on open). Separate from oauth.ts +// so the worker can import it without pulling in the browser OAuth client — +// and because the offscreen document that runs that client cannot write the +// mirror itself: offscreen documents get no chrome.storage at all. + +import type { SessionInfo } from './types' + +export const SUB_KEY = 'oauth.sub' +export const SESSION_KEY = 'session' + +/** + * storage.local marker set when a session is dropped without the user asking + * (refresh token expired or revoked remotely). The popup shows it once, so + * being signed out has an explanation instead of looking like amnesia. + */ +export const SESSION_EXPIRED_KEY = 'sessionExpired' + +export async function getStoredSession(): Promise { + const stored = await chrome.storage.local.get(SESSION_KEY) + return stored[SESSION_KEY] as SessionInfo | undefined +} + +/** A fresh sign-in also retires any pending expiry note. */ +export async function saveSessionMirror(info: SessionInfo): Promise { + await chrome.storage.local.set({ [SUB_KEY]: info.did, [SESSION_KEY]: info }) + await chrome.storage.local.remove(SESSION_EXPIRED_KEY) +} + +/** Deliberate sign-out: drop everything, including any expiry note. */ +export async function clearSessionMirror(): Promise { + await chrome.storage.local.remove([SUB_KEY, SESSION_KEY, SESSION_EXPIRED_KEY]) +} + +/** The session died on its own: drop it but leave the one-shot expiry note. */ +export async function markSessionExpired(): Promise { + await chrome.storage.local.remove([SUB_KEY, SESSION_KEY]) + await chrome.storage.local.set({ [SESSION_EXPIRED_KEY]: true }) +} diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 80f44e8..fd1f654 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -1,13 +1,8 @@ import 'actor-typeahead' import { bskyProfileUrl } from '../lib/atproto' import { AUTH_ERROR_KEY } from '../lib/authflow' -import { - SESSION_EXPIRED_KEY, - getStoredSession, - refreshStoredSession, - restoreAgent, - signOut, -} from '../lib/oauth' +import { refreshStoredSession, restoreAgent, signOut } from '../lib/oauth' +import { SESSION_EXPIRED_KEY, getStoredSession } from '../lib/session-store' import { DEFAULT_READER_ID, READERS, docUrl, pubSiteUrl, pubUrl } from '../lib/readers' import { statusMessageFor } from '../lib/status' import { wireTypeaheadSubmit } from '../lib/typeahead' diff --git a/src/signin.ts b/src/signin.ts index 1b5d7cc..4c4d113 100644 --- a/src/signin.ts +++ b/src/signin.ts @@ -9,6 +9,7 @@ // level so their events revive the worker. import { AUTH_ERROR_KEY, callbackFromUpdate, oauthRedirectUri } from './lib/authflow' +import { saveSessionMirror } from './lib/session-store' import type { OffscreenMsg, SessionInfo } from './lib/types' const PENDING_KEY = 'pendingAuth' @@ -112,6 +113,9 @@ async function onConsentTabUpdated( type: 'oauth-callback', url: callbackUrl, }) + // The offscreen document cannot persist this itself (no chrome.storage + // there); the mirror write is ours. + await saveSessionMirror(info) console.debug('[substandard] signed in as', info.did) } catch (err) { console.debug('[substandard] token exchange failed', err)