From 2a1b7abe9ad604ac863d1ef733f10e389bb10375 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:02:15 +0300 Subject: [PATCH] feat(auth): serve the device authorization grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program with no browser — the desktop app — had a client for RFC 8628 and nothing to point it at. This serves the other half: a device authorization request that hands back a code, a page a person enters that code on, and a token endpoint that answers the poll. Both of the paths the client already implements are now reachable. Polling faster than the advertised interval gets slow_down, and each warning widens the interval so ignoring one costs more than the last; refusing gets access_denied, so a request nobody started stops instead of being polled until it ages out. The interval is capped, because it only ever grows and a code has to stay pollable for the whole of its life. The codes live in the same storage as the other short-lived grants rather than in a table, since that is what they are. User codes are drawn from an alphabet with no vowels and no look-alike pairs, and are accepted back in whatever case and spacing a person retyped them in. --- packages/auth/package.json | 4 + packages/auth/src/issuer.ts | 352 +++++++++++++++++++++++++++++- packages/auth/src/random.ts | 22 ++ packages/auth/test/device.test.ts | 195 +++++++++++++++++ 4 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 packages/auth/test/device.test.ts diff --git a/packages/auth/package.json b/packages/auth/package.json index a7627a76..70729b14 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,6 +7,10 @@ "type": "module", "sideEffects": false, "exports": { + "./ui/code": { + "types": "./src/ui/code.tsx", + "import": "./src/ui/code.tsx" + }, "./*": { "types": "./src/*.ts", "import": "./src/*.ts" diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index ebc421ea..e512bbdb 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -175,6 +175,13 @@ export interface AuthorizationState { challenge: string; method: 'S256'; }; + /** + * Set when the browser half of a device authorization grant is running. + * There is no `redirect_uri` in that case: the thing waiting for the answer + * is a program on another machine polling the token endpoint, so the + * result is written to storage instead of into a redirect. + */ + device_code?: string; } /** @@ -196,6 +203,7 @@ import { } from './error.js'; import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js'; import { validatePKCE } from './pkce.js'; +import { generateUnbiasedString } from './random.js'; import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; @@ -206,6 +214,12 @@ import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; /** @internal */ export const aws = awsHandle; +/** RFC 8628's grant type, spelled out because it is a URN and not a word. */ +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +/** The longest a device is ever told to wait between polls, in seconds. */ +const DEVICE_MAX_INTERVAL = 60; + export interface IssuerInput< Providers extends Record>, Subjects extends SubjectSchema, @@ -348,6 +362,18 @@ export interface IssuerInput< * @default 0s */ retention?: number; + /** + * Interval in seconds a device code stays usable before the user has to + * start again. + * @default 600s + */ + device?: number; + /** + * Slowest a device may poll the token endpoint without being told to + * slow down, in seconds. + * @default 5s + */ + deviceInterval?: number; }; /** * Optionally, configure the UI that's displayed when the user visits the root URL of the @@ -466,6 +492,8 @@ export function issuer< const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365; const ttlRefreshReuse = input.ttl?.reuse ?? 60; const ttlRefreshRetention = input.ttl?.retention ?? 0; + const ttlDevice = input.ttl?.device ?? 60 * 10; + const deviceInterval = input.ttl?.deviceInterval ?? 5; if (input.theme) { setTheme(input.theme); } @@ -525,6 +553,44 @@ export function issuer< ? subjectOpts.subject : await resolveSubject(type, properties); await successOpts?.invalidate?.(await resolveSubject(type, properties)); + if (authorization?.device_code) { + // The device grant has nowhere to redirect to. The + // program that started this is on another machine + // polling `/token`, so the tokens are left where + // that poll will find them and the person gets a + // page telling them they are done. + const grant = await Storage.get( + storage, + deviceKey(authorization.device_code) + ); + await auth.unset(ctx, 'authorization'); + if (!grant || grant.status !== 'pending' || grant.expires <= Date.now()) { + return ctx.text( + 'That sign-in request has expired. Start it again from the app.', + 400 + ); + } + const tokens = await generateTokens(ctx, { + subject, + type: type as string, + properties, + clientID: grant.clientID, + ttl: { + access: subjectOpts?.ttl?.access ?? ttlAccess, + refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh + } + }); + await putDevice(authorization.device_code, { + ...grant, + status: 'approved', + tokens: { + access: tokens.access, + refresh: tokens.refresh, + expiresIn: tokens.expiresIn + } + }); + return ctx.text('You are signed in. You can close this page and go back to the app.'); + } if (authorization) { if (authorization.response_type === 'token') { const location = new URL(authorization.redirect_uri); @@ -635,6 +701,86 @@ export function issuer< storage }; + /** + * What a device code is while nobody has answered for it yet. + * + * It lives in the same storage as the other short-lived grants rather than + * in a table of its own: it is one of these, an authorization in flight, + * and a code that outlives its own expiry is a bug in whatever swept the + * table rather than something the storage forgets on its own. + */ + interface DeviceGrant { + userCode: string; + clientID: string; + status: 'pending' | 'approved' | 'denied'; + /** Seconds the client is being told to wait between polls. Grows. */ + interval: number; + /** + * When the last poll that got a real answer arrived, in ms; `0` while + * there has not been one. The first poll is never too early — the + * client has no way to know how long the request itself took, and + * charging it for that would make the first answer arbitrary. + */ + lastPolled: number; + /** When the code stops being usable, in ms. */ + expires: number; + tokens?: { + access: string; + refresh: string; + expiresIn: number; + }; + } + + /** + * The alphabet a user code is drawn from, which is not the whole one. + * + * Someone reads this off one screen and types it into another, so every + * pair that looks or sounds alike is a support ticket: no vowels, so no + * accidental words; no `0`/`O`, `1`/`I`, `5`/`S`, `2`/`Z`. What is left is + * unambiguous read aloud over a phone. RFC 8628 §6.1 asks for exactly this + * trade and the entropy lost is bought back by the length. + */ + const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRTVWXY346789'; + const USER_CODE_LENGTH = 8; + + /** + * The code as stored, from the code as a person typed it. + * + * People retype what they see, which includes the separator that made it + * readable and whatever case their keyboard was in. Neither carries + * meaning, so neither is allowed to make a valid code fail. + */ + function canonicalUserCode(raw: string) { + return raw.replace(/[^0-9a-zA-Z]/g, '').toUpperCase(); + } + + function deviceKey(deviceCode: string) { + return ['oauth:device', deviceCode]; + } + + function userCodeKey(userCode: string) { + return ['oauth:device:user', userCode]; + } + + async function findDeviceByUserCode(raw: string) { + const userCode = canonicalUserCode(raw); + const pointer = await Storage.get<{ deviceCode: string }>(storage!, userCodeKey(userCode)); + if (!pointer) return null; + const grant = await Storage.get(storage!, deviceKey(pointer.deviceCode)); + if (!grant) return null; + return { deviceCode: pointer.deviceCode, grant }; + } + + async function putDevice(deviceCode: string, grant: DeviceGrant) { + const ttl = Math.max(1, Math.ceil((grant.expires - Date.now()) / 1000)); + await Storage.set(storage!, deviceKey(deviceCode), grant, ttl); + } + + async function forgetDevice(deviceCode: string, grant: DeviceGrant) { + await Storage.remove(storage!, deviceKey(deviceCode)); + await Storage.remove(storage!, userCodeKey(grant.userCode)); + } + async function getAuthorization(ctx: Context) { const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization'); if (!match) throw new UnknownStateError(); @@ -786,8 +932,15 @@ export function issuer< issuer: iss, authorization_endpoint: `${iss}/authorize`, token_endpoint: `${iss}/token`, + device_authorization_endpoint: `${iss}/device/authorize`, jwks_uri: `${iss}/.well-known/jwks.json`, - response_types_supported: ['code', 'token'] + response_types_supported: ['code', 'token'], + grant_types_supported: [ + 'authorization_code', + 'refresh_token', + 'client_credentials', + DEVICE_GRANT + ] }); } ); @@ -948,6 +1101,79 @@ export function issuer< }); } + if (grantType === DEVICE_GRANT) { + const deviceCode = form.get('device_code')?.toString(); + if (!deviceCode) + return c.json( + { error: 'invalid_request', error_description: 'Missing device_code' }, + 400 + ); + const grant = await Storage.get(storage, deviceKey(deviceCode)); + + // A code nobody issued and a code that has aged out are the + // same answer on purpose: telling the two apart would let a + // caller learn which random strings were once real. + if (!grant || grant.expires <= Date.now()) { + if (grant) await forgetDevice(deviceCode, grant); + return c.json( + { error: 'expired_token', error_description: 'The device code has expired' }, + 400 + ); + } + + // Terminal answers come before the rate limit. Slowing down a + // client that has already been refused just means it takes + // longer to find out, and it has no reason to poll again. + if (grant.status === 'denied') { + await forgetDevice(deviceCode, grant); + return c.json( + { error: 'access_denied', error_description: 'The request was denied' }, + 400 + ); + } + + const now = Date.now(); + if (now - grant.lastPolled < grant.interval * 1000) { + // RFC 8628 §3.5: every warning widens the interval for this + // and every later poll, so a client that ignores the answer + // is not simply told the same thing again. `lastPolled` is + // deliberately not moved — the window is measured from the + // last poll that got a real answer, so a burst of impatient + // polls costs one wait rather than compounding into one the + // client can never satisfy. + // Capped, because the interval only ever grows and a code + // that lives ten minutes must stay pollable for all of it. + // Uncapped, enough impatience early on makes the code + // unusable for the rest of its life. + await putDevice(deviceCode, { + ...grant, + interval: Math.min(grant.interval + 5, DEVICE_MAX_INTERVAL) + }); + return c.json({ error: 'slow_down', error_description: 'Polling too frequently' }, 400); + } + + if (grant.status === 'approved' && grant.tokens) { + // One redemption. A device code that keeps working after it + // has produced tokens is a bearer token with none of a + // bearer token's expiry. + await forgetDevice(deviceCode, grant); + return c.json({ + access_token: grant.tokens.access, + refresh_token: grant.tokens.refresh, + expires_in: grant.tokens.expiresIn + }); + } + + await putDevice(deviceCode, { ...grant, lastPolled: now }); + return c.json( + { + error: 'authorization_pending', + error_description: 'The user has not finished signing in' + }, + 400 + ); + } + if (grantType === 'client_credentials') { const provider = form.get('provider'); if (!provider) return c.json({ error: 'missing `provider` form value' }, 400); @@ -995,6 +1221,123 @@ export function issuer< } ); + // The machine half of RFC 8628. A program with no browser asks for a code + // here, shows it to whoever is sitting in front of it, and polls `/token` + // until somebody has answered for it on a device that does have one. + app.post( + '/device/authorize', + cors({ + origin: '*', + allowHeaders: ['*'], + allowMethods: ['POST'], + credentials: false + }), + async (c) => { + const form = await c.req.formData().catch(() => null); + const clientID = form?.get('client_id')?.toString(); + if (!clientID) + return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400); + + const deviceCode = crypto.randomUUID(); + // Retried rather than trusted to be unique: the alphabet is small + // on purpose, so a collision is likelier than it would be for the + // device code, and a collision here hands one person's sign-in to + // somebody else's machine. + let userCode = ''; + for (let attempt = 0; attempt < 5; attempt++) { + const candidate = generateUnbiasedString(USER_CODE_ALPHABET, USER_CODE_LENGTH); + if (!(await Storage.get(storage, userCodeKey(candidate)))) { + userCode = candidate; + break; + } + } + if (!userCode) + return c.json( + { error: 'server_error', error_description: 'Could not allocate a user code' }, + 500 + ); + + const now = Date.now(); + const grant: DeviceGrant = { + userCode, + clientID, + status: 'pending', + interval: deviceInterval, + lastPolled: 0, + expires: now + ttlDevice * 1000 + }; + await putDevice(deviceCode, grant); + await Storage.set(storage, userCodeKey(userCode), { deviceCode }, ttlDevice); + + const iss = issuer(c); + return c.json({ + device_code: deviceCode, + user_code: userCode, + verification_uri: `${iss}/device`, + verification_uri_complete: `${iss}/device?user_code=${userCode}`, + expires_in: ttlDevice, + interval: deviceInterval + }); + } + ); + + // The browser half. Entering the code puts the flow into the same + // authorization state a redirect-based client would have set, so the + // providers below are reached by exactly one path either way. + app.get('/device', async (c) => { + const raw = c.req.query('user_code'); + if (!raw) { + return c.html( + `` + + `
` + + `` + + `` + + `` + + `
` + ); + } + + const found = await findDeviceByUserCode(raw); + if (!found || found.grant.status !== 'pending' || found.grant.expires <= Date.now()) { + return c.text('That code is not valid any more. Ask the app for a new one.', 400); + } + + const authorization: AuthorizationState = { + response_type: 'device_code', + client_id: found.grant.clientID, + device_code: found.deviceCode + } as AuthorizationState; + await auth.set(c, 'authorization', ttlDevice, authorization); + + const provider = c.req.query('provider'); + if (provider) return c.redirect(`/${provider}/authorize`); + const providers = Object.keys(input.providers); + if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); + return auth.forward( + c, + await select()( + Object.fromEntries( + Object.entries(input.providers).map(([key, value]) => [key, value.type]) + ), + c.req.raw + ) + ); + }); + + // Refusing is an answer, and the client has a screen for it. Without this + // a person who did not start the sign-in can only walk away, and the + // program on the other machine keeps polling until the code expires. + app.get('/device/deny', async (c) => { + const raw = c.req.query('user_code'); + if (!raw) return c.text('Missing user_code', 400); + const found = await findDeviceByUserCode(raw); + if (!found || found.grant.expires <= Date.now()) { + return c.text('That code is not valid any more.', 400); + } + await putDevice(found.deviceCode, { ...found.grant, status: 'denied' }); + return c.text('That sign-in request was refused.'); + }); + app.get('/authorize', async (c) => { const provider = c.req.query('provider'); const response_type = c.req.query('response_type'); @@ -1125,6 +1468,13 @@ export function issuer< return auth.forward(c, await error(err, c.req.raw)); } const authorization = await getAuthorization(c); + // A device grant has no redirect to carry the error back on, so it is + // said here instead. Without this the reporting path throws on a URL + // built from `undefined` and the real failure is never printed. + if (!authorization.redirect_uri) { + const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); + return c.text(oauth.description || oauth.error, 400); + } const url = new URL(authorization.redirect_uri); const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); url.searchParams.set('error', oauth.error); diff --git a/packages/auth/src/random.ts b/packages/auth/src/random.ts index dc6dec2d..7666afac 100644 --- a/packages/auth/src/random.ts +++ b/packages/auth/src/random.ts @@ -22,3 +22,25 @@ export function timingSafeCompare(a: string, b: string): boolean { } return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } + +/** + * A random string over an explicit alphabet, without modulo bias. + * + * Bytes that fall outside the largest whole multiple of the alphabet size are + * thrown away rather than folded in, because folding them makes the first few + * symbols more likely than the rest — which for a short code that gates an + * account is a real narrowing of the search space and not a rounding error. + */ +export function generateUnbiasedString(alphabet: string, length: number): string { + const limit = 256 - (256 % alphabet.length); + let result = ''; + while (result.length < length) { + const buffer = crypto.getRandomValues(new Uint8Array(length * 2)); + for (const byte of buffer) { + if (byte < limit && result.length < length) { + result += alphabet[byte % alphabet.length]; + } + } + } + return result; +} diff --git a/packages/auth/test/device.test.ts b/packages/auth/test/device.test.ts new file mode 100644 index 00000000..ccc26614 --- /dev/null +++ b/packages/auth/test/device.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test'; + +import { object, string } from 'valibot'; + +import { issuer } from '../src/issuer.js'; +import { MemoryStorage } from '../src/storage/memory.js'; +import { createSubjects } from '../src/subject.js'; + +const subjects = createSubjects({ + user: object({ + userID: string() + }) +}); + +const auth = issuer({ + storage: MemoryStorage(), + subjects, + allow: async () => true, + providers: { + dummy: { + type: 'dummy', + init(route, ctx) { + route.get('/authorize', async (c) => { + return ctx.success(c, { email: 'foo@bar.com' }); + }); + } + } + }, + success: async (ctx) => ctx.subject('user', { userID: '123' }) +}); + +const ORIGIN = 'https://auth.example.com'; + +async function begin() { + const response = await auth.request(`${ORIGIN}/device/authorize`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: 'desktop' }) + }); + expect(response.status).toBe(200); + return response.json() as Promise<{ + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }>; +} + +async function poll(deviceCode: string) { + const response = await auth.request(`${ORIGIN}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: 'desktop' + }) + }); + return { status: response.status, body: (await response.json()) as any }; +} + +/** Walk the browser half: enter the code, then finish the provider flow. */ +async function approve(userCode: string) { + const entered = await auth.request(`${ORIGIN}/device?user_code=${encodeURIComponent(userCode)}`); + expect(entered.status).toBe(302); + const cookie = entered.headers.get('set-cookie')!; + expect(cookie).toBeTruthy(); + const done = await auth.request(new URL(entered.headers.get('location')!, ORIGIN).toString(), { + headers: { cookie } + }); + expect(done.status).toBe(200); + return done; +} + +beforeEach(() => setSystemTime(new Date('2026-01-01T00:00:00Z'))); +afterEach(() => setSystemTime()); + +describe('device authorization request', () => { + test('answers with everything the polling client needs', async () => { + const started = await begin(); + + expect(started.device_code).toMatch(/.+/); + // Eight characters, so the client's four-and-four chunking reads + // evenly when a person says it out loud. + expect(started.user_code).toMatch(/^[A-Z0-9]{8}$/); + expect(started.verification_uri).toBe(`${ORIGIN}/device`); + expect(started.verification_uri_complete).toContain(started.user_code); + expect(started.interval).toBeGreaterThanOrEqual(1); + expect(started.expires_in).toBeGreaterThan(started.interval); + }); + + test('two requests do not collide', async () => { + const a = await begin(); + const b = await begin(); + expect(a.device_code).not.toBe(b.device_code); + expect(a.user_code).not.toBe(b.user_code); + }); + + test('the metadata document advertises the endpoint and the grant', async () => { + const response = await auth.request(`${ORIGIN}/.well-known/oauth-authorization-server`); + const body: any = await response.json(); + expect(body.device_authorization_endpoint).toBe(`${ORIGIN}/device/authorize`); + expect(body.grant_types_supported).toContain('urn:ietf:params:oauth:grant-type:device_code'); + }); +}); + +describe('polling', () => { + test('an unapproved code is pending', async () => { + const started = await begin(); + const first = await poll(started.device_code); + expect(first.status).toBe(400); + expect(first.body.error).toBe('authorization_pending'); + }); + + test('polling faster than the interval earns slow_down, and widens it', async () => { + const started = await begin(); + await poll(started.device_code); + + const tooSoon = await poll(started.device_code); + expect(tooSoon.body.error).toBe('slow_down'); + + // The interval the client is told to use grows, per RFC 8628 §3.5, so + // a client that ignores the first warning is not merely told again. + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + const stillTooSoon = await poll(started.device_code); + expect(stillTooSoon.body.error).toBe('slow_down'); + + setSystemTime(new Date(Date.now() + (started.interval + 6) * 1000)); + const patient = await poll(started.device_code); + expect(patient.body.error).toBe('authorization_pending'); + }); + + test('an unknown device code is not treated as pending', async () => { + const answer = await poll('not-a-device-code'); + expect(answer.status).toBe(400); + expect(answer.body.error).toBe('expired_token'); + }); + + test('an expired code says so instead of pending forever', async () => { + const started = await begin(); + setSystemTime(new Date(Date.now() + (started.expires_in + 60) * 1000)); + const answer = await poll(started.device_code); + expect(answer.body.error).toBe('expired_token'); + }); +}); + +describe('approval', () => { + test('approving hands the next poll a token', async () => { + const started = await begin(); + await approve(started.user_code); + + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + const answer = await poll(started.device_code); + expect(answer.status).toBe(200); + expect(answer.body.access_token).toMatch(/.+/); + expect(answer.body.refresh_token).toMatch(/.+/); + }); + + test('a device code is redeemable once', async () => { + const started = await begin(); + await approve(started.user_code); + + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + expect((await poll(started.device_code)).status).toBe(200); + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + expect((await poll(started.device_code)).body.error).toBe('expired_token'); + }); + + test('the user code is accepted in the form a person reads aloud', async () => { + const started = await begin(); + const chunked = `${started.user_code.slice(0, 4)}-${started.user_code.slice(4)}`; + await approve(chunked.toLowerCase()); + + setSystemTime(new Date(Date.now() + (started.interval + 1) * 1000)); + expect((await poll(started.device_code)).status).toBe(200); + }); + + test('an unknown user code does not start a provider flow', async () => { + const response = await auth.request(`${ORIGIN}/device?user_code=ZZZZZZZZ`); + expect(response.status).toBe(400); + }); + + test('a refusal is final, and says so', async () => { + const started = await begin(); + const denied = await auth.request( + `${ORIGIN}/device/deny?user_code=${encodeURIComponent(started.user_code)}` + ); + expect(denied.status).toBe(200); + + const answer = await poll(started.device_code); + expect(answer.body.error).toBe('access_denied'); + }); +});