From fc034ab773307e07ad854b7aa464c38103ec0787 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 8 Aug 2026 17:13:03 -0400 Subject: [PATCH] feat: revoke a switched-away session only after the new sign-in lands The worker now checks the stored mirror when a token exchange completes: if the new session belongs to a different account, the old session is revoked through the offscreen OAuth host and its cached subscriptions are dropped. Re-signing into the same account revokes nothing, since the fresh session lives under the same sub. Co-Authored-By: Claude Fable 5 --- src/lib/authflow.test.ts | 16 +++++++++++++++- src/lib/authflow.ts | 13 +++++++++++++ src/lib/oauth.ts | 21 ++++++++++++++------- src/lib/types.ts | 1 + src/offscreen/offscreen.ts | 4 +++- src/signin.ts | 12 ++++++++++-- 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/lib/authflow.test.ts b/src/lib/authflow.test.ts index 33e3ec2..e54adf6 100644 --- a/src/lib/authflow.test.ts +++ b/src/lib/authflow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { callbackFromUpdate, oauthRedirectUri } from './authflow' +import { callbackFromUpdate, oauthRedirectUri, replacedSession } from './authflow' const REDIRECT = oauthRedirectUri('degljbilkggdpbobomfbgnellecgbkjj') @@ -39,3 +39,17 @@ describe('callbackFromUpdate', () => { expect(callbackFromUpdate(REDIRECT, { url: REDIRECT }, {})).toBe(REDIRECT) }) }) + +describe('replacedSession', () => { + it('retires the previous account after a switch', () => { + expect(replacedSession({ did: 'did:plc:old' }, { did: 'did:plc:new' })).toBe('did:plc:old') + }) + + it('keeps a fresh sign-in when there was nothing before', () => { + expect(replacedSession(undefined, { did: 'did:plc:new' })).toBeUndefined() + }) + + it('never revokes a re-sign-in to the same account (same sub, same stored session)', () => { + expect(replacedSession({ did: 'did:plc:same' }, { did: 'did:plc:same' })).toBeUndefined() + }) +}) diff --git a/src/lib/authflow.ts b/src/lib/authflow.ts index 18420a3..2ed34e0 100644 --- a/src/lib/authflow.ts +++ b/src/lib/authflow.ts @@ -34,3 +34,16 @@ export function callbackFromUpdate( } return undefined } + +/** + * The sub of the session a completed sign-in has replaced, which should now + * be revoked. Undefined when there was no previous session — or when it was + * the same account: the fresh session is stored under the same sub, so + * revoking it would sign the user right back out. + */ +export function replacedSession( + prev: { did: string } | undefined, + next: { did: string }, +): string | undefined { + return prev && prev.did !== next.did ? prev.did : undefined +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index a621dcb..9c92f3a 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -110,15 +110,22 @@ export async function restoreAgent(): Promise { } } +/** + * Best-effort revocation of one stored session, leaving the mirror alone. + * Used on its own when a completed account switch retires the login it + * replaced. + */ +export async function revokeSession(sub: string): Promise { + await getClient() + .revoke(sub) + .catch(() => { + // best-effort; the caller's local state moves on regardless + }) +} + export async function signOut(): Promise { const stored = await chrome.storage.local.get(SUB_KEY) const sub = stored[SUB_KEY] as string | undefined - if (sub) { - await getClient() - .revoke(sub) - .catch(() => { - // best-effort; clear local state regardless - }) - } + if (sub) await revokeSession(sub) await clearSessionMirror() } diff --git a/src/lib/types.ts b/src/lib/types.ts index f8fb4d0..3e01b4d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -87,3 +87,4 @@ export type Msg = export type OffscreenMsg = | { target: 'offscreen'; type: 'oauth-authorize'; handle: string } | { target: 'offscreen'; type: 'oauth-callback'; url: string } + | { target: 'offscreen'; type: 'oauth-revoke'; sub: string } diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index 3d7b523..b6c5700 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -3,7 +3,7 @@ // 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 { completeAuthorization, revokeSession, startAuthorization } from '../lib/oauth' import type { OffscreenMsg } from '../lib/types' chrome.runtime.onMessage.addListener( @@ -24,5 +24,7 @@ async function dispatch(msg: OffscreenMsg): Promise { return startAuthorization(msg.handle) case 'oauth-callback': return completeAuthorization(msg.url) + case 'oauth-revoke': + return revokeSession(msg.sub) } } diff --git a/src/signin.ts b/src/signin.ts index 4c4d113..6d18c21 100644 --- a/src/signin.ts +++ b/src/signin.ts @@ -8,8 +8,8 @@ // 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 { saveSessionMirror } from './lib/session-store' +import { AUTH_ERROR_KEY, callbackFromUpdate, oauthRedirectUri, replacedSession } from './lib/authflow' +import { getStoredSession, saveSessionMirror } from './lib/session-store' import type { OffscreenMsg, SessionInfo } from './lib/types' const PENDING_KEY = 'pendingAuth' @@ -113,6 +113,14 @@ async function onConsentTabUpdated( type: 'oauth-callback', url: callbackUrl, }) + // An account switch keeps the old login usable until the new one has + // landed — which is now, so retire it (and its cached subscriptions). + const replaced = replacedSession(await getStoredSession(), info) + if (replaced) { + await toOffscreen({ target: 'offscreen', type: 'oauth-revoke', sub: replaced }) + await chrome.storage.session.remove(`subs:${replaced}`) + console.debug('[substandard] revoked replaced session', replaced) + } // The offscreen document cannot persist this itself (no chrome.storage // there); the mirror write is ours. await saveSessionMirror(info) -- 2.51.2