From eedb143b46e01c1ed1eedad7f24484fc7a0bccc8 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 18 Sep 2026 00:16:33 +0300 Subject: [PATCH] refactor(auth): describe sign-in screens as data, draw them in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/auth/package.json | 6 +- packages/auth/script/build.ts | 6 +- packages/auth/src/issuer.ts | 352 ++++++++++++--------- packages/auth/src/provider/apple.ts | 3 + packages/auth/src/provider/code.ts | 23 +- packages/auth/src/provider/cognito.ts | 1 + packages/auth/src/provider/discord.ts | 2 + packages/auth/src/provider/facebook.ts | 3 + packages/auth/src/provider/github.ts | 2 + packages/auth/src/provider/google.ts | 3 + packages/auth/src/provider/jumpcloud.ts | 1 + packages/auth/src/provider/linkedin.ts | 2 + packages/auth/src/provider/microsoft.ts | 3 + packages/auth/src/provider/oauth2.ts | 10 +- packages/auth/src/provider/oidc.ts | 10 +- packages/auth/src/provider/password.ts | 59 ++-- packages/auth/src/provider/provider.ts | 34 +++ packages/auth/src/provider/slack.ts | 2 + packages/auth/src/provider/spotify.ts | 2 + packages/auth/src/provider/steam.ts | 2 + packages/auth/src/provider/twitch.ts | 2 + packages/auth/src/provider/x.ts | 2 + packages/auth/src/provider/yahoo.ts | 2 + packages/auth/src/ui/base.tsx | 45 ++- packages/auth/src/ui/code.ts | 195 ++++++++++++ packages/auth/src/ui/code.tsx | 229 -------------- packages/auth/src/ui/css.ts | 96 ++++++ packages/auth/src/ui/form.tsx | 35 --- packages/auth/src/ui/icon.tsx | 86 ------ packages/auth/src/ui/mark.ts | 51 ++++ packages/auth/src/ui/password.ts | 249 +++++++++++++++ packages/auth/src/ui/password.tsx | 390 ------------------------ packages/auth/src/ui/render.tsx | 372 ++++++++++++++++++++++ packages/auth/src/ui/screen.ts | 189 ++++++++++++ packages/auth/src/ui/select.tsx | 201 ------------ packages/auth/src/ui/theme.ts | 310 ++----------------- packages/auth/test/code.test.ts | 18 +- 37 files changed, 1546 insertions(+), 1452 deletions(-) create mode 100644 packages/auth/src/ui/code.ts delete mode 100644 packages/auth/src/ui/code.tsx delete mode 100644 packages/auth/src/ui/form.tsx delete mode 100644 packages/auth/src/ui/icon.tsx create mode 100644 packages/auth/src/ui/mark.ts create mode 100644 packages/auth/src/ui/password.ts delete mode 100644 packages/auth/src/ui/password.tsx create mode 100644 packages/auth/src/ui/render.tsx create mode 100644 packages/auth/src/ui/screen.ts delete mode 100644 packages/auth/src/ui/select.tsx diff --git a/packages/auth/package.json b/packages/auth/package.json index 70729b14..9ba38a22 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,9 +7,9 @@ "type": "module", "sideEffects": false, "exports": { - "./ui/code": { - "types": "./src/ui/code.tsx", - "import": "./src/ui/code.tsx" + "./ui/render": { + "types": "./src/ui/render.tsx", + "import": "./src/ui/render.tsx" }, "./*": { "types": "./src/*.ts", diff --git a/packages/auth/script/build.ts b/packages/auth/script/build.ts index dd0a5b7f..f184f326 100644 --- a/packages/auth/script/build.ts +++ b/packages/auth/script/build.ts @@ -18,6 +18,10 @@ await Bun.build({ outdir: 'dist/esm', external: [...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies)], root: 'src', - entrypoints: ['./src/ui/base.tsx'] + // The renderer, bundled with the layout and stylesheet it pulls in. It is + // the one entry point whose imports must be followed rather than left + // external, because a consumer replacing the pages still imports this to + // build on it. + entrypoints: ['./src/ui/render.tsx'] }); await $`tsc --outDir dist/types --declaration --emitDeclarationOnly --declarationMap`; diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index ab59f241..c27b5cb8 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -199,39 +199,40 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { compactDecrypt, CompactEncrypt, jwtVerify, SignJWT } from 'jose'; -import { - MissingParameterError, - OauthError, - UnauthorizedClientError, - UnknownStateError -} from './error.js'; -import { encryptionKeys, signingKeys } from './keys.js'; -import { type KeyStore, StorageKeyStore } from './key.js'; import { type AuthorizationCodeRecord, type CodeStore, hashAuthorizationCode, StorageCodeStore } from './authorization-code.js'; -import { - hashRefreshToken, - type RefreshRecord, - type RefreshStore, - StorageRefreshStore -} from './refresh.js'; import { type DeviceGrantSubject, type DeviceStore, hashDeviceCode, MemoryDeviceStore } from './device.js'; +import { + 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 { Select } from './ui/select.js'; -import { setTheme, Theme } from './ui/theme.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 */ @@ -319,37 +320,31 @@ export interface IssuerInput< */ providers: Providers; /** - * The theme you want to use for the UI. + * Per-deployment trim for the built-in screens: title, favicon, brand + * colour, and any stylesheet needed to load a font. * - * This includes the UI the user sees when selecting a provider. And the `PasswordUI` and - * `CodeUI` that are used by the `PasswordProvider` and `CodeProvider`. - * - * @example - * ```ts title="issuer.ts" - * import { THEME_SST } from "@openauthjs/openauth/ui/theme" - * - * issuer({ - * theme: THEME_SST - * // ... - * }) - * ``` - * - * Or define your own. + * 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" - * import type { Theme } from "@openauthjs/openauth/ui/theme" - * - * const MY_THEME: Theme = { - * // ... - * } - * * issuer({ - * theme: MY_THEME + * 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. * @@ -474,26 +469,30 @@ export interface IssuerInput< */ allowDeviceClient?(clientID: string, req: Request): Promise; /** - * Optionally, configure the UI that's displayed when the user visits the root URL of the - * of the OpenAuth server. + * 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" - * import { Select } from "@openauthjs/openauth/ui/select" - * * issuer({ - * select: Select({ - * providers: { - * github: { hide: true }, - * google: { display: "Google" } - * } - * }) + * chooser: { hide: ["steam"], order: ["code", "discord"] } * // ... * }) * ``` - * - * @default Select() */ - select?(providers: Record, req: Request): Promise; + 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 */ @@ -578,13 +577,17 @@ export function issuer< >(input: IssuerInput) { const error = input.error ?? - function (err) { - return new Response(err.message, { - status: 400, - headers: { - 'Content-Type': 'text/plain' - } - }); + 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; @@ -602,11 +605,42 @@ export function issuer< req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? undefined); - if (input.theme) { - setTheme(input.theme); - } + const renderer = input.renderer ?? HtmlRenderer({ theme: input.theme }); - const select = lazy(() => input.select ?? Select()); + /** + * 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 ?? @@ -674,10 +708,7 @@ export function issuer< await auth.unset(ctx, 'authorization'); const grant = await deviceStore.byDeviceCode(authorization.device_code); if (!grant || grant.status !== 'pending' || grant.expires <= Date.now()) { - return ctx.text( - 'That sign-in request has expired. Start it again from the app.', - 400 - ); + return auth.screen(ctx, expired()); } // Carried in an encrypted cookie rather than written to @@ -700,7 +731,7 @@ export function issuer< } }; await auth.set(ctx, 'device_confirm', ttlDevice, confirmation); - return ctx.html(deviceConfirmPage(confirmation)); + return auth.screen(ctx, deviceConfirmScreen(confirmation)); } if (authorization) { if (authorization.response_type === 'token') { @@ -784,6 +815,12 @@ export function issuer< 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, @@ -858,12 +895,7 @@ export function issuer< 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)) - ); + await Storage.set(storage!, key, next, Math.max(1, Math.ceil((next.resetAt - now) / 1000))); return next.count <= deviceGuessLimit; } @@ -888,16 +920,6 @@ export function issuer< return raw.replace(/[^0-9a-zA-Z]/g, '').toUpperCase(); } - /** Enough escaping to put an attacker-chosen client name on a page safely. */ - function escapeHtml(raw: string) { - return raw - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - } - /** * The page that asks the only question that authorizes anything. * @@ -907,27 +929,43 @@ export function issuer< * link. Approving is a POST carrying a value that was put in the cookie * alongside it, so a page on another site cannot submit it on their behalf. */ - function deviceConfirmPage(confirmation: DeviceConfirmation) { - const code = escapeHtml(confirmation.userCode); - const client = escapeHtml(confirmation.clientID); - return ( - `` + - `Confirm sign-in` + - `

Is this you?

` + - `

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

` + - `

The code it is showing you should be:

` + - `

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

` + - `

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

` + - `
` + - `` + - ` ` + - `` + - `
` - ); + /** + * 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 + }; } - async function getAuthorization(ctx: Context) { + 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; @@ -1257,10 +1295,7 @@ export function issuer< 400 ); if (!clientID) - return c.json( - { error: 'invalid_request', error_description: 'Missing client_id' }, - 400 - ); + return c.json({ error: 'invalid_request', error_description: 'Missing client_id' }, 400); const hash = await hashDeviceCode(deviceCode); const grant = await deviceStore.byDeviceCode(hash); @@ -1282,7 +1317,10 @@ export function issuer< // 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' }, + { + error: 'invalid_grant', + error_description: 'That device code belongs to another client' + }, 400 ); } @@ -1428,10 +1466,7 @@ export function issuer< 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 - ); + 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 @@ -1488,19 +1523,32 @@ export function issuer< app.get('/device', async (c) => { const raw = c.req.query('user_code'); if (!raw) { - return c.html( - `` + - `Sign in to a device` + - `
` + - `` + - `` + - `` + - `
` - ); + 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 c.text('Too many codes tried. Wait a while and start again from the app.', 429); + 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)); @@ -1509,7 +1557,13 @@ export function issuer< // nothing, so a person mistyping once and then succeeding is not // walking towards a lockout. await chargeGuess(c.req.raw); - return c.text('That code is not valid any more. Ask the app for a new one.', 400); + 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 = { @@ -1523,15 +1577,7 @@ export function issuer< if (provider) return c.redirect(`/${provider}/authorize`); const providers = Object.keys(input.providers); if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); - return auth.forward( - c, - await select()( - Object.fromEntries( - Object.entries(input.providers).map(([key, value]) => [key, value.type]) - ), - c.req.raw - ) - ); + return auth.screen(c, chooseScreen()); }); // The step that actually authorizes, and the reason there is one. @@ -1549,31 +1595,53 @@ export function issuer< app.post('/device/confirm', async (c) => { const confirmation = (await auth.get(c, 'device_confirm')) as DeviceConfirmation | undefined; if (!confirmation) { - return c.text('That sign-in request has expired. Start it again from the app.', 400); + 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 c.text('That form was not the one we sent. Start again from the app.', 400); + 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 c.text('That sign-in request was refused. You can close this page.'); + 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 c.text('That sign-in request has already been answered.', 400); + return auth.screen(c, { + kind: 'message', + tone: 'danger', + heading: 'Already answered', + body: ['That sign-in request has already been answered.'], + status: 400 + }); } - return c.text('You are signed in. You can close this page and go back to the app.'); + 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) => { + 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'); @@ -1629,15 +1697,7 @@ export function issuer< if (provider) return c.redirect(`/${provider}/authorize`); const providers = Object.keys(input.providers); if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); - return auth.forward( - c, - await select()( - Object.fromEntries( - Object.entries(input.providers).map(([key, value]) => [key, value.type]) - ), - c.req.raw - ) - ); + return auth.screen(c, chooseScreen()); }); app.get('/userinfo', async (c) => { diff --git a/packages/auth/src/provider/apple.ts b/packages/auth/src/provider/apple.ts index ed6a9530..e9fa84cd 100644 --- a/packages/auth/src/provider/apple.ts +++ b/packages/auth/src/provider/apple.ts @@ -51,6 +51,7 @@ * @packageDocumentation */ +import { MARK_APPLE } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; import { OidcProvider, OidcWrappedConfig } from './oidc.js'; @@ -96,6 +97,7 @@ export function AppleProvider(config: AppleConfig) { return Oauth2Provider({ ...restConfig, type: 'apple' as const, + display: { name: 'Apple', icon: MARK_APPLE }, endpoint: { authorization: 'https://appleid.apple.com/auth/authorize', token: 'https://appleid.apple.com/auth/token', @@ -122,6 +124,7 @@ export function AppleOidcProvider(config: AppleOidcConfig) { return OidcProvider({ ...config, type: 'apple' as const, + display: { name: 'Apple', icon: MARK_APPLE }, issuer: 'https://appleid.apple.com' }); } diff --git a/packages/auth/src/provider/code.ts b/packages/auth/src/provider/code.ts index 7b3a2dd5..a55bd1df 100644 --- a/packages/auth/src/provider/code.ts +++ b/packages/auth/src/provider/code.ts @@ -56,6 +56,8 @@ import { Context } from 'hono'; import { generateUnbiasedDigits, generateUnbiasedString, timingSafeCompare } from '../random.js'; import { Storage } from '../storage/storage.js'; +import { MARK_CODE } from '../ui/mark.js'; +import type { Screen } from '../ui/screen.js'; import { Provider } from './provider.js'; export interface CodeProviderConfig< @@ -120,23 +122,18 @@ export interface CodeProviderConfig< */ resendInterval?: number; /** - * The request handler to generate the UI for the code flow. + * What to ask for at each step of the flow. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * - * Also passes in the current `state` of the flow and any `error` that occurred. - * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a {@link Screen} describing the question, not a rendered page. + * Which one is drawn follows from `state`, and `error` says what to say + * above it. */ request: ( req: Request, state: CodeProviderState, form?: FormData, error?: CodeProviderError - ) => Promise; + ) => Promise; /** * Callback to send the pin code to the user. * @@ -242,6 +239,7 @@ export function CodeProvider = Record = Record(c, 'provider', ttl, next); - const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err)); - return resp; + return ctx.screen(c, await config.request(c.req.raw, next, fd, err)); } routes.get('/authorize', async (c) => { @@ -383,7 +380,7 @@ export function CodeProvider = Record { const state = crypto.randomUUID(); diff --git a/packages/auth/src/provider/oidc.ts b/packages/auth/src/provider/oidc.ts index 15d46a8b..b13266c9 100644 --- a/packages/auth/src/provider/oidc.ts +++ b/packages/auth/src/provider/oidc.ts @@ -24,9 +24,16 @@ import { createLocalJWKSet, JSONWebKeySet, jwtVerify } from 'jose'; import { WellKnown } from '../client.js'; import { OauthError } from '../error.js'; import { getRelativeUrl, lazy } from '../util.js'; -import { Provider } from './provider.js'; +import { Provider, type ProviderDisplay } from './provider.js'; export interface OidcConfig { + /** + * How this provider is named and marked on the chooser. + * + * @internal + */ + display?: ProviderDisplay; + /** * @internal */ @@ -123,6 +130,7 @@ export function OidcProvider(config: OidcConfig): Provider<{ id: JWTPayload; cli return { type: config.type || 'oidc', + display: config.display, init(routes, ctx) { routes.get('/authorize', async (c) => { const provider: ProviderState = { diff --git a/packages/auth/src/provider/password.ts b/packages/auth/src/provider/password.ts index e5a9e766..ca765a5c 100644 --- a/packages/auth/src/provider/password.ts +++ b/packages/auth/src/provider/password.ts @@ -5,8 +5,8 @@ import { v1 } from '@standard-schema/spec'; * paired with the `PasswordUI`. * * ```ts - * import { PasswordUI } from "@openauthjs/openauth/ui/password" - * import { PasswordProvider } from "@openauthjs/openauth/provider/password" + * import { PasswordUI } from "@nestri/auth/ui/password" + * import { PasswordProvider } from "@nestri/auth/provider/password" * * export default issuer({ * providers: { @@ -23,25 +23,26 @@ import { v1 } from '@standard-schema/spec'; * }) * ``` * - * Behind the scenes, the `PasswordProvider` expects callbacks that implements request handlers - * that generate the UI for the following. + * Behind the scenes, the `PasswordProvider` asks its config what to put on + * each screen. Each callback returns a `Screen` — a description of what is + * being asked for — and the issuer's renderer decides how it is drawn. * * ```ts * PasswordProvider({ * // ... - * login: (req, form, error) => Promise - * register: (req, state, form, error) => Promise - * change: (req, state, form, error) => Promise + * login: (req, form, error) => Promise + * register: (req, state, form, error) => Promise + * change: (req, state, form, error) => Promise * }) * ``` * - * This allows you to create your own UI for each of these screens. - * * @packageDocumentation */ import { UnknownStateError } from '../error.js'; import { generateUnbiasedDigits, timingSafeCompare } from '../random.js'; import { Storage } from '../storage/storage.js'; +import { MARK_PASSWORD } from '../ui/mark.js'; +import type { Screen } from '../ui/screen.js'; import { Provider } from './provider.js'; /** @@ -64,52 +65,37 @@ export interface PasswordConfig { /** * The request handler to generate the UI for the login screen. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * * In case of an error, this is called again with the `error`. * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a `Screen` describing what to ask for, not a rendered page. */ - login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise; + login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise; /** * The request handler to generate the UI for the register screen. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * * Also passes in the current `state` of the flow and any `error` that occurred. * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a `Screen` describing what to ask for, not a rendered page. */ register: ( req: Request, state: PasswordRegisterState, form?: FormData, error?: PasswordRegisterError - ) => Promise; + ) => Promise; /** * The request handler to generate the UI for the change password screen. * - * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) - * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) - * ojects. - * * Also passes in the current `state` of the flow and any `error` that occurred. * - * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object - * in return. + * Returns a `Screen` describing what to ask for, not a rendered page. */ change: ( req: Request, state: PasswordChangeState, form?: FormData, error?: PasswordChangeError - ) => Promise; + ) => Promise; /** * Callback to send the confirmation pin code to the user. * @@ -268,13 +254,14 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri } return { type: 'password', + display: { name: 'Password', icon: MARK_PASSWORD }, init(routes, ctx) { - routes.get('/authorize', async (c) => ctx.forward(c, await config.login(c.req.raw))); + routes.get('/authorize', async (c) => ctx.screen(c, await config.login(c.req.raw))); routes.post('/authorize', async (c) => { const fd = await c.req.formData(); async function error(err: PasswordLoginError) { - return ctx.forward(c, await config.login(c.req.raw, fd, err)); + return ctx.screen(c, await config.login(c.req.raw, fd, err)); } const email = fd.get('email')?.toString()?.toLowerCase(); if (!email) return error({ type: 'invalid_email' }); @@ -300,7 +287,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri type: 'start' }; await ctx.set(c, 'provider', 60 * 60 * 24, state); - return ctx.forward(c, await config.register(c.req.raw, state)); + return ctx.screen(c, await config.register(c.req.raw, state)); }); routes.post('/register', async (c) => { @@ -311,7 +298,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri async function transition(next: PasswordRegisterState, err?: PasswordRegisterError) { await ctx.set(c, 'provider', 60 * 60 * 24, next); - return ctx.forward(c, await config.register(c.req.raw, next, fd, err)); + return ctx.screen(c, await config.register(c.req.raw, next, fd, err)); } if (action === 'register' && provider.type === 'start') { @@ -386,7 +373,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri redirect }; await ctx.set(c, 'provider', 60 * 60 * 24, state); - return ctx.forward(c, await config.change(c.req.raw, state)); + return ctx.screen(c, await config.change(c.req.raw, state)); }); routes.post('/change', async (c) => { @@ -397,7 +384,7 @@ export function PasswordProvider(config: PasswordConfig): Provider<{ email: stri async function transition(next: PasswordChangeState, err?: PasswordChangeError) { await ctx.set(c, 'provider', 60 * 60 * 24, next); - return ctx.forward(c, await config.change(c.req.raw, next, fd, err)); + return ctx.screen(c, await config.change(c.req.raw, next, fd, err)); } if (action === 'code') { diff --git a/packages/auth/src/provider/provider.ts b/packages/auth/src/provider/provider.ts index 41ed5cf9..fa441f23 100644 --- a/packages/auth/src/provider/provider.ts +++ b/packages/auth/src/provider/provider.ts @@ -1,11 +1,36 @@ import type { Context, Hono } from 'hono'; import { StorageAdapter } from '../storage/storage.js'; +import type { Mark, Screen } from '../ui/screen.js'; export type ProviderRoute = Hono; +/** + * How a provider is offered to a person choosing one. + * + * Declared by the provider rather than looked up by whatever draws the + * chooser. That used to be two hardcoded records inside the rendering code, so + * a provider the library had not been told about rendered as its own lowercase + * identifier with no mark beside it — and there was no way to fix it from + * outside the library. + */ +export interface ProviderDisplay { + /** The name as a person reads it: `GitHub`, not `github`. */ + name: string; + /** Raw SVG for the brand mark, from `ui/mark.ts`. */ + icon?: Mark; +} + export interface Provider { type: string; + /** + * What to call this provider, and what to draw beside it. + * + * Optional because a provider nobody picks from a list — one reached + * directly, or one with no browser in the flow at all — has nothing to + * display. Falling back to `type` is correct there and only there. + */ + display?: ProviderDisplay; init: (route: ProviderRoute, options: ProviderOptions) => void; client?: (input: { clientID: string; @@ -24,6 +49,15 @@ export interface ProviderOptions { } ) => Promise; forward: (ctx: Context, response: Response) => Response; + /** + * Draw a screen and return it as this request's response. + * + * The only way a provider produces a page. It cannot reach the renderer + * itself, which is the point: a provider says what it needs to ask and + * never how it looks, so there is exactly one place that has to agree with + * the stylesheet. + */ + screen: (ctx: Context, screen: Screen) => Response; set: (ctx: Context, key: string, maxAge: number, value: T) => Promise; get: (ctx: Context, key: string) => Promise; unset: (ctx: Context, key: string) => Promise; diff --git a/packages/auth/src/provider/slack.ts b/packages/auth/src/provider/slack.ts index a053bf38..f1d6231f 100644 --- a/packages/auth/src/provider/slack.ts +++ b/packages/auth/src/provider/slack.ts @@ -19,6 +19,7 @@ * @packageDocumentation */ +import { MARK_SLACK } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface SlackConfig extends Oauth2WrappedConfig { @@ -59,6 +60,7 @@ export function SlackProvider(config: SlackConfig) { return Oauth2Provider({ ...config, type: 'slack', + display: { name: 'Slack', icon: MARK_SLACK }, endpoint: { authorization: 'https://slack.com/openid/connect/authorize', token: 'https://slack.com/api/openid.connect.token' diff --git a/packages/auth/src/provider/spotify.ts b/packages/auth/src/provider/spotify.ts index 266fcf68..6326d3db 100644 --- a/packages/auth/src/provider/spotify.ts +++ b/packages/auth/src/provider/spotify.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_SPOTIFY } from '../ui/mark.js'; import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js'; export interface SpotifyConfig extends Oauth2WrappedConfig {} @@ -37,6 +38,7 @@ export function SpotifyProvider(config: SpotifyConfig) { return Oauth2Provider({ ...config, type: 'spotify', + display: { name: 'Spotify', icon: MARK_SPOTIFY }, endpoint: { authorization: 'https://accounts.spotify.com/authorize', token: 'https://accounts.spotify.com/api/token' diff --git a/packages/auth/src/provider/steam.ts b/packages/auth/src/provider/steam.ts index cb565dfd..f5fb0152 100644 --- a/packages/auth/src/provider/steam.ts +++ b/packages/auth/src/provider/steam.ts @@ -1,3 +1,4 @@ +import { MARK_STEAM } from '../ui/mark.js'; import { getRelativeUrl } from '../util.js'; import { Provider } from './provider.js'; @@ -6,6 +7,7 @@ const STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login'; export function SteamProvider(): Provider<{ steamid: string }> { return { type: 'steam', + display: { name: 'Steam', icon: MARK_STEAM }, init(routes, ctx) { routes.get('/authorize', async (c) => { const returnUrl = getRelativeUrl(c, './callback'); diff --git a/packages/auth/src/provider/twitch.ts b/packages/auth/src/provider/twitch.ts index 2523b36c..603206ad 100644 --- a/packages/auth/src/provider/twitch.ts +++ b/packages/auth/src/provider/twitch.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_TWITCH } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface TwitchConfig extends Oauth2WrappedConfig {} @@ -36,6 +37,7 @@ export interface TwitchConfig extends Oauth2WrappedConfig {} export function TwitchProvider(config: TwitchConfig) { return Oauth2Provider({ type: 'twitch', + display: { name: 'Twitch', icon: MARK_TWITCH }, ...config, endpoint: { authorization: 'https://id.twitch.tv/oauth2/authorize', diff --git a/packages/auth/src/provider/x.ts b/packages/auth/src/provider/x.ts index 398cdfe0..c48e3963 100644 --- a/packages/auth/src/provider/x.ts +++ b/packages/auth/src/provider/x.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_X } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface XProviderConfig extends Oauth2WrappedConfig {} @@ -37,6 +38,7 @@ export function XProvider(config: XProviderConfig) { return Oauth2Provider({ ...config, type: 'x', + display: { name: 'X', icon: MARK_X }, endpoint: { authorization: 'https://twitter.com/i/oauth2/authorize', token: 'https://api.x.com/2/oauth2/token' diff --git a/packages/auth/src/provider/yahoo.ts b/packages/auth/src/provider/yahoo.ts index 84058709..0abfa05c 100644 --- a/packages/auth/src/provider/yahoo.ts +++ b/packages/auth/src/provider/yahoo.ts @@ -17,6 +17,7 @@ * @packageDocumentation */ +import { MARK_YAHOO } from '../ui/mark.js'; import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; export interface YahooConfig extends Oauth2WrappedConfig {} @@ -37,6 +38,7 @@ export function YahooProvider(config: YahooConfig) { return Oauth2Provider({ ...config, type: 'yahoo', + display: { name: 'Yahoo', icon: MARK_YAHOO }, endpoint: { authorization: 'https://api.login.yahoo.com/oauth2/request_auth', token: 'https://api.login.yahoo.com/oauth2/get_token' diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx index 2b312d15..508510a4 100644 --- a/packages/auth/src/ui/base.tsx +++ b/packages/auth/src/ui/base.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren } from 'hono/jsx'; import css from './css.js'; -import { getTheme } from './theme.js'; +import type { Theme } from './theme.js'; /** * The page every sign-in screen is drawn inside. @@ -20,12 +20,25 @@ import { getTheme } from './theme.js'; */ export function Layout( props: PropsWithChildren<{ + theme?: Theme; size?: 'small'; + /** + * Replaces the product tagline above the content. + * + * A screen that asks its own question — "Is this you?", "That code has + * expired" — says it here, because the tagline is the wrong line to + * read above an answer somebody has to give. + */ + headline?: unknown; }> ) { - const theme = getTheme(); + // Passed in rather than read from a module global. It was a global — with a + // comment conceding as much — which made every component here 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. + const theme = props.theme; - function get(key: 'primary' | 'background' | 'logo', mode: 'light' | 'dark') { + function get(key: 'primary' | 'logo', mode: 'light' | 'dark') { if (!theme) return; if (!theme[key]) return; if (typeof theme[key] === 'string') return theme[key]; @@ -66,18 +79,20 @@ export function Layout(
-

- One place for all the ways you play.{' '} - - Gather Around - - . - - -

+ {props.headline ?? ( +

+ One place for all the ways you play.{' '} + + Gather Around + + . + + +

+ )}
{props.children}
diff --git a/packages/auth/src/ui/code.ts b/packages/auth/src/ui/code.ts new file mode 100644 index 00000000..f0058cef --- /dev/null +++ b/packages/auth/src/ui/code.ts @@ -0,0 +1,195 @@ +/** + * The screens for the pin code provider. + * + * ```ts + * import { CodeUI } from "@nestri/auth/ui/code" + * import { CodeProvider } from "@nestri/auth/provider/code" + * + * export default issuer({ + * providers: { + * code: CodeProvider( + * CodeUI({ + * copy: { code_info: "We'll send a pin code to your email" }, + * sendCode: (claims, code) => console.log(claims.email, code) + * }) + * ) + * } + * }) + * ``` + * + * What this file contains is copy and flow — which alert belongs to which + * error, what the two steps ask for. It contains no markup, because how a + * screen is drawn is the renderer's business and describing it here is what + * made every provider its own little design system. + * + * @packageDocumentation + */ + +import type { CodeProviderOptions } from '../provider/code.js'; +import type { Alert, Copy, Screen } from './screen.js'; + +const DEFAULT_COPY = { + /** + * Copy for the email input. + */ + email_placeholder: 'Email', + /** + * Error message when the email is invalid. + */ + email_invalid: 'Email address is not valid', + /** + * Copy for the continue button. + */ + button_continue: 'Continue', + /** + * Copy informing that the pin code will be emailed. + */ + code_info: "We'll send a pin code to your email.", + /** + * Copy for the pin code input. + */ + code_placeholder: 'Code', + /** + * Error message when the code is invalid. + */ + code_invalid: 'Invalid code', + /** + * Copy for when the code was sent. + */ + code_sent: 'Code sent to ', + /** + * Copy for when the code was resent. + */ + code_resent: 'Code resent to ', + /** + * Copy for the link to resend the code. + */ + code_didnt_get: "Didn't get code?", + /** + * Copy for the resend button. + */ + code_resend: 'Resend', + /** + * Error message when too many codes have been asked for, or too many + * guesses made. Deliberately one message for both: which of the two it was + * is a fact about somebody else's mailbox. + */ + rate_limited: 'Too many attempts. Wait a moment and start again.', + /** + * The consent line under the action, split around its two links so the + * sentence stays one translatable run rather than being glued together + * from fragments in the markup. + */ + terms_before: + 'By continuing, you acknowledge that you have read and understood, and agree to Nestri’s ', + terms_label: 'Terms & Conditions', + terms_url: 'https://nestri.io/terms', + terms_between: ' and ', + privacy_label: 'Privacy Policy', + privacy_url: 'https://nestri.io/privacy', + terms_after: '.' +}; + +export type CodeUICopy = typeof DEFAULT_COPY; + +export interface CodeUIOptions { + /** + * Callback to send the pin code to the user. + * + * The `claims` object contains the email or phone number of the user. + */ + sendCode: (claims: Record, code: string) => Promise; + /** + * Custom copy for the UI. + */ + copy?: Partial; + /** + * The mode to use for the input. + * @default "email" + */ + mode?: 'email' | 'phone'; +} + +/** + * Creates the screens for the code provider flow. + * @param props - Configure the screens. + */ +export function CodeUI(props: CodeUIOptions): CodeProviderOptions { + const copy = { ...DEFAULT_COPY, ...props.copy }; + const mode = props.mode ?? 'email'; + + const terms: Copy = [ + copy.terms_before, + { text: copy.terms_label, href: copy.terms_url, external: true }, + copy.terms_between, + { text: copy.privacy_label, href: copy.privacy_url, external: true }, + copy.terms_after + ]; + + return { + sendCode: props.sendCode, + length: 6, + request: async (_req, state, _form, error): Promise => { + const alerts: Alert[] = []; + if (error?.type === 'invalid_claim') + alerts.push({ tone: 'danger', message: copy.email_invalid }); + if (error?.type === 'rate_limit') alerts.push({ tone: 'danger', message: copy.rate_limited }); + + if (state.type === 'start') { + return { + kind: 'form', + alerts, + fields: [ + { kind: 'hidden', name: 'action', value: 'request' }, + { + kind: mode === 'email' ? 'email' : 'tel', + name: mode === 'email' ? 'email' : 'phone', + label: copy.email_placeholder, + autocomplete: mode === 'email' ? 'email' : 'tel', + autofocus: true + } + ], + submit: copy.button_continue, + footer: terms + }; + } + + if (error?.type === 'invalid_code') + alerts.push({ tone: 'danger', message: copy.code_invalid }); + // Said after any error, so a person who mistyped still sees which + // address the code they are looking for actually went to. + alerts.push({ + tone: 'success', + message: (state.resend ? copy.code_resent : copy.code_sent) + state.claims[mode] + }); + + return { + kind: 'form', + alerts, + fields: [ + { kind: 'hidden', name: 'action', value: 'verify' }, + { + kind: 'segments', + name: 'code', + label: copy.code_placeholder, + length: 6, + numeric: true, + autocomplete: 'one-time-code', + autofocus: true + } + ], + submit: copy.button_continue, + aside: { + prompt: copy.code_didnt_get, + submit: copy.code_resend, + fields: [ + ...Object.entries(state.claims).map( + ([name, value]) => ({ kind: 'hidden', name, value }) as const + ), + { kind: 'hidden', name: 'action', value: 'request' } + ] + } + }; + } + }; +} diff --git a/packages/auth/src/ui/code.tsx b/packages/auth/src/ui/code.tsx deleted file mode 100644 index 9d0561ae..00000000 --- a/packages/auth/src/ui/code.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Configure the UI that's used by the Code provider. - * - * ```ts {1,7-12} - * import { CodeUI } from "@openauthjs/openauth/ui/code" - * import { CodeProvider } from "@openauthjs/openauth/provider/code" - * - * export default issuer({ - * providers: { - * code: CodeAdapter( - * CodeUI({ - * copy: { - * code_info: "We'll send a pin code to your email" - * }, - * sendCode: (claims, code) => console.log(claims.email, code) - * }) - * ) - * }, - * // ... - * }) - * ``` - * - * @packageDocumentation - */ -/** @jsxImportSource hono/jsx */ - -import { UnknownStateError } from '../error.js'; -import { CodeProviderOptions } from '../provider/code.js'; -import { Layout } from './base.js'; -import { FormAlert } from './form.js'; - -const DEFAULT_COPY = { - /** - * Copy for the email input. - */ - email_placeholder: 'Email', - /** - * Error message when the email is invalid. - */ - email_invalid: 'Email address is not valid', - /** - * Copy for the continue button. - */ - button_continue: 'Continue', - /** - * Copy informing that the pin code will be emailed. - */ - code_info: "We'll send a pin code to your email.", - /** - * Copy for the pin code input. - */ - code_placeholder: 'Code', - /** - * Error message when the code is invalid. - */ - code_invalid: 'Invalid code', - /** - * Copy for when the code was sent. - */ - code_sent: 'Code sent to ', - /** - * Copy for when the code was resent. - */ - code_resent: 'Code resent to ', - /** - * Copy for the link to resend the code. - */ - code_didnt_get: "Didn't get code?", - /** - * Copy for the resend button. - */ - code_resend: 'Resend', - /** - * Error message when too many codes have been asked for, or too many - * guesses made. Deliberately one message for both: which of the two it was - * is a fact about somebody else's mailbox. - */ - rate_limited: 'Too many attempts. Wait a moment and start again.', - /** - * The consent line under the action, split around its two links so the - * sentence stays one translatable run rather than being glued together - * from fragments in the markup. - */ - terms_before: - 'By continuing, you acknowledge that you have read and understood, and agree to Nestri\u2019s ', - terms_label: 'Terms & Conditions', - terms_url: 'https://nestri.io/terms', - terms_between: ' and ', - privacy_label: 'Privacy Policy', - privacy_url: 'https://nestri.io/privacy', - terms_after: '.' -}; - -export type CodeUICopy = typeof DEFAULT_COPY; - -/** - * Configure the password UI. - */ -export interface CodeUIOptions { - /** - * Callback to send the pin code to the user. - * - * The `claims` object contains the email or phone number of the user. You can send the code - * using this. - * - * @example - * ```ts - * async (claims, code) => { - * // Send the code via the claim - * } - * ``` - */ - sendCode: (claims: Record, code: string) => Promise; - /** - * Custom copy for the UI. - */ - copy?: Partial; - /** - * The mode to use for the input. - * @default "email" - */ - mode?: 'email' | 'phone'; -} - -/** - * Creates a UI for the Code provider flow. - * @param props - Configure the UI. - */ -export function CodeUI(props: CodeUIOptions): CodeProviderOptions { - const copy = { - ...DEFAULT_COPY, - ...props.copy - }; - - const mode = props.mode ?? 'email'; - - return { - sendCode: props.sendCode, - length: 6, - request: async (_req, state, _form, error): Promise => { - if (state.type === 'start') { - const jsx = ( - -
- {error?.type === 'invalid_claim' && } - {error?.type === 'rate_limit' && } - - - - -

- {copy.terms_before} - - {copy.terms_label} - - {copy.terms_between} - - {copy.privacy_label} - - {copy.terms_after} -

-
- ); - return new Response(jsx.toString(), { - headers: { - 'Content-Type': 'text/html' - } - }); - } - - if (state.type === 'code') { - const jsx = ( - -
- {error?.type === 'invalid_code' && } - {error?.type === 'rate_limit' && } - {state.type === 'code' && ( - - )} - - - - -
- {Object.entries(state.claims).map(([key, value]) => ( - - ))} - -
- - {copy.code_didnt_get} - -
-
-
- ); - return new Response(jsx.toString(), { - headers: { - 'Content-Type': 'text/html' - } - }); - } - - throw new UnknownStateError(); - } - }; -} diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index cba715e0..f866b85e 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -401,6 +401,102 @@ body { display: none; } +/* The secondary action: a way in that is not the one being recommended, and + the only button that does not want to be the brightest thing on the page. */ +[data-component='button'][data-color='ghost'] { + background: var(--color-background-100); + border: 1px solid var(--color-gray-300); + color: var(--color-foreground); + text-decoration: none; + gap: 0.625rem; +} + +[data-component='button'][data-color='ghost']:hover { + background: var(--color-gray-100); + border-color: var(--color-gray-400); +} + +[data-component='button'] [data-slot='icon'] { + display: flex; + height: 1.25rem; + width: 1.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +[data-component='button'] [data-slot='icon'] svg { + height: 100%; + width: 100%; +} + +/* A code shown back to be compared against one on another screen. Tracked out + and monospaced because the whole job of this line is that two people looking + at two devices can tell whether the characters are the same. */ +[data-component='verify'] { + margin: 0 0 1.25rem; + width: 100%; + text-align: center; + font-family: var(--font-mona); + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 700; + letter-spacing: 0.15em; + font-variant-numeric: tabular-nums; + color: var(--color-foreground); + pointer-events: auto; + user-select: text; +} + +[data-component='prose'] { + width: 100%; + margin-bottom: 1.5rem; + text-align: center; + text-wrap: pretty; + color: var(--color-muted-foreground); + pointer-events: auto; + user-select: text; +} + +[data-component='prose'] p { + margin: 0 0 0.75rem; +} + +[data-component='prose'] p:last-child { + margin-bottom: 0; +} + +[data-component='prose'] a { + color: var(--color-foreground); + text-decoration: underline; + text-underline-offset: 0.125rem; +} + +[data-component='prose'][data-tone='danger'] { + color: var(--color-muted-foreground); +} + +/* The same field whether the code arrived by email or is showing on a + television across the room: wide, tracked out, and never autocorrected. */ +[data-component='input'][data-variant='code'] { + text-align: center; + font-family: var(--font-mona); + font-size: 1.5rem; + line-height: 2rem; + font-weight: 700; + letter-spacing: 0.35em; + /* Tracking adds a trailing gap after the last glyph, which pushes the run + visibly off-centre; half the tracking back as padding cancels it. */ + padding-left: calc(1.25rem + 0.35em); + font-variant-numeric: tabular-nums; +} + +[data-component='input'][data-variant='code']::placeholder { + letter-spacing: normal; + font-size: 1rem; + font-weight: 400; +} + @media (min-width: 40rem) { [data-component='stack'] { padding: 2.5rem; diff --git a/packages/auth/src/ui/form.tsx b/packages/auth/src/ui/form.tsx deleted file mode 100644 index 90b35a88..00000000 --- a/packages/auth/src/ui/form.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** @jsxImportSource hono/jsx */ - -export function FormAlert(props: { message?: string; color?: 'danger' | 'success' }) { - return ( -
- - - - - - - {props.message} -
- ); -} diff --git a/packages/auth/src/ui/icon.tsx b/packages/auth/src/ui/icon.tsx deleted file mode 100644 index 1005bdd7..00000000 --- a/packages/auth/src/ui/icon.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** @jsxImportSource hono/jsx */ - -export const ICON_GITHUB = ( - - - -); - -export const ICON_GOOGLE = ( - - - - - - -); - -export const ICON_EMAIL = ( - - - -); - -export const ICON_SLACK = ( - - - - - - - - -); diff --git a/packages/auth/src/ui/mark.ts b/packages/auth/src/ui/mark.ts new file mode 100644 index 00000000..48b6f189 --- /dev/null +++ b/packages/auth/src/ui/mark.ts @@ -0,0 +1,51 @@ +/** + * Brand marks, as raw SVG. + * + * Each provider names its own mark from `provider/*.ts`, which is why these are + * strings rather than JSX: a provider says what it is called and what it looks + * like, and does it without importing a rendering library. Before this, the + * marks lived in a record inside the code that drew the chooser, so a provider + * that record had never heard of rendered as a bare lowercase word — and the + * only way to fix that was to edit the library doing the drawing. + * + * `fill="currentColor"` wherever the mark is monochrome, so it takes the colour + * of the control it sits in. The few that are not — the ones whose brand *is* + * the colours — keep theirs. + * + * These are constants written here and never assembled from anything a request + * carries, which is what makes them safe to inject as markup. + * + * @packageDocumentation + */ + +/** A keypad: the mark for the pin code provider. */ +export const MARK_CODE = ``; + +/** A padlock: the mark for the password provider. */ +export const MARK_PASSWORD = ``; + +export const MARK_DISCORD = ``; + +export const MARK_GITHUB = ``; + +export const MARK_GOOGLE = ``; + +export const MARK_APPLE = ``; + +export const MARK_X = ``; + +export const MARK_FACEBOOK = ``; + +export const MARK_MICROSOFT = ``; + +export const MARK_TWITCH = ``; + +export const MARK_SLACK = ``; + +export const MARK_SPOTIFY = ``; + +export const MARK_LINKEDIN = ``; + +export const MARK_STEAM = ``; + +export const MARK_YAHOO = ``; diff --git a/packages/auth/src/ui/password.ts b/packages/auth/src/ui/password.ts new file mode 100644 index 00000000..e49be6b9 --- /dev/null +++ b/packages/auth/src/ui/password.ts @@ -0,0 +1,249 @@ +/** + * The screens for the password provider. + * + * ```ts + * import { PasswordUI } from "@nestri/auth/ui/password" + * import { PasswordProvider } from "@nestri/auth/provider/password" + * + * export default issuer({ + * providers: { + * password: PasswordProvider( + * PasswordUI({ + * copy: { error_email_taken: "This email is already taken." }, + * sendCode: (email, code) => console.log(email, code) + * }) + * ) + * } + * }) + * ``` + * + * Six screens across three flows, and not one of them mentions a colour, a + * class name or a tag. That is the difference the {@link Screen} boundary + * makes: this file was markup for six pages, drifting from the design language + * every time the design language moved, and none of it was noticed because + * nobody had turned password sign-in on yet. + * + * @packageDocumentation + */ + +import type { + PasswordChangeError, + PasswordChangeState, + PasswordConfig, + PasswordLoginError, + PasswordRegisterError, + PasswordRegisterState +} from '../provider/password.js'; +import type { Alert, Field, Screen } from './screen.js'; + +const DEFAULT_COPY = { + /** Error message when email is already taken. */ + error_email_taken: 'There is already an account with this email.', + /** Error message when the confirmation code is incorrect. */ + error_invalid_code: 'Code is incorrect.', + /** Error message when the email is invalid. */ + error_invalid_email: 'Email is not valid.', + /** Error message when the password is incorrect. */ + error_invalid_password: 'Password is incorrect.', + /** Error message when the passwords do not match. */ + error_password_mismatch: 'Passwords do not match.', + /** Error message when the user enters a password that fails validation. */ + error_validation_error: 'Password does not meet requirements.', + /** Copy for the register button. */ + register: 'Register', + /** Copy for the register link. */ + register_prompt: "Don't have an account?", + /** Copy for the login link. */ + login_prompt: 'Already have an account?', + /** Copy for the login button. */ + login: 'Login', + /** Copy for the forgot password link. */ + change_prompt: 'Forgot password?', + /** Copy for the resend code button. */ + code_resend: 'Resend code', + /** Copy for the "Back to" link. */ + code_return: 'Back to', + /** Copy for the email input. */ + input_email: 'Email', + /** Copy for the password input. */ + input_password: 'Password', + /** Copy for the code input. */ + input_code: 'Code', + /** Copy for the repeat password input. */ + input_repeat: 'Repeat password', + /** Copy for the continue button. */ + button_continue: 'Continue' +} satisfies { + [key in `error_${ + | PasswordLoginError['type'] + | PasswordRegisterError['type'] + | PasswordChangeError['type']}`]: string; +} & Record; + +export type PasswordUICopy = typeof DEFAULT_COPY; + +export interface PasswordUIOptions extends Pick { + /** + * Custom copy for the UI. + */ + copy?: Partial; +} + +/** + * Creates the screens for the password provider flow. + * @param input - Configure the screens. + */ +export function PasswordUI(input: PasswordUIOptions): PasswordConfig { + const copy = { ...DEFAULT_COPY, ...input.copy }; + + /** + * The banner for whatever just went wrong. + * + * One function for all three flows because the error types overlap almost + * entirely, and a `validation_error` carries its own message — the only + * case where the provider knows better than the copy table what to say. + */ + function alerts( + error?: PasswordLoginError | PasswordRegisterError | PasswordChangeError + ): Alert[] { + if (!error) return []; + if (error.type === 'validation_error') { + return [{ tone: 'danger', message: error.message || copy.error_validation_error }]; + } + return [{ tone: 'danger', message: copy[`error_${error.type}`] }]; + } + + const codeField: Field = { + kind: 'segments', + name: 'code', + label: copy.input_code, + length: 6, + numeric: true, + autocomplete: 'one-time-code', + autofocus: true + }; + + return { + validatePassword: input.validatePassword, + sendCode: input.sendCode, + + login: async (_req, _form, error): Promise => ({ + kind: 'form', + alerts: alerts(error), + fields: [ + { + kind: 'email', + name: 'email', + label: copy.input_email, + autocomplete: 'email', + autofocus: true + }, + { + kind: 'password', + name: 'password', + label: copy.input_password, + autocomplete: 'current-password' + } + ], + submit: copy.button_continue, + links: [ + { prompt: copy.register_prompt, link: { label: copy.register, href: 'register' } }, + { link: { label: copy.change_prompt, href: 'change' } } + ] + }), + + register: async (_req, state: PasswordRegisterState, _form, error): Promise => { + if (state.type === 'code') { + return { + kind: 'form', + alerts: alerts(error), + fields: [{ kind: 'hidden', name: 'action', value: 'verify' }, codeField], + submit: copy.button_continue, + links: [{ prompt: copy.code_return, link: { label: copy.login, href: 'authorize' } }] + }; + } + + return { + kind: 'form', + alerts: alerts(error), + fields: [ + { kind: 'hidden', name: 'action', value: 'register' }, + { + kind: 'email', + name: 'email', + label: copy.input_email, + autocomplete: 'email', + autofocus: true + }, + { + kind: 'password', + name: 'password', + label: copy.input_password, + autocomplete: 'new-password' + }, + { + kind: 'password', + name: 'repeat', + label: copy.input_repeat, + autocomplete: 'new-password' + } + ], + submit: copy.button_continue, + links: [{ prompt: copy.login_prompt, link: { label: copy.login, href: 'authorize' } }] + }; + }, + + change: async (_req, state: PasswordChangeState, _form, error): Promise => { + if (state.type === 'code') { + return { + kind: 'form', + alerts: alerts(error), + fields: [{ kind: 'hidden', name: 'action', value: 'verify' }, codeField], + submit: copy.button_continue, + links: [{ prompt: copy.code_return, link: { label: copy.login, href: 'authorize' } }] + }; + } + + if (state.type === 'update') { + return { + kind: 'form', + alerts: alerts(error), + fields: [ + { kind: 'hidden', name: 'action', value: 'update' }, + { + kind: 'password', + name: 'password', + label: copy.input_password, + autocomplete: 'new-password', + autofocus: true + }, + { + kind: 'password', + name: 'repeat', + label: copy.input_repeat, + autocomplete: 'new-password' + } + ], + submit: copy.button_continue + }; + } + + return { + kind: 'form', + alerts: alerts(error), + fields: [ + { kind: 'hidden', name: 'action', value: 'code' }, + { + kind: 'email', + name: 'email', + label: copy.input_email, + autocomplete: 'email', + autofocus: true + } + ], + submit: copy.button_continue, + links: [{ prompt: copy.code_return, link: { label: copy.login, href: 'authorize' } }] + }; + } + }; +} diff --git a/packages/auth/src/ui/password.tsx b/packages/auth/src/ui/password.tsx deleted file mode 100644 index d345a0ad..00000000 --- a/packages/auth/src/ui/password.tsx +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Configure the UI that's used by the Password provider. - * - * ```ts {1,7-12} - * import { PasswordUI } from "@openauthjs/openauth/ui/password" - * import { PasswordProvider } from "@openauthjs/openauth/provider/password" - * - * export default issuer({ - * providers: { - * password: PasswordAdapter( - * PasswordUI({ - * copy: { - * error_email_taken: "This email is already taken." - * }, - * sendCode: (email, code) => console.log(email, code) - * }) - * ) - * }, - * // ... - * }) - * ``` - * - * @packageDocumentation - */ -/** @jsxImportSource hono/jsx */ - -import { - PasswordChangeError, - PasswordConfig, - PasswordLoginError, - PasswordRegisterError -} from '../provider/password.js'; -import { Layout } from './base.js'; -import './form.js'; -import { FormAlert } from './form.js'; - -const DEFAULT_COPY = { - /** - * Error message when email is already taken. - */ - error_email_taken: 'There is already an account with this email.', - /** - * Error message when the confirmation code is incorrect. - */ - error_invalid_code: 'Code is incorrect.', - /** - * Error message when the email is invalid. - */ - error_invalid_email: 'Email is not valid.', - /** - * Error message when the password is incorrect. - */ - error_invalid_password: 'Password is incorrect.', - /** - * Error message when the passwords do not match. - */ - error_password_mismatch: 'Passwords do not match.', - /** - * Error message when the user enters a password that fails validation. - */ - error_validation_error: 'Password does not meet requirements.', - /** - * Title of the register page. - */ - register_title: 'Welcome to the app', - /** - * Description of the register page. - */ - register_description: 'Sign in with your email', - /** - * Title of the login page. - */ - login_title: 'Welcome to the app', - /** - * Description of the login page. - */ - login_description: 'Sign in with your email', - /** - * Copy for the register button. - */ - register: 'Register', - /** - * Copy for the register link. - */ - register_prompt: "Don't have an account?", - /** - * Copy for the login link. - */ - login_prompt: 'Already have an account?', - /** - * Copy for the login button. - */ - login: 'Login', - /** - * Copy for the forgot password link. - */ - change_prompt: 'Forgot password?', - /** - * Copy for the resend code button. - */ - code_resend: 'Resend code', - /** - * Copy for the "Back to" link. - */ - code_return: 'Back to', - /** - * Copy for the logo. - * @internal - */ - logo: 'A', - /** - * Copy for the email input. - */ - input_email: 'Email', - /** - * Copy for the password input. - */ - input_password: 'Password', - /** - * Copy for the code input. - */ - input_code: 'Code', - /** - * Copy for the repeat password input. - */ - input_repeat: 'Repeat password', - /** - * Copy for the continue button. - */ - button_continue: 'Continue' -} satisfies { - [key in `error_${ - | PasswordLoginError['type'] - | PasswordRegisterError['type'] - | PasswordChangeError['type']}`]: string; -} & Record; - -type PasswordUICopy = typeof DEFAULT_COPY; - -/** - * Configure the password UI. - */ -export interface PasswordUIOptions extends Pick { - /** - * Custom copy for the UI. - */ - copy?: Partial; -} - -/** - * Creates a UI for the Password provider flow. - * @param input - Configure the UI. - */ -export function PasswordUI(input: PasswordUIOptions): PasswordConfig { - const copy = { - ...DEFAULT_COPY, - ...input.copy - }; - return { - validatePassword: input.validatePassword, - sendCode: input.sendCode, - login: async (_req, form, error): Promise => { - const jsx = ( - -
- - - - -
- - {copy.register_prompt}{' '} - - {copy.register} - - - - {copy.change_prompt} - -
- -
- ); - return new Response(jsx.toString(), { - status: error ? 401 : 200, - headers: { - 'Content-Type': 'text/html' - } - }); - }, - register: async (_req, state, form, error): Promise => { - const emailError = ['invalid_email', 'email_taken'].includes(error?.type || ''); - const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes( - error?.type || '' - ); - const jsx = ( - -
- - {state.type === 'start' && ( - <> - - - - - -
- - {copy.login_prompt}{' '} - - {copy.login} - - -
- - )} - - {state.type === 'code' && ( - <> - - - - - )} - -
- ) as string; - return new Response(jsx.toString(), { - headers: { - 'Content-Type': 'text/html' - } - }); - }, - change: async (_req, state, form, error): Promise => { - const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes( - error?.type || '' - ); - const jsx = ( - -
- - {state.type === 'start' && ( - <> - - - - )} - {state.type === 'code' && ( - <> - - - - )} - {state.type === 'update' && ( - <> - - - - - )} - - - {state.type === 'code' && ( -
- - - {state.type === 'code' && ( -
- - {copy.code_return}{' '} - - {copy.login.toLowerCase()} - - - -
- )} -
- )} -
- ); - return new Response(jsx.toString(), { - status: error ? 400 : 200, - headers: { - 'Content-Type': 'text/html' - } - }); - } - }; -} diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx new file mode 100644 index 00000000..9f9e0317 --- /dev/null +++ b/packages/auth/src/ui/render.tsx @@ -0,0 +1,372 @@ +/** + * The one place that turns a {@link Screen} into markup. + * + * Everything that knows what a button looks like is in this file. A provider + * describes what it needs, this decides how it is drawn, and the two are + * swappable independently — which is the property the previous arrangement did + * not have, because each provider returned a finished `Response` and therefore + * had an opinion about markup. + * + * The components below are deliberately not exported. A `data-component` + * attribute is a contract with the stylesheet and nothing else should be + * writing one: it is a string, so a typo in it is silent, and the whole reason + * to have typed components is that nobody adding a screen ever types one again. + * + * @packageDocumentation + */ +/** @jsxImportSource hono/jsx */ + +import { Layout } from './base.js'; +import type { + Alert, + ChooseScreen, + ConfirmScreen, + Copy, + Field, + FormScreen, + Mark, + MessageScreen, + Screen +} from './screen.js'; +import type { Theme } from './theme.js'; + +/** + * Draws a screen. + * + * One method, on purpose. It is the entire boundary between what the auth flow + * needs to ask and how it is presented, so replacing the presentation wholesale + * means implementing this and nothing else. + */ +export interface Renderer { + render(screen: Screen, req: Request): Response; +} + +export interface HtmlRendererOptions { + /** + * Page title, favicon, brand colour and any extra stylesheet. + * + * Held in the closure rather than in a module global, so two renderers with + * two themes can exist at once and a component can be rendered in a test + * without arranging global state first. + */ + theme?: Theme; +} + +/** The default renderer: server-rendered HTML, no client-side script. */ +export function HtmlRenderer(options?: HtmlRendererOptions): Renderer { + const theme = options?.theme; + + return { + render(screen, _req) { + const body = (() => { + switch (screen.kind) { + case 'choose': + return ; + case 'form': + return
; + case 'confirm': + return ; + case 'message': + return ; + } + })(); + + // The doctype is prepended rather than being part of the tree + // because the JSX runtime will not emit one, and without it every + // one of these pages renders in quirks mode. + return new Response(`${body.toString()}`, { + status: screen.status ?? 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + }; +} + +/* -------------------------------------------------------------------------- */ +/* Screens */ +/* -------------------------------------------------------------------------- */ + +function Choose(props: { theme?: Theme; screen: ChooseScreen }) { + return ( + +
+ {props.screen.options.map((option) => ( + + {option.mark && } + {option.label} + + ))} +
+ {props.screen.footer &&