mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-22 02:35:25 +03:00
Providers no longer return a `Response`. Each one says what it needs from the person — an address, a pin, a yes-or-no — as a `Screen`, and a single `Renderer` decides how that is drawn. The old arrangement made every provider a small web framework. It had to know about markup, about the stylesheet's attribute names, about how a page is assembled, so each grew its own callback signature and its own copy of `new Response(jsx.toString())`. Three consequences, all of them visible in the tree before this change: - The device flow never got a design at all. Its two pages were built by concatenating HTML strings, with an inline `style` on the user code, and six of its replies were `text/plain` — unstyled black-on-white in the middle of signing in, which is also what a person got when their sign-in cookie expired. - The password screens were drifting. They were written against attribute names the stylesheet no longer had, and nobody noticed because password sign-in is not switched on. They are deleted here rather than repaired; the flow is now six screen descriptions and no markup. - A provider could not be named or marked without editing the library. The brand marks and display names were two hardcoded records inside the code that drew the chooser, so anything missing from them rendered as its own lowercase identifier with no icon. Providers now declare `display` themselves, and the chooser is built from what they say. Also removes the theme global. It was `globalThis`, with a comment conceding as much, which made every component depend on something invisible at the call site — untestable in isolation, and shared mutable state on a runtime that keeps one module instance across requests. The theme is now a closure argument, and the same change shrinks `Theme` to the handful of values a deployment sets that the stylesheet cannot. Adding a screen now touches no CSS, and swapping the presentation layer means implementing one method. The code flow's tests demonstrate the second: they render screens as JSON. Behaviour is unchanged. Status codes, cookies and the confirmation step are the same, which the device tests cover unmodified.
1792 lines
55 KiB
TypeScript
1792 lines
55 KiB
TypeScript
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.
|
|
*
|
|
* <Tabs>
|
|
* <TabItem label="Node">
|
|
* ```ts title="issuer.ts"
|
|
* import { serve } from "@hono/node-server"
|
|
*
|
|
* serve(app)
|
|
* ```
|
|
* </TabItem>
|
|
* <TabItem label="Lambda">
|
|
* ```ts title="issuer.ts"
|
|
* import { handle } from "hono/aws-lambda"
|
|
*
|
|
* export const handler = handle(app)
|
|
* ```
|
|
* </TabItem>
|
|
* <TabItem label="Bun">
|
|
* ```ts title="issuer.ts"
|
|
* export default app
|
|
* ```
|
|
* </TabItem>
|
|
* <TabItem label="Workers">
|
|
* ```ts title="issuer.ts"
|
|
* export default app
|
|
* ```
|
|
* </TabItem>
|
|
* </Tabs>
|
|
*
|
|
* @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<T extends { type: string; properties: any }> {
|
|
/**
|
|
* 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 extends T['type']>(
|
|
type: Type,
|
|
properties: Extract<T, { type: Type }>['properties'],
|
|
opts?: {
|
|
ttl?: {
|
|
access?: number;
|
|
refresh?: number;
|
|
};
|
|
subject?: string;
|
|
}
|
|
): Promise<Response>;
|
|
}
|
|
|
|
/**
|
|
* @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<T> = {
|
|
[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<string, Provider<any>>,
|
|
Subjects extends SubjectSchema,
|
|
Result = {
|
|
[key in keyof Providers]: Prettify<
|
|
{
|
|
provider: key;
|
|
} & (Providers[key] extends Provider<infer T> ? 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<boolean>;
|
|
/**
|
|
* 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<void>;
|
|
/**
|
|
* 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<SubjectPayload<Subjects>>,
|
|
input: Result,
|
|
req: Request
|
|
): Promise<Response>;
|
|
/**
|
|
* @internal
|
|
*/
|
|
error?(error: UnknownStateError, req: Request): Promise<Response>;
|
|
/**
|
|
* 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<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Create an OpenAuth server, a Hono app.
|
|
*/
|
|
export function issuer<
|
|
Providers extends Record<string, Provider<any>>,
|
|
Subjects extends SubjectSchema,
|
|
Result = {
|
|
[key in keyof Providers]: Prettify<
|
|
{
|
|
provider: key;
|
|
} & (Providers[key] extends Provider<infer T> ? T : {})
|
|
>;
|
|
}[keyof Providers]
|
|
>(input: IssuerInput<Providers, Subjects, Result>) {
|
|
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<ProviderOptions<any>, '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<boolean> {
|
|
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<boolean> {
|
|
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<any>();
|
|
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<string, string>
|
|
});
|
|
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<SubjectSchema[keyof SubjectSchema]>;
|
|
}>(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;
|
|
}
|