-
- Sign in — substandard
-
-
-
-
-
-
- substandard — sign in
-
-
- 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: {