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 {
type AuthorizationCodeRecord,
type CodeStore,
hashAuthorizationCode,
StorageCodeStore
} from './authorization-code.js';
import {
type DeviceGrantSubject,
type DeviceStore,
hashDeviceCode,
MemoryDeviceStore
} from './device.js';
import {
MissingParameterError,
OauthError,
UnauthorizedClientError,
UnknownStateError
} from './error.js';
import { type KeyStore, StorageKeyStore } from './key.js';
import { encryptionKeys, signingKeys } from './keys.js';
import { validatePKCE } from './pkce.js';
import { generateUnbiasedString, timingSafeCompare } from './random.js';
import {
hashRefreshToken,
type RefreshRecord,
type RefreshStore,
StorageRefreshStore
} from './refresh.js';
import { DynamoStorage } from './storage/dynamo.js';
import { MemoryStorage } from './storage/memory.js';
import { Storage, StorageAdapter } from './storage/storage.js';
import { HtmlRenderer, type Renderer } from './ui/render.js';
import type { ChooseOption, Screen } from './ui/screen.js';
import type { 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;
/**
* Per-deployment trim for the built-in screens: title, favicon, brand
* colour, and any stylesheet needed to load a font.
*
* Ignored when {@link IssuerInput.renderer} is supplied, because a renderer
* that was handed a theme would have two sources for the same values.
*
* ```ts title="issuer.ts"
* issuer({
* theme: { title: "Login | Example", primary: "hsl(12 84% 53%)" }
* // ...
* })
* ```
*/
theme?: Theme;
/**
* Draws every screen this issuer serves.
*
* The whole presentation layer behind one method. Supply this to replace
* the built-in pages outright — it is the only thing that has to change,
* because providers describe what they need as data and never render
* anything themselves.
*
* @default HtmlRenderer({ theme })
*/
renderer?: Renderer;
/**
* 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;
/**
* Which providers appear on the screen offering a choice of them, and in
* what order.
*
* What each one is *called*, and the mark beside it, comes from the
* provider itself — so adding one needs nothing here. This is only for the
* two decisions a deployment makes that a provider cannot: whether to offer
* it at all, and what to put first.
*
* ```ts title="issuer.ts"
* issuer({
* chooser: { hide: ["steam"], order: ["code", "discord"] }
* // ...
* })
* ```
*/
chooser?: {
/** Providers to leave off the screen, by their key in `providers`. */
hide?: string[];
/**
* Providers to put first, by key. Anything not named keeps its order
* from `providers` and follows.
*/
order?: string[];
};
/**
* @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: UnknownStateError, req: Request) {
return renderer.render(
{
kind: 'message',
tone: 'danger',
heading: 'That sign-in has expired',
body: [err.message, 'Start again from wherever you were signing in.'],
status: 400
},
req
);
};
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);
const renderer = input.renderer ?? HtmlRenderer({ theme: input.theme });
/**
* The screen offering a choice of providers.
*
* Built from what each provider says about itself. Nothing here knows the
* name of a single provider, which is the property worth keeping: this was
* two hardcoded records in the rendering code, and a provider missing from
* them appeared as its own bare identifier with no way to fix it short of
* editing the library.
*/
function chooseScreen(): Screen {
const hidden = new Set(input.chooser?.hide ?? []);
const first = input.chooser?.order ?? [];
const options: ChooseOption[] = Object.keys(input.providers)
.filter((key) => !hidden.has(key))
// Stable, so anything `order` does not name keeps the order it was
// declared in rather than being shuffled by the comparator.
.sort((a, b) => {
const ai = first.indexOf(a);
const bi = first.indexOf(b);
if (ai === bi) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
})
.map((key) => {
const provider = input.providers[key]!;
return {
href: `/${key}/authorize`,
label: `Continue with ${provider.display?.name ?? provider.type}`,
mark: provider.display?.icon
};
});
return { kind: 'choose', options };
}
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 auth.screen(ctx, expired());
}
// 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 auth.screen(ctx, deviceConfirmScreen(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())
);
},
screen(ctx, screen) {
// Forwarded rather than returned directly so that cookies set
// earlier in the handler survive onto the response. Every page this
// issuer serves goes through here.
return auth.forward(ctx, renderer.render(screen, ctx.req.raw));
},
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();
}
/**
* 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.
*/
/**
* What a device grant says once there is nothing left to answer.
*
* Written once because three different dead ends reach it — a cookie that
* timed out, a grant that expired, a confirmation that was already given —
* and the person on the other end can do the same one thing about all
* three.
*/
function expired(): Screen {
return {
kind: 'message',
tone: 'danger',
heading: 'That sign-in request has expired',
body: ['Start it again from the app.'],
status: 400
};
}
function deviceConfirmScreen(confirmation: DeviceConfirmation): Screen {
return {
kind: 'confirm',
heading: 'Is this you?',
verify: { code: confirmation.userCode, group: 4 },
body: [
`${confirmation.clientID} is asking to sign in to your account. The code above should match the one it is showing you.`,
'If it does not, or you did not start this on a device of your own, choose Deny. Nobody can sign in as you unless you approve here.'
],
action: '/device/confirm',
// The client id is escaped by the renderer like any other text. It
// is chosen by whoever started the grant, so it is never markup.
fields: [{ kind: 'hidden', name: 'csrf', value: confirmation.csrf }],
approve: { label: 'Approve', name: 'action', value: 'approve' },
deny: { label: 'Deny', name: 'action', value: 'deny' }
};
}
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 auth.screen(c, {
kind: 'form',
method: 'get',
action: '/device',
fields: [
{
kind: 'segments',
name: 'user_code',
label: 'Enter the code shown in the app',
length: USER_CODE_LENGTH,
autocomplete: 'off',
autofocus: true
}
],
submit: 'Continue'
});
}
if (!(await guessesLeft(c.req.raw))) {
return auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'Too many tries',
body: ['Wait a while, then start again from the app.'],
status: 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 auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'That code is not valid',
body: ['It may have expired, or already been used. Ask the app for a new one.'],
status: 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.screen(c, chooseScreen());
});
// 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 auth.screen(c, expired());
}
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 auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'That form was not the one we sent',
body: ['Start again from the app.'],
status: 400
});
}
if (form?.get('action')?.toString() === 'deny') {
await deviceStore.deny(confirmation.deviceCode);
return auth.screen(c, {
kind: 'message',
tone: 'notice',
heading: 'Refused',
body: ['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 auth.screen(c, {
kind: 'message',
tone: 'danger',
heading: 'Already answered',
body: ['That sign-in request has already been answered.'],
status: 400
});
}
return auth.screen(c, {
kind: 'message',
tone: 'notice',
heading: 'You are signed in',
body: ['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.screen(c, chooseScreen());
});
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;
}