Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/content/4.helpers/3.interactive-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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`.
29 changes: 29 additions & 0 deletions packages/devframe/src/recipes/__tests__/interactive-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ async function createTestContext(): Promise<DevframeNodeContext> {
async function startAuthenticatedServer(
banners: { code: string, url: string }[] = [],
preTrust = false,
onTrusted?: (info: { authToken: string }) => void,
) {
const context = await createTestContext()
context.rpc.register({
Expand All @@ -38,6 +39,7 @@ async function startAuthenticatedServer(
})
const auth = createInteractiveAuth(context, {
banner: info => banners.push(info),
onTrusted,
})

const host = '127.0.0.1'
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions packages/devframe/src/recipes/interactive-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down Expand Up @@ -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 }
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export interface CreateInteractiveAuthOptions {
code: string;
url: string;
}) => void;
onTrusted?: (_: {
session: DevframeNodeRpcSession;
authToken: string;
}) => void;
serverUrl?: () => string;
}
// #endregion
Expand Down