diff --git a/auth.html b/auth.html deleted file mode 100644 index 4ee43b3..0000000 --- a/auth.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - Sign in — substandard - - - - - -

- Sign in with your AT Protocol account. A consent window from your PDS will - open; substandard only requests permission to manage its subscription records. -

-
-
- - -
-
-

- - - diff --git a/offscreen.html b/offscreen.html new file mode 100644 index 0000000..0e2bc21 --- /dev/null +++ b/offscreen.html @@ -0,0 +1,10 @@ + + + + + substandard OAuth host + + + + + diff --git a/public/manifest.json b/public/manifest.json index 32c7c9b..62280a7 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -28,6 +28,6 @@ "32": "icons/icon32.png" } }, - "permissions": ["storage", "identity", "activeTab", "tabs"], + "permissions": ["storage", "offscreen", "activeTab", "tabs"], "host_permissions": ["http://*/*", "https://*/*"] } diff --git a/scripts/verify-dist.mjs b/scripts/verify-dist.mjs index c357708..d3c1e13 100644 --- a/scripts/verify-dist.mjs +++ b/scripts/verify-dist.mjs @@ -56,8 +56,8 @@ for (const war of manifest.web_accessible_resources ?? []) { } } -// Pages opened via chrome.runtime.getURL() rather than the manifest. -requireFile('auth.html', 'opened by popup via chrome.runtime.getURL') +// Pages created at runtime rather than referenced from the manifest. +requireFile('offscreen.html', 'created by the worker via chrome.offscreen') // Local script/stylesheet references inside built HTML pages. for (const name of readdirSync(dist).filter((f) => f.endsWith('.html'))) { diff --git a/src/auth/auth.ts b/src/auth/auth.ts deleted file mode 100644 index 1fd7bf9..0000000 --- a/src/auth/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Dedicated sign-in tab. The popup can't host the OAuth flow because it closes -// when the consent window takes focus. - -import { signInInteractive } from '../lib/oauth' - -const form = document.getElementById('form') as HTMLFormElement -const input = document.getElementById('handle') as HTMLInputElement -const status = document.getElementById('status') as HTMLParagraphElement - -async function run(handle: string) { - status.textContent = 'Waiting for consent…' - try { - const info = await signInInteractive(handle) - status.textContent = `Signed in as @${info.handle ?? info.did}. You can close this tab.` - setTimeout(() => window.close(), 1500) - } catch (err) { - status.textContent = `Sign-in failed: ${err instanceof Error ? err.message : err}` - } -} - -form.addEventListener('submit', (e) => { - e.preventDefault() - const handle = input.value.trim().replace(/^@/, '') - if (handle) void run(handle) -}) - -// Pre-fill and start automatically when opened from the popup. -const prefill = new URLSearchParams(location.search).get('handle') -if (prefill) { - input.value = prefill - void run(prefill) -} diff --git a/src/background.ts b/src/background.ts index 5d5efa3..242faec 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,12 +1,13 @@ -// MV3 service worker: detection state per tab, toolbar badge states, and -// public reads (publication records, the user's subscription list). OAuth and -// PDS writes happen in extension pages (see src/lib/oauth.ts) because the -// OAuth client cannot run in a worker; the pages mirror {did, handle} into -// chrome.storage.local under `session` for the worker to use. +// MV3 service worker: detection state per tab, toolbar badge states, public +// reads (publication records, the user's subscription list), and the worker +// half of sign-in (src/signin.ts). The OAuth client itself cannot run in a +// worker; it lives in DOM contexts (see src/lib/oauth.ts), which mirror +// {did, handle} into chrome.storage.local under `session` for the worker. import { listRecords, parseAtUri, resolveDid } from './lib/atproto' import { detectPage } from './lib/detection' import { type IconState, badgeFor, iconStateFor } from './lib/icon' +import { startSignIn } from './signin' import type { Msg, PageState, SessionInfo } from './lib/types' const SUB_COLLECTION = 'site.standard.graph.subscription' @@ -130,8 +131,11 @@ function isHttpUrl(url: string | undefined): url is string { // --- message handling -------------------------------------------------------- -chrome.runtime.onMessage.addListener((msg: Msg, sender, sendResponse) => { - handle(msg, sender) +chrome.runtime.onMessage.addListener((msg: Msg | { target?: string }, sender, sendResponse) => { + // Offscreen-bound messages: stay quiet so our sendResponse(undefined) + // cannot win the race against the offscreen document's real response. + if ('target' in msg && msg.target) return false + handle(msg as Msg, sender) .then(sendResponse) .catch((err: unknown) => { sendResponse({ __error: err instanceof Error ? err.message : String(err) }) @@ -160,6 +164,11 @@ async function handle(msg: Msg, sender: chrome.runtime.MessageSender): Promise undefined) return computeState(msg.tabId, tab.url, hints ?? {}, !!msg.refresh) } + case 'signin': { + // Resolves once the consent window is open; the redirect listeners in + // src/signin.ts finish the flow after the popup is gone. + return startSignIn(msg.handle) + } } } diff --git a/src/lib/authflow.test.ts b/src/lib/authflow.test.ts new file mode 100644 index 0000000..33e3ec2 --- /dev/null +++ b/src/lib/authflow.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { callbackFromUpdate, oauthRedirectUri } from './authflow' + +const REDIRECT = oauthRedirectUri('degljbilkggdpbobomfbgnellecgbkjj') + +describe('oauthRedirectUri', () => { + it('matches the redirect_uri registered in the client metadata', () => { + expect(REDIRECT).toBe('https://degljbilkggdpbobomfbgnellecgbkjj.chromiumapp.org/oauth2') + }) +}) + +describe('callbackFromUpdate', () => { + it('finds the callback in changeInfo.url', () => { + const url = `${REDIRECT}?code=abc&state=xyz` + expect(callbackFromUpdate(REDIRECT, { url }, {})).toBe(url) + }) + + it('finds the callback in pendingUrl while the navigation is provisional', () => { + const url = `${REDIRECT}?code=abc` + expect(callbackFromUpdate(REDIRECT, {}, { pendingUrl: url, url: 'https://pds.example/consent' })).toBe(url) + }) + + it('finds the callback in the committed tab url', () => { + const url = `${REDIRECT}?error=access_denied` + expect(callbackFromUpdate(REDIRECT, { url: undefined }, { url })).toBe(url) + }) + + it('ignores consent-page navigations', () => { + expect( + callbackFromUpdate(REDIRECT, { url: 'https://pds.example/oauth/authorize?x=1' }, {}), + ).toBeUndefined() + }) + + it('ignores lookalike paths on the redirect host', () => { + expect(callbackFromUpdate(REDIRECT, { url: `${REDIRECT}x?code=abc` }, {})).toBeUndefined() + }) + + it('accepts the bare redirect with no query', () => { + expect(callbackFromUpdate(REDIRECT, { url: REDIRECT }, {})).toBe(REDIRECT) + }) +}) diff --git a/src/lib/authflow.ts b/src/lib/authflow.ts new file mode 100644 index 0000000..18420a3 --- /dev/null +++ b/src/lib/authflow.ts @@ -0,0 +1,36 @@ +// Pure helpers for the interactive sign-in flow. Shared by the worker and the +// offscreen document, and unit-testable without chrome.* APIs. + +/** storage.session key for the last sign-in failure, shown by the popup. */ +export const AUTH_ERROR_KEY = 'authError' + +/** + * The https redirect URI the PDS sends the consent window back to. The host + * is the extension id — stable because the manifest pins `key` — and must + * match a redirect_uri in oauth/client-metadata.json exactly. The domain + * never resolves; we only need the navigation attempt to be observable. + */ +export function oauthRedirectUri(extensionId: string): string { + return `https://${extensionId}.chromiumapp.org/oauth2` +} + +function isRedirect(url: string, redirectUri: string): boolean { + return url === redirectUri || url.startsWith(`${redirectUri}?`) +} + +/** + * The OAuth callback URL carried by a tabs.onUpdated event, if any. The + * redirect commits as an error page (chromiumapp.org has no DNS), so the URL + * can surface in changeInfo.url, or only in the tab's pendingUrl while the + * navigation is provisional. + */ +export function callbackFromUpdate( + redirectUri: string, + changeInfo: { url?: string }, + tab: { pendingUrl?: string; url?: string }, +): string | undefined { + for (const url of [changeInfo.url, tab.pendingUrl, tab.url]) { + if (url && isRedirect(url, redirectUri)) return url + } + return undefined +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 006f1ff..c12211b 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -1,12 +1,14 @@ -// OAuth session handling. This module must only be used from extension pages -// (popup, auth tab) — BrowserOAuthClient depends on window/localStorage and -// does not run in the MV3 service worker. The worker learns about the session -// through the `session` mirror in chrome.storage.local. +// OAuth session handling. This module must only be used from DOM contexts +// (popup, offscreen document) — BrowserOAuthClient depends on +// window/localStorage and does not run in the MV3 service worker. The worker +// learns about the session through the `session` mirror in +// chrome.storage.local. import { Agent } from '@atproto/api' import { BrowserOAuthClient, type OAuthSession } from '@atproto/oauth-client-browser' import clientMetadata from '../../oauth/client-metadata.json' import { resolveDid } from './atproto' +import { oauthRedirectUri } from './authflow' import type { SessionInfo } from './types' const SUB_KEY = 'oauth.sub' @@ -32,30 +34,22 @@ export async function getStoredSession(): Promise { } /** - * Interactive sign-in. Call from a full extension page (not the popup — the - * popup document is destroyed when the auth window takes focus). + * First half of interactive sign-in: resolve the handle, push the + * authorization request, and return the consent URL to open. The client + * persists the pending state (PKCE verifier, DPoP keys), so the matching + * completeAuthorization can run in a later client instance. */ -export async function signInInteractive(handle: string): Promise { - const c = getClient() - const authUrl = await c.authorize(handle, { - redirect_uri: chrome.identity.getRedirectURL('oauth2') as `https://${string}`, - }) - - const finalUrl = await new Promise((resolve, reject) => { - chrome.identity.launchWebAuthFlow( - { url: authUrl.href, interactive: true }, - (responseUrl) => { - if (chrome.runtime.lastError || !responseUrl) { - reject(new Error(chrome.runtime.lastError?.message ?? 'Sign-in was cancelled')) - } else { - resolve(responseUrl) - } - }, - ) +export async function startAuthorization(handle: string): Promise { + const url = await getClient().authorize(handle, { + redirect_uri: oauthRedirectUri(chrome.runtime.id) as `https://${string}`, }) + return url.href +} - const params = new URL(finalUrl).searchParams - const { session } = await c.callback(params) +/** Second half: exchange the callback redirect URL for a stored session. */ +export async function completeAuthorization(callbackUrl: string): Promise { + const params = new URL(callbackUrl).searchParams + const { session } = await getClient().callback(params) return storeSession(session) } diff --git a/src/lib/types.ts b/src/lib/types.ts index 671fd26..6b4bb6d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -75,3 +75,13 @@ export interface SessionInfo { export type Msg = | { type: 'page-hints'; pubHint?: string; docHint?: string } | { type: 'get-state'; tabId: number; refresh?: boolean } + | { type: 'signin'; handle: string } + +/** + * Messages from the worker to the offscreen document that hosts the OAuth + * client. `target` routes them: the worker's listener ignores these, and the + * offscreen listener handles nothing else. + */ +export type OffscreenMsg = + | { target: 'offscreen'; type: 'oauth-authorize'; handle: string } + | { target: 'offscreen'; type: 'oauth-callback'; url: string } diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts new file mode 100644 index 0000000..3d7b523 --- /dev/null +++ b/src/offscreen/offscreen.ts @@ -0,0 +1,28 @@ +// Invisible host for the OAuth client during sign-in. BrowserOAuthClient +// needs window/localStorage, which the MV3 worker lacks, so the worker opens +// this document and asks it to build the authorization URL and, later, to +// exchange the callback redirect for a session (see src/signin.ts). + +import { completeAuthorization, startAuthorization } from '../lib/oauth' +import type { OffscreenMsg } from '../lib/types' + +chrome.runtime.onMessage.addListener( + (msg: OffscreenMsg | { target?: undefined }, _sender, sendResponse) => { + if (msg.target !== 'offscreen') return false + dispatch(msg) + .then(sendResponse) + .catch((err: unknown) => { + sendResponse({ __error: err instanceof Error ? err.message : String(err) }) + }) + return true + }, +) + +async function dispatch(msg: OffscreenMsg): Promise { + switch (msg.type) { + case 'oauth-authorize': + return startAuthorization(msg.handle) + case 'oauth-callback': + return completeAuthorization(msg.url) + } +} diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 916d4a9..79672fc 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -1,4 +1,5 @@ import { bskyProfileUrl } from '../lib/atproto' +import { AUTH_ERROR_KEY } from '../lib/authflow' import { getStoredSession, restoreAgent, signOut } from '../lib/oauth' import { DEFAULT_READER_ID, READERS, docUrl, pubUrl } from '../lib/readers' import { statusMessageFor } from '../lib/status' @@ -39,9 +40,19 @@ async function init() { } session = await getStoredSession() renderAccount() + await showAuthError() await loadState(false) } +/** Show (once) a sign-in failure that happened after the last popup closed. */ +async function showAuthError() { + const stored = await chrome.storage.session.get(AUTH_ERROR_KEY) + const authError = stored[AUTH_ERROR_KEY] as string | undefined + if (!authError) return + $('signin-note').textContent = `Sign-in failed: ${authError}` + await chrome.storage.session.remove(AUTH_ERROR_KEY) +} + async function loadState(refresh: boolean) { if (tabId === undefined) { $('status').textContent = 'No active tab.' @@ -279,14 +290,21 @@ $('subscribe').addEventListener('click', async () => { $('subscribe').disabled = false }) -$('signin-form').addEventListener('submit', (e) => { +$('signin-form').addEventListener('submit', async (e) => { e.preventDefault() const handle = $('handle-input').value.trim().replace(/^@/, '') if (!handle) return - // The popup closes as soon as the consent window opens, so the flow runs in - // a dedicated tab instead. - chrome.tabs.create({ url: chrome.runtime.getURL(`auth.html?handle=${encodeURIComponent(handle)}`) }) - window.close() + const note = $('signin-note') + note.textContent = 'Waiting for consent…' + try { + // The worker runs the flow (consent window + offscreen OAuth host); this + // popup dies as soon as the consent window takes focus, and the result + // lands in storage for the next popup open. + await send({ type: 'signin', handle }) + window.close() + } catch (err) { + note.textContent = `Sign-in failed: ${err instanceof Error ? err.message : err}` + } }) $('signout').addEventListener('click', async () => { diff --git a/src/signin.ts b/src/signin.ts new file mode 100644 index 0000000..2fa66ff --- /dev/null +++ b/src/signin.ts @@ -0,0 +1,124 @@ +// Worker side of interactive sign-in. The OAuth client cannot run in the +// service worker, so it lives in an offscreen document; this module owns what +// that document cannot touch: opening the consent window, spotting the +// redirect back, and cleaning up. +// +// Consent can take minutes and the worker can be reaped meanwhile, so no flow +// state lives in worker memory: the pending window id sits in +// storage.session, and the tab/window listeners are registered at the top +// level so their events revive the worker. + +import { AUTH_ERROR_KEY, callbackFromUpdate, oauthRedirectUri } from './lib/authflow' +import type { OffscreenMsg, SessionInfo } from './lib/types' + +const PENDING_KEY = 'pendingAuth' + +// Enough for every PDS consent page we know of; Chrome clamps to the screen. +const CONSENT_WIDTH = 480 +const CONSENT_HEIGHT = 720 + +interface PendingAuth { + windowId: number +} + +async function getPending(): Promise { + return (await chrome.storage.session.get(PENDING_KEY))[PENDING_KEY] as PendingAuth | undefined +} + +async function ensureOffscreen(): Promise { + if (await chrome.offscreen.hasDocument()) return + await chrome.offscreen.createDocument({ + url: 'offscreen.html', + reasons: [chrome.offscreen.Reason.LOCAL_STORAGE], + justification: 'The AT Protocol OAuth client keeps its sign-in state in DOM storage', + }) +} + +async function closeOffscreen(): Promise { + await chrome.offscreen.closeDocument().catch(() => { + // already gone + }) +} + +async function toOffscreen(msg: OffscreenMsg): Promise { + const res = await chrome.runtime.sendMessage(msg) + if (res && typeof res === 'object' && '__error' in res) { + throw new Error(String((res as { __error: unknown }).__error)) + } + return res as T +} + +/** + * Open a consent window for `handle`. Resolves once the window is up; the + * rest of the flow continues in the listeners below. Rejects (into the + * popup's form) if the handle can't be resolved or the PDS refuses the + * authorization request. + */ +export async function startSignIn(handle: string): Promise { + // A new attempt supersedes any window still waiting for consent. + const prev = await getPending() + if (prev) { + await chrome.storage.session.remove(PENDING_KEY) + await chrome.windows.remove(prev.windowId).catch(() => {}) + } + await chrome.storage.session.remove(AUTH_ERROR_KEY) + + await ensureOffscreen() + const url = await toOffscreen({ target: 'offscreen', type: 'oauth-authorize', handle }) + const win = await chrome.windows.create({ + url, + type: 'popup', + width: CONSENT_WIDTH, + height: CONSENT_HEIGHT, + }) + if (win.id === undefined) throw new Error('Could not open the consent window') + console.debug('[substandard] consent window opened', win.id) + await chrome.storage.session.set({ [PENDING_KEY]: { windowId: win.id } satisfies PendingAuth }) +} + +chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => { + void onConsentTabUpdated(changeInfo, tab) +}) + +async function onConsentTabUpdated( + changeInfo: chrome.tabs.TabChangeInfo, + tab: chrome.tabs.Tab, +): Promise { + const pending = await getPending() + if (!pending || tab.windowId !== pending.windowId) return + const callbackUrl = callbackFromUpdate(oauthRedirectUri(chrome.runtime.id), changeInfo, tab) + if (!callbackUrl) return + + // Claim the flow before anything async so the onRemoved listener below + // doesn't read the window close as a cancellation. + await chrome.storage.session.remove(PENDING_KEY) + await chrome.windows.remove(pending.windowId).catch(() => {}) + try { + await ensureOffscreen() + const info = await toOffscreen({ + target: 'offscreen', + type: 'oauth-callback', + url: callbackUrl, + }) + console.debug('[substandard] signed in as', info.did) + } catch (err) { + console.debug('[substandard] token exchange failed', err) + await chrome.storage.session.set({ + [AUTH_ERROR_KEY]: err instanceof Error ? err.message : String(err), + }) + } + await closeOffscreen() +} + +chrome.windows.onRemoved.addListener((windowId) => { + void onConsentWindowClosed(windowId) +}) + +async function onConsentWindowClosed(windowId: number): Promise { + const pending = await getPending() + if (!pending || pending.windowId !== windowId) return + // User closed the window without consenting; not an error worth surfacing. + console.debug('[substandard] consent window closed before redirect') + await chrome.storage.session.remove(PENDING_KEY) + await closeOffscreen() +} diff --git a/vite.config.ts b/vite.config.ts index 0e6566d..0969762 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,8 +4,8 @@ import { resolve } from 'node:path' // One build, two environments (Vite 6 builder API — `vite build` builds both // in order): // -// - `client`: the popup/auth pages and the background service worker, as ES -// modules. Empties dist/ and copies public/ (manifest, icons). +// - `client`: the popup/offscreen pages and the background service worker, as +// ES modules. Empties dist/ and copies public/ (manifest, icons). // - `content`: the content script as a single IIFE file, because MV3 content // scripts cannot be ES modules. Builds second, into the same dist/. // @@ -19,7 +19,7 @@ export default defineConfig({ rollupOptions: { input: { popup: resolve(__dirname, 'popup.html'), - auth: resolve(__dirname, 'auth.html'), + offscreen: resolve(__dirname, 'offscreen.html'), background: resolve(__dirname, 'src/background.ts'), }, output: {