Skip to content

Commit e6f71f8

Browse files
authored
feat(auth): notify hosts when an interactive code is exchanged (#291)
1 parent 38d47d5 commit e6f71f8

4 files changed

Lines changed: 46 additions & 0 deletions

File tree

docs/content/4.helpers/3.interactive-auth.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ As `auth` it wires `rpcFunctions`, `authorize`, and `onConnect` — see [Securit
2929
|--------|---------|---------|
3030
| `clientAuthTokens` | `undefined` | Pre-shared bearer tokens, always trusted. |
3131
| `banner` | a small boxed console message | Called with `{ code, url }`; prints via `printBanner()`. |
32+
| `onTrusted` | `undefined` | Called with `{ session, authToken }` once a code exchange succeeds, so a host rendering its own banner can retract it. |
3233
| `serverUrl` | `context.host.resolveOrigin()` | Magic-link base URL. |
3334

3435
Returns a `DevframeAuthHandler`:
@@ -57,4 +58,6 @@ if (!auth.authorize(methodName, session))
5758
auth.onConnect(peer, session)
5859
```
5960

61+
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.
62+
6063
Auth storage is internal, not `devframe/node/hub-internals`.

packages/devframe/src/recipes/__tests__/interactive-auth.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async function createTestContext(): Promise<DevframeNodeContext> {
2929
async function startAuthenticatedServer(
3030
banners: { code: string, url: string }[] = [],
3131
preTrust = false,
32+
onTrusted?: (info: { authToken: string }) => void,
3233
) {
3334
const context = await createTestContext()
3435
context.rpc.register({
@@ -38,6 +39,7 @@ async function startAuthenticatedServer(
3839
})
3940
const auth = createInteractiveAuth(context, {
4041
banner: info => banners.push(info),
42+
onTrusted,
4143
})
4244

4345
const host = '127.0.0.1'
@@ -135,6 +137,33 @@ describe('recipes/interactive-auth', () => {
135137
}
136138
})
137139

140+
it('onTrusted() fires once a code exchange succeeds, after the rotated code is printed', async () => {
141+
const banners: { code: string, url: string }[] = []
142+
const trusted: { authToken: string, bannerCountAtCall: number, lastBannerCode?: string }[] = []
143+
const { server, host, port } = await startAuthenticatedServer(banners, false, info => trusted.push({
144+
authToken: info.authToken,
145+
bannerCountAtCall: banners.length,
146+
lastBannerCode: banners.at(-1)?.code,
147+
}))
148+
149+
try {
150+
const client = connectClient(host, port)
151+
await client.$call('anonymous:devframe:auth:exchange', { code: 'wrong1', ua: 'test', origin: 'http://localhost' })
152+
expect(trusted).toHaveLength(0)
153+
154+
const code = getTempAuthCode()
155+
const { authToken } = await client.$call('anonymous:devframe:auth:exchange', { code, ua: 'test', origin: 'http://localhost' })
156+
157+
expect(trusted.map(info => info.authToken)).toEqual([authToken])
158+
expect(trusted[0]!.bannerCountAtCall).toBe(banners.length)
159+
expect(trusted[0]!.lastBannerCode).toBe(getTempAuthCode())
160+
client.$close()
161+
}
162+
finally {
163+
await server.close()
164+
}
165+
})
166+
138167
it('preserves trust established by the host before the client handshake', async () => {
139168
const { server, host, port } = await startAuthenticatedServer([], true)
140169

packages/devframe/src/recipes/interactive-auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export interface CreateInteractiveAuthOptions {
2323
* stdout.
2424
*/
2525
banner?: (info: { code: string, url: string }) => void
26+
/**
27+
* Called once a code exchange succeeds, so a host rendering its own
28+
* banner can retract it. Fires after the rotated code is printed, so
29+
* such a host drops that follow-up too and calls `auth.printBanner()`
30+
* when it next wants a code on screen. Connect-time trust from a static
31+
* or remote-dock token doesn't call this.
32+
*/
33+
onTrusted?: (info: { session: DevframeNodeRpcSession, authToken: string }) => void
2634
/**
2735
* The base URL the magic link should point at. Defaults to
2836
* `context.host.resolveOrigin()`.
@@ -125,6 +133,8 @@ export function createInteractiveAuth(
125133
// The code was just consumed (success or a rotating failure) — the
126134
// next `printBanner()` call shows whatever code is current now.
127135
printBanner()
136+
if (authToken)
137+
options.onTrusted?.({ session, authToken })
128138
return { authToken }
129139
},
130140
})

tests/__snapshots__/tsnapi/devframe/recipes/interactive-auth.snapshot.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export interface CreateInteractiveAuthOptions {
88
code: string;
99
url: string;
1010
}) => void;
11+
onTrusted?: (_: {
12+
session: DevframeNodeRpcSession;
13+
authToken: string;
14+
}) => void;
1115
serverUrl?: () => string;
1216
}
1317
// #endregion

0 commit comments

Comments
 (0)