diff --git a/docs/content/4.helpers/3.interactive-auth.md b/docs/content/4.helpers/3.interactive-auth.md index 7d96ffa5..7db3505c 100644 --- a/docs/content/4.helpers/3.interactive-auth.md +++ b/docs/content/4.helpers/3.interactive-auth.md @@ -29,6 +29,7 @@ As `auth` it wires `rpcFunctions`, `authorize`, and `onConnect` — see [Securit |--------|---------|---------| | `clientAuthTokens` | `undefined` | Pre-shared bearer tokens, always trusted. | | `banner` | a small boxed console message | Called with `{ code, url }`; prints via `printBanner()`. | +| `onTrusted` | `undefined` | Called with `{ session, authToken }` once a code exchange succeeds, so a host rendering its own banner can retract it. | | `serverUrl` | `context.host.resolveOrigin()` | Magic-link base URL. | Returns a `DevframeAuthHandler`: @@ -57,4 +58,6 @@ if (!auth.authorize(methodName, session)) auth.onConnect(peer, session) ``` +An exchange rotates the code and prints the new one, and `onTrusted` fires after that, so a host retracting a sticky notice drops that follow-up too and calls `auth.printBanner()` when it next wants a code on screen. + Auth storage is internal, not `devframe/node/hub-internals`. diff --git a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts index 7fd7e529..893628d7 100644 --- a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts +++ b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts @@ -29,6 +29,7 @@ async function createTestContext(): Promise { async function startAuthenticatedServer( banners: { code: string, url: string }[] = [], preTrust = false, + onTrusted?: (info: { authToken: string }) => void, ) { const context = await createTestContext() context.rpc.register({ @@ -38,6 +39,7 @@ async function startAuthenticatedServer( }) const auth = createInteractiveAuth(context, { banner: info => banners.push(info), + onTrusted, }) const host = '127.0.0.1' @@ -135,6 +137,33 @@ describe('recipes/interactive-auth', () => { } }) + it('onTrusted() fires once a code exchange succeeds, after the rotated code is printed', async () => { + const banners: { code: string, url: string }[] = [] + const trusted: { authToken: string, bannerCountAtCall: number, lastBannerCode?: string }[] = [] + const { server, host, port } = await startAuthenticatedServer(banners, false, info => trusted.push({ + authToken: info.authToken, + bannerCountAtCall: banners.length, + lastBannerCode: banners.at(-1)?.code, + })) + + try { + const client = connectClient(host, port) + await client.$call('anonymous:devframe:auth:exchange', { code: 'wrong1', ua: 'test', origin: 'http://localhost' }) + expect(trusted).toHaveLength(0) + + const code = getTempAuthCode() + const { authToken } = await client.$call('anonymous:devframe:auth:exchange', { code, ua: 'test', origin: 'http://localhost' }) + + expect(trusted.map(info => info.authToken)).toEqual([authToken]) + expect(trusted[0]!.bannerCountAtCall).toBe(banners.length) + expect(trusted[0]!.lastBannerCode).toBe(getTempAuthCode()) + client.$close() + } + finally { + await server.close() + } + }) + it('preserves trust established by the host before the client handshake', async () => { const { server, host, port } = await startAuthenticatedServer([], true) diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index 1d214185..f22259d9 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -23,6 +23,14 @@ export interface CreateInteractiveAuthOptions { * stdout. */ banner?: (info: { code: string, url: string }) => void + /** + * Called once a code exchange succeeds, so a host rendering its own + * banner can retract it. Fires after the rotated code is printed, so + * such a host drops that follow-up too and calls `auth.printBanner()` + * when it next wants a code on screen. Connect-time trust from a static + * or remote-dock token doesn't call this. + */ + onTrusted?: (info: { session: DevframeNodeRpcSession, authToken: string }) => void /** * The base URL the magic link should point at. Defaults to * `context.host.resolveOrigin()`. @@ -125,6 +133,8 @@ export function createInteractiveAuth( // The code was just consumed (success or a rotating failure) — the // next `printBanner()` call shows whatever code is current now. printBanner() + if (authToken) + options.onTrusted?.({ session, authToken }) return { authToken } }, }) diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts index acb99288..868b0d9b 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts @@ -8,6 +8,10 @@ export interface CreateInteractiveAuthOptions { code: string; url: string; }) => void; + onTrusted?: (_: { + session: DevframeNodeRpcSession; + authToken: string; + }) => void; serverUrl?: () => string; } // #endregion