import type { v1 } from '@standard-schema/spec'; import { Context } from 'hono'; import { handle as awsHandle } from 'hono/aws-lambda'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { Hono } from 'hono/tiny'; /** * The `issuer` create an OpentAuth server, a [Hono](https://hono.dev) app that's * designed to run anywhere. * * The `issuer` function requires a few things: * * ```ts title="issuer.ts" * import { issuer } from "@openauthjs/openauth" * * const app = issuer({ * providers: { ... }, * storage, * subjects, * success: async (ctx, value) => { ... } * }) * ``` * * #### Add providers * * You start by specifying the auth providers you are going to use. Let's say you want your users * to be able to authenticate with GitHub and with their email and password. * * ```ts title="issuer.ts" * import { GithubProvider } from "@openauthjs/openauth/provider/github" * import { PasswordProvider } from "@openauthjs/openauth/provider/password" * * const app = issuer({ * providers: { * github: GithubProvider({ * // ... * }), * password: PasswordProvider({ * // ... * }), * }, * }) * ``` * * #### Handle success * * The `success` callback receives the payload when a user completes a provider's auth flow. * * ```ts title="issuer.ts" * const app = issuer({ * providers: { ... }, * subjects, * async success(ctx, value) { * let userID * if (value.provider === "password") { * console.log(value.email) * userID = ... // lookup user or create them * } * if (value.provider === "github") { * console.log(value.tokenset.access) * userID = ... // lookup user or create them * } * return ctx.subject("user", { * userID * }) * } * }) * ``` * * Once complete, the `issuer` issues the access tokens that a client can use. The `ctx.subject` * call is what is placed in the access token as a JWT. * * #### Define subjects * * You define the shape of these in the `subjects` field. * * ```ts title="subjects.ts" * import { object, string } from "valibot" * import { createSubjects } from "@openauthjs/openauth/subject" * * const subjects = createSubjects({ * user: object({ * userID: string() * }) * }) * ``` * * It's good to place this in a separate file since this'll be used in your client apps as well. * * ```ts title="issuer.ts" * import { subjects } from "./subjects.js" * * const app = issuer({ * providers: { ... }, * subjects, * // ... * }) * ``` * * #### Deploy * * Since `issuer` is a Hono app, you can deploy it anywhere Hono supports. * * * * ```ts title="issuer.ts" * import { serve } from "@hono/node-server" * * serve(app) * ``` * * * ```ts title="issuer.ts" * import { handle } from "hono/aws-lambda" * * export const handler = handle(app) * ``` * * * ```ts title="issuer.ts" * export default app * ``` * * * ```ts title="issuer.ts" * export default app * ``` * * * * @packageDocumentation */ import { Provider, ProviderOptions } from './provider/provider.js'; import { SubjectPayload, SubjectSchema } from './subject.js'; /** * Sets the subject payload in the JWT token and returns the response. * * ```ts * ctx.subject("user", { * userID * }) * ``` */ export interface OnSuccessResponder { /** * The `type` is the type of the subject, that was defined in the `subjects` field. * * The `properties` are the properties of the subject. This is the shape of the subject that * you defined in the `subjects` field. */ subject( type: Type, properties: Extract['properties'], opts?: { ttl?: { access?: number; refresh?: number; }; subject?: string; } ): Promise; } /** * @internal */ export interface AuthorizationState { redirect_uri: string; response_type: string; state: string; client_id: string; audience?: string; pkce?: { 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 recorded against the grant instead of into a redirect. * * This is the *hash* of the device code. The browser half never sees the * code itself — it arrives holding a user code, and the code that redeems * tokens stays with the program that asked for it. */ device_code?: string; } /** * @internal */ export type Prettify = { [K in keyof T]: T[K]; } & {}; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { compactDecrypt, CompactEncrypt, jwtVerify, SignJWT } from 'jose'; import { MissingParameterError, OauthError, UnauthorizedClientError, UnknownStateError } from './error.js'; import { encryptionKeys, signingKeys } from './keys.js'; import { type KeyStore, StorageKeyStore } from './key.js'; import { type AuthorizationCodeRecord, type CodeStore, hashAuthorizationCode, StorageCodeStore } from './authorization-code.js'; import { hashRefreshToken, type RefreshRecord, type RefreshStore, StorageRefreshStore } from './refresh.js'; import { type DeviceGrantSubject, type DeviceStore, hashDeviceCode, MemoryDeviceStore } from './device.js'; import { validatePKCE } from './pkce.js'; import { generateUnbiasedString, timingSafeCompare } from './random.js'; import { DynamoStorage } from './storage/dynamo.js'; import { MemoryStorage } from './storage/memory.js'; import { Storage, StorageAdapter } from './storage/storage.js'; import { Select } from './ui/select.js'; import { setTheme, Theme } from './ui/theme.js'; 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, Result = { [key in keyof Providers]: Prettify< { provider: key; } & (Providers[key] extends Provider ? T : {}) >; }[keyof Providers] > { /** * The shape of the subjects that you want to return. * * @example * * ```ts title="issuer.ts" * import { object, string } from "valibot" * import { createSubjects } from "@openauthjs/openauth/subject" * * issuer({ * subjects: createSubjects({ * user: object({ * userID: string() * }) * }) * // ... * }) * ``` */ subjects: Subjects; /** * The storage adapter that you want to use. * * @example * ```ts title="issuer.ts" * import { DynamoStorage } from "@openauthjs/openauth/storage/dynamo" * * issuer({ * storage: DynamoStorage() * // ... * }) * ``` */ storage?: StorageAdapter; /** * The providers that you want your OpenAuth server to support. * * @example * * ```ts title="issuer.ts" * import { GithubProvider } from "@openauthjs/openauth/provider/github" * * issuer({ * providers: { * github: GithubProvider() * } * }) * ``` * * The key is just a string that you can use to identify the provider. It's passed back to * the `success` callback. * * You can also specify multiple providers. * * ```ts * { * providers: { * github: GithubProvider(), * google: GoogleProvider() * } * } * ``` */ providers: Providers; /** * The theme you want to use for the UI. * * This includes the UI the user sees when selecting a provider. And the `PasswordUI` and * `CodeUI` that are used by the `PasswordProvider` and `CodeProvider`. * * @example * ```ts title="issuer.ts" * import { THEME_SST } from "@openauthjs/openauth/ui/theme" * * issuer({ * theme: THEME_SST * // ... * }) * ``` * * Or define your own. * * ```ts title="issuer.ts" * import type { Theme } from "@openauthjs/openauth/ui/theme" * * const MY_THEME: Theme = { * // ... * } * * issuer({ * theme: MY_THEME * // ... * }) * ``` */ theme?: Theme; /** * Set the TTL, in seconds, for access and refresh tokens. * * @example * ```ts * { * ttl: { * access: 60 * 60 * 24 * 30, * refresh: 60 * 60 * 24 * 365 * } * } * ``` */ ttl?: { /** * Interval in seconds where the access token is valid. * @default 30d */ access?: number; /** * Interval in seconds where the refresh token is valid. * @default 1y */ refresh?: number; /** * Interval in seconds where refresh token reuse is allowed. This helps mitigrate * concurrency issues. * @default 60s */ reuse?: number; /** * Interval in seconds to retain refresh tokens for reuse detection. * @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; }; /** * Where device authorization grants are kept. * * Defaults to one held in this process's memory, which is right for tests * and for a single local process and wrong for anything else — a grant * created by one instance has to be findable by whichever instance the * browser and the polling client happen to reach. A real deployment passes * a store backed by something shared, and the interface is written so that * store can make each transition a single operation. */ deviceStore?: DeviceStore; /** * Where the issuer's signing and encryption keys are kept. * * Defaults to the generic {@link storage} adapter, under the prefixes it * has always used, so an issuer that does not set this keeps the keys it * already had. Setting it moves the one piece of state here whose loss * invalidates every session at once into somewhere a deployment controls. */ keyStore?: KeyStore; /** * Where authorization codes are kept between the redirect and the exchange. * * Defaults to the generic {@link storage} adapter, which cannot promise a * code is redeemable only once — it reads and removes in two steps, so two * exchanges arriving together are both answered, and each mints a session. * A store that can delete and return in one operation closes that. */ codeStore?: CodeStore; /** * Where refresh tokens are kept. * * Defaults to the generic {@link storage} adapter, with the same weakness: * reuse detection depends on recording when a token was first spent, and * through get and set that record happens after the check rather than as * part of it, so two refreshes arriving together both look like the first. */ refreshStore?: RefreshStore; /** * How hard a caller may guess at user codes before `/device` stops * answering them. * * A user code is short so that a person can read it off one screen and type * it into another, and short means guessable given enough tries. RFC 8628 * §5.2 asks for a limit on the verification endpoint for exactly this * reason. Counted per caller address over a rolling window; a caller who * gets one right is not charged for it. */ deviceVerification?: { /** Wrong codes allowed per window. @default 10 */ guessLimit?: number; /** Length of the window, in seconds. @default 600 */ guessWindow?: number; /** * Which caller a guess is charged to. * * Defaults to the usual forwarded-address headers. Returning undefined * puts the request in one shared bucket, which is the right answer for * a caller whose address cannot be established: it means stripping the * headers buys a smaller budget rather than an unlimited one. */ address?(req: Request): string | undefined; }; /** * Whether a client may start a device authorization grant. * * `/device/authorize` takes no secret — that is what the grant is for — so * without this any caller can mint a grant naming any client identifier, * and that identifier is what the issued token ends up carrying. Returning * false refuses the request. * * Defaults to allowing everything, which preserves the behaviour of an * issuer that has not thought about it, and is worth thinking about. */ allowDeviceClient?(clientID: string, req: Request): Promise; /** * Optionally, configure the UI that's displayed when the user visits the root URL of the * of the OpenAuth server. * * ```ts title="issuer.ts" * import { Select } from "@openauthjs/openauth/ui/select" * * issuer({ * select: Select({ * providers: { * github: { hide: true }, * google: { display: "Google" } * } * }) * // ... * }) * ``` * * @default Select() */ select?(providers: Record, req: Request): Promise; /** * @internal */ start?(req: Request): Promise; /** * The success callback that's called when the user completes the flow. * * This is called after the user has been redirected back to your app after the OAuth flow. * * @example * ```ts * { * success: async (ctx, value) => { * let userID * if (value.provider === "password") { * console.log(value.email) * userID = ... // lookup user or create them * } * if (value.provider === "github") { * console.log(value.tokenset.access) * userID = ... // lookup user or create them * } * return ctx.subject("user", { * userID * }) * }, * // ... * } * ``` */ success( response: OnSuccessResponder>, input: Result, req: Request ): Promise; /** * @internal */ error?(error: UnknownStateError, req: Request): Promise; /** * Override the logic for whether a client request is allowed to call the issuer. * * By default, it uses the following: * * - Allow if the `redirectURI` is localhost. * - Compare `redirectURI` to the request's hostname or the `x-forwarded-host` header. If they * are from the same sub-domain level, then allow. * * @example * ```ts * { * allow: async (input, req) => { * // Allow all clients * return true * } * } * ``` */ allow?( input: { clientID: string; redirectURI: string; audience?: string; }, req: Request ): Promise; } /** * Create an OpenAuth server, a Hono app. */ export function issuer< Providers extends Record>, Subjects extends SubjectSchema, Result = { [key in keyof Providers]: Prettify< { provider: key; } & (Providers[key] extends Provider ? T : {}) >; }[keyof Providers] >(input: IssuerInput) { const error = input.error ?? function (err) { return new Response(err.message, { status: 400, headers: { 'Content-Type': 'text/plain' } }); }; const ttlAccess = input.ttl?.access ?? 60 * 60 * 24 * 30; 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; const deviceStore = input.deviceStore ?? MemoryDeviceStore(); const deviceGuessLimit = input.deviceVerification?.guessLimit ?? 10; const deviceGuessWindow = input.deviceVerification?.guessWindow ?? 600; const deviceAddress = input.deviceVerification?.address ?? ((req: Request) => req.headers.get('cf-connecting-ip') ?? req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? undefined); if (input.theme) { setTheme(input.theme); } const select = lazy(() => input.select ?? Select()); const allow = lazy( () => input.allow ?? (async (input: any, req: Request) => { const redir = new URL(input.redirectURI).hostname; if (redir === 'localhost' || redir === '127.0.0.1') { return true; } const forwarded = req.headers.get('x-forwarded-host'); const host = forwarded ? new URL(`https://${forwarded}`).hostname : new URL(req.url).hostname; return isDomainMatch(redir, host); }) ); let storage = input.storage; if (process.env.OPENAUTH_STORAGE) { const parsed = JSON.parse(process.env.OPENAUTH_STORAGE); if (parsed.type === 'dynamo') storage = DynamoStorage(parsed.options); if (parsed.type === 'memory') storage = MemoryStorage(); if (parsed.type === 'cloudflare') throw new Error( 'Cloudflare storage cannot be configured through env because it requires bindings.' ); } if (!storage) throw new Error( 'Store is not configured. Either set the `storage` option or set `OPENAUTH_STORAGE` environment variable.' ); const keyStore = input.keyStore ?? StorageKeyStore(storage); const codeStore = input.codeStore ?? StorageCodeStore(storage); const refreshStore = input.refreshStore ?? StorageRefreshStore(storage); const allSigning = lazy(() => signingKeys(keyStore)); const allEncryption = lazy(() => encryptionKeys(keyStore)); const signingKey = lazy(() => allSigning().then((all) => all[0])); const encryptionKey = lazy(() => allEncryption().then((all) => all[0])); const auth: Omit, 'name'> = { async success(ctx: Context, properties: any, successOpts) { return await input.success( { async subject(type, properties, subjectOpts) { let authorization: AuthorizationState | null = null; try { authorization = await getAuthorization(ctx); } catch (e) { if (!(e instanceof UnknownStateError)) throw e; // Non-browser provider (SSH, etc.) — no OAuth state; issue tokens directly. } const subject = subjectOpts?.subject ? subjectOpts.subject : await resolveSubject(type, properties); await successOpts?.invalidate?.(await resolveSubject(type, properties)); if (authorization?.device_code) { // A device grant has nowhere to redirect to, and it is // also not finished. Signing in says who this browser // is; it does not say that the person meant to hand an // account to whatever program is holding the other half // of this code. Those are two different questions and // only the second one authorizes anything, so what // happens here is a page that asks it. await auth.unset(ctx, 'authorization'); const grant = await deviceStore.byDeviceCode(authorization.device_code); 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 ); } // Carried in an encrypted cookie rather than written to // the grant, so that a request nobody has confirmed // leaves nothing on the record a later poll could // mistake for an answer. const confirmation: DeviceConfirmation = { deviceCode: authorization.device_code, userCode: grant.userCode, clientID: grant.clientID, csrf: generateUnbiasedString(CSRF_ALPHABET, 32), subject: { subject, type: type as string, properties, ttl: { access: subjectOpts?.ttl?.access ?? ttlAccess, refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh } } }; await auth.set(ctx, 'device_confirm', ttlDevice, confirmation); return ctx.html(deviceConfirmPage(confirmation)); } if (authorization) { if (authorization.response_type === 'token') { const location = new URL(authorization.redirect_uri); const tokens = await generateTokens(ctx, { subject, type: type as string, properties, clientID: authorization.client_id, ttl: { access: subjectOpts?.ttl?.access ?? ttlAccess, refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh } }); location.hash = new URLSearchParams({ access_token: tokens.access, refresh_token: tokens.refresh, state: authorization.state || '' }).toString(); await auth.unset(ctx, 'authorization'); return ctx.redirect(location.toString(), 302); } if (authorization.response_type === 'code') { const code = crypto.randomUUID(); await codeStore.create( await hashAuthorizationCode(code), { type, properties, subject, redirectURI: authorization.redirect_uri, clientID: authorization.client_id, pkce: authorization.pkce, ttl: { access: subjectOpts?.ttl?.access ?? ttlAccess, refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh } }, 60 ); const location = new URL(authorization.redirect_uri); location.searchParams.set('code', code); location.searchParams.set('state', authorization.state || ''); await auth.unset(ctx, 'authorization'); return ctx.redirect(location.toString(), 302); } throw new OauthError( 'invalid_request', `Unsupported response_type: ${authorization.response_type}` ); } // Non-browser provider — return tokens as JSON directly. const tokens = await generateTokens(ctx, { subject, type: type as string, properties, clientID: 'ssh', ttl: { access: subjectOpts?.ttl?.access ?? ttlAccess, refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh } }); return ctx.json({ accessToken: tokens.access, refreshToken: tokens.refresh, expiresIn: tokens.expiresIn }); } }, { provider: ctx.get('provider'), ...properties }, ctx.req.raw ); }, forward(ctx, response) { return ctx.newResponse( response.body, response.status as any, Object.fromEntries(response.headers.entries()) ); }, async set(ctx, key, maxAge, value) { setCookie(ctx, key, await encrypt(value), { maxAge, httpOnly: true, ...(ctx.req.url.startsWith('https://') ? { secure: true, sameSite: 'None' } : {}) }); }, async get(ctx: Context, key: string) { const raw = getCookie(ctx, key); if (!raw) return; return decrypt(raw).catch((ex) => { console.error('failed to decrypt', key, ex); }); }, async unset(ctx: Context, key: string) { deleteCookie(ctx, key); }, async invalidate(subject: string) { await refreshStore.removeSubject(subject); }, storage }; /** * 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; /** Nothing a person reads, so the whole alphabet is available. */ const CSRF_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; /** * What is known after signing in and before confirming. * * This is the half of the flow that has no answer yet: a browser that has * proved who it belongs to, holding a code it has not said yes to. It is * kept in an encrypted cookie rather than on the grant so that a person who * closes the tab at this point has authorized nothing. */ interface DeviceConfirmation { /** The hash, which is all this side of the flow ever sees. */ deviceCode: string; userCode: string; clientID: string; csrf: string; subject: DeviceGrantSubject; } /** * How many user codes this caller has got wrong lately. * * Kept in the general-purpose store rather than with the grants, because it * is a counter and not a grant, and because being approximate is fine here: * the number that matters is whether somebody is working through the code * space, and a handful either way does not change the answer. A caller * spread across several addresses gets a budget per address, which is what * makes the limit worth having rather than a way to lock one person out. */ async function chargeGuess(req: Request): Promise { const who = deviceAddress(req) ?? 'unknown'; const key = ['oauth:device:guess', who]; const now = Date.now(); const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, key); const next = bucket && bucket.resetAt > now ? { count: bucket.count + 1, resetAt: bucket.resetAt } : { count: 1, resetAt: now + deviceGuessWindow * 1000 }; await Storage.set( storage!, key, next, Math.max(1, Math.ceil((next.resetAt - now) / 1000)) ); return next.count <= deviceGuessLimit; } async function guessesLeft(req: Request): Promise { const who = deviceAddress(req) ?? 'unknown'; const bucket = await Storage.get<{ count: number; resetAt: number }>(storage!, [ 'oauth:device:guess', who ]); if (!bucket || bucket.resetAt <= Date.now()) return true; return bucket.count < deviceGuessLimit; } /** * 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(); } /** Enough escaping to put an attacker-chosen client name on a page safely. */ function escapeHtml(raw: string) { return raw .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } /** * The page that asks the only question that authorizes anything. * * It shows the code back, because that is the check a person can actually * perform: the code here and the code on the device in front of them either * match or they do not, and if they do not then somebody else sent this * link. Approving is a POST carrying a value that was put in the cookie * alongside it, so a page on another site cannot submit it on their behalf. */ function deviceConfirmPage(confirmation: DeviceConfirmation) { const code = escapeHtml(confirmation.userCode); const client = escapeHtml(confirmation.clientID); return ( `` + `Confirm sign-in` + `

Is this you?

` + `

${client} is asking to sign in to your account.

` + `

The code it is showing you should be:

` + `

${code.slice(0, 4)}-${code.slice(4)}

` + `

If those do not match, or you did not start this on a device of your own, ` + `choose Deny. Nobody can sign in as you unless you approve here.

` + `
` + `` + ` ` + `` + `
` ); } async function getAuthorization(ctx: Context) { const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization'); if (!match) throw new UnknownStateError(); return match as AuthorizationState; } async function encrypt(value: any) { return await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(value))) .setProtectedHeader({ alg: 'RSA-OAEP-512', enc: 'A256GCM' }) .encrypt(await encryptionKey().then((k) => k.public)); } async function resolveSubject(type: string, properties: any) { const jsonString = JSON.stringify(properties); const encoder = new TextEncoder(); const data = encoder.encode(jsonString); const hashBuffer = await crypto.subtle.digest('SHA-1', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); return `${type}:${hashHex.slice(0, 16)}`; } async function generateTokens( ctx: Context, value: { type: string; properties: any; subject: string; clientID: string; ttl: { access: number; refresh: number; }; timeUsed?: number; nextToken?: string; }, opts?: { generateRefreshToken?: boolean; } ) { const refreshToken = value.nextToken ?? crypto.randomUUID(); if (opts?.generateRefreshToken ?? true) { /** * Generate and store the next refresh token after the one we are currently returning. * Reserving these in advance avoids concurrency issues with multiple refreshes. * Similar treatment should be given to any other values that may have race conditions, * for example if a jti claim was added to the access token. */ const refreshValue: RefreshRecord = { ...value, nextToken: crypto.randomUUID() }; delete refreshValue.timeUsed; await refreshStore.create( value.subject, await hashRefreshToken(refreshToken), refreshValue, value.ttl.refresh ); } const accessTimeUsed = Math.floor((value.timeUsed ?? Date.now()) / 1000); return { access: await new SignJWT({ mode: 'access', type: value.type, properties: value.properties, aud: value.clientID, iss: issuer(ctx), sub: value.subject }) .setExpirationTime(Math.floor(accessTimeUsed + value.ttl.access)) .setProtectedHeader( await signingKey().then((k) => ({ alg: k.alg, kid: k.id, typ: 'JWT' })) ) .sign(await signingKey().then((item) => item.private)), expiresIn: Math.floor(accessTimeUsed + value.ttl.access - Date.now() / 1000), refresh: [value.subject, refreshToken].join(':') }; } async function decrypt(value: string) { return JSON.parse( new TextDecoder().decode( await compactDecrypt(value, await encryptionKey().then((v) => v.private)).then( (value) => value.plaintext ) ) ); } function issuer(ctx: Context) { return new URL(getRelativeUrl(ctx, '/')).origin; } const app = new Hono<{ Variables: { authorization: AuthorizationState; }; }>().use(logger()); for (const [name, value] of Object.entries(input.providers)) { const route = new Hono(); route.use(async (c, next) => { c.set('provider', name); await next(); }); value.init(route, { name, ...auth }); app.route(`/${name}`, route); } app.get( '/.well-known/jwks.json', cors({ origin: '*', allowHeaders: ['*'], allowMethods: ['GET'], credentials: false }), async (c) => { const all = await allSigning(); return c.json({ keys: all.map((item) => ({ ...item.jwk, alg: item.alg, exp: item.expired ? Math.floor(item.expired.getTime() / 1000) : undefined })) }); } ); app.get( '/.well-known/oauth-authorization-server', cors({ origin: '*', allowHeaders: ['*'], allowMethods: ['GET'], credentials: false }), async (c) => { const iss = issuer(c); return c.json({ 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'], grant_types_supported: [ 'authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT ] }); } ); app.post( '/token', cors({ origin: '*', allowHeaders: ['*'], allowMethods: ['POST'], credentials: false }), async (c) => { const form = await c.req.formData(); const grantType = form.get('grant_type'); if (grantType === 'authorization_code') { const code = form.get('code'); if (!code) return c.json( { error: 'invalid_request', error_description: 'Missing code' }, 400 ); // Taken away before anything is checked, and deliberately not // after. A code is redeemable once, so the operation that // decides which caller gets it has to be the one that removes // it — checking first and removing at the end lets two // exchanges of the same code both pass every check. It also // means a code that fails a check below is spent rather than // left to be tried again, which is what RFC 6749 §4.1.2 asks // for. const payload: AuthorizationCodeRecord | null = await codeStore.consume( await hashAuthorizationCode(code.toString()) ); if (!payload) { return c.json( { error: 'invalid_grant', error_description: 'Authorization code has been used or expired' }, 400 ); } if (payload.redirectURI !== form.get('redirect_uri')) { return c.json( { error: 'invalid_redirect_uri', error_description: 'Redirect URI mismatch' }, 400 ); } if (payload.clientID !== form.get('client_id')) { return c.json( { error: 'unauthorized_client', error_description: 'Client is not authorized to use this authorization code' }, 403 ); } if (payload.pkce) { const codeVerifier = form.get('code_verifier')?.toString(); if (!codeVerifier) return c.json( { error: 'invalid_grant', error_description: 'Missing code_verifier' }, 400 ); if (!(await validatePKCE(codeVerifier, payload.pkce.challenge, payload.pkce.method))) { return c.json( { error: 'invalid_grant', error_description: 'Code verifier does not match' }, 400 ); } } const tokens = await generateTokens(c, payload); return c.json({ access_token: tokens.access, expires_in: tokens.expiresIn, refresh_token: tokens.refresh }); } if (grantType === 'refresh_token') { const refreshToken = form.get('refresh_token'); if (!refreshToken) return c.json( { error: 'invalid_request', error_description: 'Missing refresh_token' }, 400 ); const splits = refreshToken.toString().split(':'); const token = splits.pop()!; const subject = splits.join(':'); const at = Date.now(); // Spending the token and finding out whether it had already // been spent are one operation. Split into a read and a write // they are the race that reuse detection exists to catch: two // refreshes arriving together both read an unspent token, both // mint a session, and neither is ever reported. const claim = await refreshStore.claim( subject, await hashRefreshToken(token), at, ttlRefreshReuse <= 0 ? 0 : ttlRefreshReuse + ttlRefreshRetention ); if (claim.status === 'missing') { return c.json( { error: 'invalid_grant', error_description: 'Refresh token has been used or expired' }, 400 ); } // Reuse inside the window is tolerated so that a client that // fired two refreshes at once gets the same answer twice // instead of losing its session. Past it, the only explanation // left is that someone else has the token, so every session the // subject has goes. if (claim.status === 'reused' && at > claim.timeUsed + ttlRefreshReuse * 1000) { await auth.invalidate(subject); return c.json( { error: 'invalid_grant', error_description: 'Refresh token has been used or expired' }, 400 ); } // The access token is dated from when the refresh token was // first spent, not from now — so the second answer inside the // reuse window is the same session, and not a quietly extended // one. const payload: RefreshRecord = { ...claim.record, timeUsed: claim.status === 'fresh' ? at : claim.timeUsed }; const tokens = await generateTokens(c, payload, { generateRefreshToken: claim.status === 'fresh' }); return c.json({ access_token: tokens.access, refresh_token: tokens.refresh, expires_in: tokens.expiresIn }); } if (grantType === DEVICE_GRANT) { const deviceCode = form.get('device_code')?.toString(); const clientID = form.get('client_id')?.toString(); if (!deviceCode) return c.json( { error: 'invalid_request', error_description: 'Missing device_code' }, 400 ); if (!clientID) return c.json( { error: 'invalid_request', error_description: 'Missing client_id' }, 400 ); const hash = await hashDeviceCode(deviceCode); const grant = await deviceStore.byDeviceCode(hash); // 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 deviceStore.remove(hash); return c.json( { error: 'expired_token', error_description: 'The device code has expired' }, 400 ); } // The code belongs to the program that asked for it. Without // this, a code leaked to anybody at all is redeemable by // anybody at all, and the client identifier the token ends up // carrying is whatever the last caller claimed. if (grant.clientID !== clientID) { return c.json( { error: 'invalid_grant', error_description: 'That device code belongs to another client' }, 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 deviceStore.remove(hash); 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 deviceStore.recordPoll( hash, grant.lastPolled, Math.min(grant.interval + 5, DEVICE_MAX_INTERVAL) ); return c.json({ error: 'slow_down', error_description: 'Polling too frequently' }, 400); } if (grant.status === 'approved') { // One redemption, and the store is what enforces it: taking // the grant away and reading it are the same operation, so // two polls arriving together cannot both be served. A // device code that keeps working after it has produced // tokens is a bearer token with none of a bearer token's // expiry. const claimed = await deviceStore.consume(hash, clientID); if (!claimed?.subject) { return c.json( { error: 'expired_token', error_description: 'The device code has expired' }, 400 ); } // Minted now rather than at approval, so the lifetime the // client is told about starts when it receives them. Tokens // made when the person clicked would already have been // ageing for however long the next poll took, and a grant // nobody ever collects would have left a usable refresh // token lying in the store. const tokens = await generateTokens(c, { subject: claimed.subject.subject, type: claimed.subject.type, properties: claimed.subject.properties, clientID: claimed.clientID, ttl: claimed.subject.ttl }); return c.json({ access_token: tokens.access, refresh_token: tokens.refresh, expires_in: tokens.expiresIn }); } await deviceStore.recordPoll(hash, now, grant.interval); 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); const match = input.providers[provider.toString()]; if (!match) return c.json({ error: 'invalid `provider` query parameter' }, 400); if (!match.client) return c.json({ error: 'this provider does not support client_credentials' }, 400); const clientID = form.get('client_id'); const clientSecret = form.get('client_secret'); if (!clientID) return c.json({ error: 'missing `client_id` form value' }, 400); if (!clientSecret) return c.json({ error: 'missing `client_secret` form value' }, 400); const response = await match.client({ clientID: clientID.toString(), clientSecret: clientSecret.toString(), params: Object.fromEntries(form) as Record }); return input.success( { async subject(type, properties, opts) { const tokens = await generateTokens(c, { type: type as string, subject: opts?.subject || (await resolveSubject(type, properties)), properties, clientID: clientID.toString(), ttl: { access: opts?.ttl?.access ?? ttlAccess, refresh: opts?.ttl?.refresh ?? ttlRefresh } }); return c.json({ access_token: tokens.access, refresh_token: tokens.refresh }); } }, { provider: provider.toString(), ...response }, c.req.raw ); } throw new Error('Invalid grant_type'); } ); // 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); if (input.allowDeviceClient && !(await input.allowDeviceClient(clientID, c.req.raw))) return c.json( { error: 'invalid_client', error_description: 'Unknown client_id' }, 400 ); // Not `randomUUID`: a device code is the credential the tokens are // handed to, so it gets the same treatment as one — full-width // randomness, and only its hash is written down. const deviceCode = generateUnbiasedString(CSRF_ALPHABET, 43); const deviceCodeHash = await hashDeviceCode(deviceCode); // 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 deviceStore.byUserCode(candidate))) { userCode = candidate; break; } } if (!userCode) return c.json( { error: 'server_error', error_description: 'Could not allocate a user code' }, 500 ); await deviceStore.create({ deviceCodeHash, userCode, clientID, status: 'pending', interval: deviceInterval, lastPolled: 0, expires: Date.now() + ttlDevice * 1000 }); 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. // // Reaching this page authorizes nothing. It starts a sign-in, and the // sign-in ends at a confirmation page — see `/device/confirm`. app.get('/device', async (c) => { const raw = c.req.query('user_code'); if (!raw) { return c.html( `` + `Sign in to a device` + `
` + `` + `` + `` + `
` ); } if (!(await guessesLeft(c.req.raw))) { return c.text('Too many codes tried. Wait a while and start again from the app.', 429); } const found = await deviceStore.byUserCode(canonicalUserCode(raw)); if (!found || found.status !== 'pending' || found.expires <= Date.now()) { // Charged only when the code was wrong. Getting one right costs // nothing, so a person mistyping once and then succeeding is not // walking towards a lockout. await chargeGuess(c.req.raw); 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.clientID, device_code: found.deviceCodeHash } 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 ) ); }); // The step that actually authorizes, and the reason there is one. // // Anybody at all can ask for a device code and be handed a link with the // user code already filled in. If following that link and signing in were // enough, then sending it to somebody would be enough: they would sign in // to what looks like an ordinary prompt, and whoever kept the device code // would poll and collect their tokens. What stops that is not the sign-in, // which the victim performs perfectly well — it is being shown the code and // the program asking, and having to say yes to *that*. // // A POST, because it changes something. Carrying a value from the cookie, // so another site cannot post it on the person's behalf. app.post('/device/confirm', async (c) => { const confirmation = (await auth.get(c, 'device_confirm')) as DeviceConfirmation | undefined; if (!confirmation) { return c.text('That sign-in request has expired. Start it again from the app.', 400); } await auth.unset(c, 'device_confirm'); const form = await c.req.formData().catch(() => null); const csrf = form?.get('csrf')?.toString() ?? ''; if (!timingSafeCompare(confirmation.csrf, csrf)) { return c.text('That form was not the one we sent. Start again from the app.', 400); } if (form?.get('action')?.toString() === 'deny') { await deviceStore.deny(confirmation.deviceCode); return c.text('That sign-in request was refused. You can close this page.'); } // The store decides, not this code. If a refusal got here first the // answer is already given and an approval must not overwrite it. const approved = await deviceStore.approve(confirmation.deviceCode, confirmation.subject); if (!approved) { return c.text('That sign-in request has already been answered.', 400); } return c.text('You are signed in. You can close this page and go back to the app.'); }); app.get('/authorize', async (c) => { const provider = c.req.query('provider'); const response_type = c.req.query('response_type'); const redirect_uri = c.req.query('redirect_uri'); const state = c.req.query('state'); const client_id = c.req.query('client_id'); const audience = c.req.query('audience'); const code_challenge = c.req.query('code_challenge'); const code_challenge_method = c.req.query('code_challenge_method'); const authorization: AuthorizationState = { response_type, redirect_uri, state, client_id, audience, pkce: code_challenge && code_challenge_method ? { challenge: code_challenge, method: code_challenge_method } : undefined } as AuthorizationState; c.set('authorization', authorization); if (!redirect_uri) { return c.text('Missing redirect_uri', { status: 400 }); } if (!response_type) { throw new MissingParameterError('response_type'); } if (!client_id) { throw new MissingParameterError('client_id'); } if (input.start) { await input.start(c.req.raw); } if ( !(await allow()( { clientID: client_id, redirectURI: redirect_uri, audience }, c.req.raw )) ) throw new UnauthorizedClientError(client_id, redirect_uri); await auth.set(c, 'authorization', 60 * 60 * 24, authorization); 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 ) ); }); app.get('/userinfo', async (c) => { const header = c.req.header('Authorization'); if (!header) { return c.json( { error: 'invalid_request', error_description: 'Missing Authorization header' }, 400 ); } const [type, token] = header.split(' '); if (type !== 'Bearer') { return c.json( { error: 'invalid_request', error_description: 'Missing or invalid Authorization header' }, 400 ); } if (!token) { return c.json( { error: 'invalid_request', error_description: 'Missing token' }, 400 ); } const result = await jwtVerify<{ mode: 'access'; type: keyof SubjectSchema; properties: v1.InferInput; }>(token, () => signingKey().then((item) => item.public), { issuer: issuer(c) }); const validated = await input.subjects[result.payload.type]['~standard'].validate( result.payload.properties ); if (!validated.issues && result.payload.mode === 'access') { return c.json(validated.value as SubjectSchema); } return c.json({ error: 'invalid_token', error_description: 'Invalid token' }); }); app.onError(async (err, c) => { console.error(err); if (err instanceof UnknownStateError) { return auth.forward(c, await error(err, c.req.raw)); } // A refused client does not get to choose where the refusal is delivered. // Everything below reports an error by redirecting to the `redirect_uri` // the caller supplied, which is correct once that URI has been approved // and is an open redirector before it has: the check that approves it is // the one that just failed, so honouring it here would turn every // refusal into a redirect to anywhere at all — no sign-in required, on // the hostname people are told to trust with a password. if (err instanceof UnauthorizedClientError) { return c.text(err.description || err.error, 400); } 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); url.searchParams.set('error_description', oauth.description); return c.redirect(url.toString()); }); return app; }