# @udibo/oauth2 > OAuth2/OIDC integration tools for TypeScript applications. Two paths: > connect an app to Udibo's managed identity service, or host authorization > for the app's own users and API. Udibo is in private beta with a waitlist > at https://udibo.com; do not assume public registration is available. ## Integration rules - Choose the path before implementing. A managed-service client needs a callback, BFF/session storage, and API authorization; it does not implement an identity service, password storage, MFA enrollment, or token issuance. - For a browser app with a backend, use HonoBff + BffClient. Keep client secrets, access tokens, and refresh tokens off browser-accessible storage. DirectClient is for the process that owns tokens; omit clientSecret for a public client. - Core servers accept Request/Response. HonoAuthorizationServer and HonoResourceServer supply routing/protect middleware. React guards do not replace API authorization. - Use exported subpaths; there is no package-root barrel or default export. Check the typed API reference for exact signatures. In snippets, declare const marks an app-owned dependency, not an implementation. - Keep PKCE, state, confidential-client authentication, and BFF CSRF enabled. Configure issuers and callbacks from trusted settings, not browser parameters. - Persistent adapters must implement atomic code/OTP consumption and refresh rotation. Stateful SessionStore.update rejects missing, expired, or revoked sessions; it never recreates them. SessionSummary IDs are non-secret row IDs, never cookie values, hashes, or token material. - IdentityUserStore.replaceCredential is optional, atomic compare-and-set for password upgrades. Compare all previous credential fields; undefined expects no native credential. Without it upgrades are skipped. Persist credential.params. - Rate limits, lockout, and bot challenges require explicit configuration. Log-only mode does not enforce. MFA routes need authentication, CSRF, and a pending first-factor state before creating a full session. - Generic OIDC/Google connectors trust direct TLS exchange and validate ID-token claims without signature verification. Apple verifies signatures and uses form_post; its transient cookie requires SameSite=None; Secure. Never use these connectors to accept arbitrary ID tokens from other channels. - Memory stores are development fixtures. EncryptedCookieSessionStore is stateless and cannot immediately revoke a copied cookie. Offline JWT validation cannot immediately observe token revocation. Read known limitations before choosing. - Use exact deployed configuration and the actual store contract tests. The complete suite runs on Deno; Node has import/type smoke coverage for 19 subpaths. Bun is unverified. The CLI is Deno-only and is not a production dependency. - Public docs cover application integration. Do not infer hosted-service admin APIs, tenant platform architecture, pricing, or beta availability from this package. --- ## Full documentation Every page linked from the `## Docs` section of `llms.txt`, concatenated in index order for a single-fetch corpus. Generated by `deno task llms:generate`; do not edit by hand. --- # @udibo/oauth2 OAuth2 and OpenID Connect tools for TypeScript applications. Connect your app to Udibo's identity service, or host an authorization server for your own app. The package includes clients, authorization and resource servers, Hono middleware, React bindings, and optional login flows over your own database. **Udibo's managed identity service is in private beta.** [Join the waitlist](https://udibo.com). The self-hosted examples run locally without a hosted account. ## Choose your path | Your application needs | Start here | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Udibo to handle sign-in | [Use Udibo's identity service](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md) — private-beta access required | | Its own login and authorization server | [Run the quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md), then [host authorization for your app](https://github.com/udibo/oauth2/blob/main/docs/guides/become-an-oauth-provider.md) | | An API that accepts access tokens | [Protect an API](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md) | | A React application with server-held tokens | [Juniper starter](https://github.com/udibo/oauth2/blob/main/templates/juniper/README.md) or [React Router starter](https://github.com/udibo/oauth2/blob/main/templates/react-router/README.md) | [Documentation index](https://github.com/udibo/oauth2/blob/main/docs/index.md) · [API reference](https://jsr.io/@udibo/oauth2/doc) · [Examples](https://github.com/udibo/oauth2/blob/main/README.md#examples) · [Agent documentation](https://github.com/udibo/oauth2/blob/main/README.md#for-coding-agents) ## Install Requires Deno 2 for the development workflow: ```sh deno add jsr:@udibo/oauth2@0.1.0 ``` Import the part you use; there is no root barrel or default export: ```ts import { BffClient, DirectClient } from "@udibo/oauth2/client"; import { HonoBff } from "@udibo/oauth2/hono/bff"; import { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; ``` Version `0.1.0` is a pre-1.0 API. Minor releases may contain breaking changes; review the [stability policy](https://github.com/udibo/oauth2/blob/main/docs/stability.md) before upgrading. ## How the pieces fit For a browser app, use a backend-for-frontend (BFF). The browser holds an HttpOnly session cookie; the BFF exchanges authorization codes and keeps access and refresh tokens on the server. `BffClient` connects the browser to the BFF, and the React adapter uses that same client. ```mermaid sequenceDiagram participant Browser participant App as Your app / BFF participant Issuer as Udibo or your app's authorization server participant API as Your API Browser->>App: Start sign-in App-->>Browser: Redirect to authorization endpoint Browser->>Issuer: Sign in Issuer-->>Browser: Redirect back with code and state Browser->>App: Complete callback App->>Issuer: Exchange code with PKCE and client authentication Issuer-->>App: Tokens App-->>Browser: Session cookie Browser->>App: Request application data App->>API: Access token API-->>Browser: Application data, through the BFF ``` `DirectClient` is for a process that holds tokens: your backend, a CLI, a native app, or a public browser client. Supply a client secret only on the server. For a browser app with a backend, start with the BFF guide. `HonoResourceServer` validates access tokens and enforces scopes on API routes. `HonoAuthorizationServer` issues tokens when your app hosts its own authorization server. The framework-independent core accepts standard `Request` and `Response` objects; Hono adapters add routing and middleware. ## Examples From a checkout of this repository: ```sh deno ci deno task serve:app-with-own-auth ``` Open and sign in as `user` / `password`. The [quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md) explains what to try and which files to read. All demo credentials, in-memory stores, and local HTTP settings are for development. | Example | Demonstrates | | ------------------------------------------------------------------------------- | --------------------------------------------------------------- | | [Hono with own auth](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/README.md) | App login, OAuth2 server, BFF, and protected API in one process | | [Hono with external auth](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-external-auth/README.md) | Delegated sign-in, token introspection, and a BFF proxy | | [Standalone API](https://github.com/udibo/oauth2/blob/main/examples/hono/api-service/README.md) | Bearer-token validation without a frontend | | [Juniper with own auth](https://github.com/udibo/oauth2/blob/main/examples/juniper/app-with-own-auth/README.md) | Server-rendered React with application-owned login | | [Juniper with external auth](https://github.com/udibo/oauth2/blob/main/examples/juniper/app-with-external-auth/README.md) | Server-rendered React with delegated sign-in | ## Package entrypoints All paths below start with `@udibo/oauth2`. Each entrypoint has typed API docs and examples in the [reference](https://jsr.io/@udibo/oauth2/doc). | Subpath | Purpose | | ---------------------------- | ----------------------------------------------------------------------------------------------- | | `/client` | `BffClient`, `DirectClient`, token storage, discovery caching | | `/server` | Shared models, errors, scope types, and service contracts | | `/server/authorization` | Authorization server, grants, token/code services, signing keys | | `/server/resource` | Resource server, introspection and JWKS token readers | | `/server/public-suffix` | Public Suffix List checks for optional wildcard redirect registration | | `/hono/authorization-server` | Hono authorization endpoints | | `/hono/resource-server` | Bearer authentication and scope middleware | | `/hono/bff` | BFF routes, server-held tokens, session and pending-login stores | | `/identity` | App-owned signup, login, password reset, email verification, passwordless, and protection hooks | | `/identity/mfa` | TOTP, recovery codes, and MFA storage contract | | `/identity/external` | Social and external OIDC sign-in for your own login pages | | `/identity/migration` | Verify imported password hashes and upgrade them at login | | `/hono/identity` | Hono routes for `IdentityService` | | `/hono/log` | Request logger and URL helper that redact every query value | | `/react` | Context, hooks, authentication guards, and callback handling | | `/react/components` | Optional forms for login, signup, password reset, and MFA | | `/crypto` | Random tokens, hashing, encoding, authenticated encryption | | `/url` | Safe return paths and login continuation | | `/cli` | Local test identity provider and OIDC signing-key generation | | `/testing` | In-memory fixtures and authorization-server test helpers | | `/testing/contract` | Tests for app-owned service and storage implementations | | `/hono/bff/testing` | BFF session fixtures and session-store contract tests | | `/react/testing` | Mock clients and providers for component tests | ## Runtime support The library uses Web APIs. The complete suite runs on Deno. CI also builds an npm-format compatibility artifact and verifies 20 entrypoints with TypeScript and Node; this does not publish an npm package. | Surface | Deno | Node | Browser | Bun | | ------------------------------------------- | --------------------- | ------------------------ | ------------------------------------------- | ------------- | | Clients and React | Tested, including SSR | Import/type smoke-tested | Intended for client code; no secrets | Not verified | | Server, identity, Hono, crypto, URL helpers | Tested | Import/type smoke-tested | Server functionality belongs on the backend | Not verified | | `/hono/bff/testing`, `/react/testing` | Tested | Import/type smoke-tested | React test helpers only | Not verified | | `/testing`, `/testing/contract` | Tested | Not verified | Not supported | Not verified | | `/cli` | Deno only | Not supported | Not supported | Not supported | See [stability](https://github.com/udibo/oauth2/blob/main/docs/stability.md#runtime-support) for the support boundary. ## Before deploying Use the [application deployment guide](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md) and [deployment checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md). Keep PKCE, state, confidential-client authentication, and CSRF checks enabled. Replace development stores with persistent implementations where needed, and run the exported contract tests against those implementations. Read [known limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) for token-revocation, cookie-session, OIDC, and optional-protection behavior. Identity feature examples apply to apps hosting their own login; Udibo clients use the hosted sign-in flow. ## For coding agents Start with [llms.txt](https://github.com/udibo/oauth2/blob/main/llms.txt) for the task-to-guide map and integration rules. Use [llms-full.txt](https://github.com/udibo/oauth2/blob/main/llms-full.txt) when a single documentation download is useful. It is generated from the same guides developers read. Prefer the exported types and [extension reference](https://github.com/udibo/oauth2/blob/main/docs/trigger-points.md) when implementing a custom store or callback. ## Contributing and security Run `deno task check` and `deno task test:all` before proposing changes. See [CONTRIBUTING.md](https://github.com/udibo/oauth2/blob/main/CONTRIBUTING.md) for development conventions, [SECURITY.md](https://github.com/udibo/oauth2/blob/main/SECURITY.md) for private vulnerability reports, and [CHANGELOG.md](https://github.com/udibo/oauth2/blob/main/CHANGELOG.md) for release history. --- # Documentation `@udibo/oauth2` helps you add authentication and authorization to an application. Choose who hosts sign-in first; that determines which parts of the package you need. ## Use Udibo's identity service Udibo hosts the sign-in flow and issues tokens. Your app handles its callback, keeps its own session, and validates access to its API. You do not need to implement password storage, MFA enrollment, or an authorization server. **Private beta:** [join the waitlist](https://udibo.com). If you already have access, start with [the integration guide](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md). Then read [API protection](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md) and [environment configuration](https://github.com/udibo/oauth2/blob/main/docs/guides/deploy-across-environments.md). ## Host authorization for your own app Your app owns its users, login pages, and persistent storage. The package provides OAuth2 protocol handling and optional identity flows. 1. [Run a complete local example](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md). 2. [Configure your authorization server](https://github.com/udibo/oauth2/blob/main/docs/guides/become-an-oauth-provider.md). 3. [Add login over your database](https://github.com/udibo/oauth2/blob/main/docs/guides/add-login.md). 4. [Prepare the application for deployment](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md). Add only the features your application needs: | Feature | Guide | | ------------------------------- | ------------------------------------------------------------- | | Social or enterprise OIDC login | [External sign-in](https://github.com/udibo/oauth2/blob/main/docs/guides/social-sign-in.md) | | TOTP and recovery codes | [MFA](https://github.com/udibo/oauth2/blob/main/docs/guides/add-mfa.md) | | Email codes and magic links | [Passwordless sign-in](https://github.com/udibo/oauth2/blob/main/docs/guides/passwordless.md) | | Existing password hashes | [Password migration](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md) | These guides explain application integrations. Hosted-service administration, commercial platform architecture, and internal Udibo operations are outside this package's documentation. ## Testing and reference - [React integration](https://github.com/udibo/oauth2/blob/main/docs/guides/react.md): session state, rendering guards, and app-owned forms. - [Integration testing](https://github.com/udibo/oauth2/blob/main/docs/guides/testing.md): route fixtures, persistent store contracts, and browser checks. - [Local identity provider](https://github.com/udibo/oauth2/blob/main/docs/guides/run-a-local-identity-provider.md): exercise your client against a local OAuth2/OIDC server without a hosted account. - [Deployment checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md): review the boundaries your chosen integration uses. - [Extension reference](https://github.com/udibo/oauth2/blob/main/docs/trigger-points.md): callback and storage contracts. - [Known limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md): supported behavior and constraints. - [API reference](https://jsr.io/@udibo/oauth2/doc): exported types and methods. - [Versioning and runtime support](https://github.com/udibo/oauth2/blob/main/docs/stability.md). - [Issue triage](https://github.com/udibo/oauth2/blob/main/docs/triage.md) and [security disclosure](https://github.com/udibo/oauth2/blob/main/SECURITY.md). - [Agent index](https://github.com/udibo/oauth2/blob/main/llms.txt) and [complete documentation](https://github.com/udibo/oauth2/blob/main/llms-full.txt). Code fences show one of three things: a runnable command, a self-contained example, or integration wiring that explicitly declares app-owned dependencies. A `declare const` represents something your application must supply; it is not an implementation to paste into production. --- # Use Udibo's identity service Udibo handles sign-in and issues tokens for your application. Your backend completes the OAuth2 callback, maintains an application session, and protects application data. **Udibo is in private beta.** [Join the waitlist](https://udibo.com). The configuration below is for developers who already have beta access. Public registration and its setup instructions will be documented when access opens. To develop without an account, use the [local identity provider](https://github.com/udibo/oauth2/blob/main/docs/guides/run-a-local-identity-provider.md) or the [external-auth example](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-external-auth/README.md). ## What you need Obtain these values for your application through your beta setup: | Value | Use | | ----------------------------------- | ----------------------------------------------------------------------------------- | | Issuer URL | The identity service's issuer, copied exactly; it may differ from your app's origin | | Client ID and secret | A confidential client registration for your backend | | Callback URL | An exact registered URL, such as `https://app.example.com/auth/callback` | | Allowed scopes | Identity claims and API permissions your application may request | | Introspection or JWKS configuration | Token validation for your API; use the mode configured for the application | Use separate registrations and secrets for development and production. Keep client credentials on the backend. The issuer and client ID are identifiers; a client secret is a credential. ## Put the BFF on your app's origin Mount the BFF at `/auth`. The browser talks to your own backend using its session cookie. It never needs your client secret or refresh token. The following is integration wiring. `sessions` is a persistent `SessionStore` you implement for your app; `resourceServer` validates access tokens using the [API protection guide](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md). Both are declared here so the example makes its application-owned dependencies explicit. ```ts import { Hono } from "hono"; import { DirectClient } from "@udibo/oauth2/client"; import { EncryptedCookieAuthRequestStorage, HonoBff, type SessionStore, } from "@udibo/oauth2/hono/bff"; import type { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; import { requestLogger } from "@udibo/oauth2/hono/log"; import type { ClientInterface } from "@udibo/oauth2/server"; declare const sessions: SessionStore; declare const resourceServer: HonoResourceServer; function required(name: string): string { const value = Deno.env.get(name); if (!value) throw new Error(`Missing ${name}`); return value; } const client = new DirectClient({ issuer: required("AUTH_ISSUER"), clientId: required("AUTH_CLIENT_ID"), clientSecret: required("AUTH_CLIENT_SECRET"), redirectUri: required("AUTH_CALLBACK_URL"), }); const bff = new HonoBff({ client, sessionStore: sessions, authRequestStorage: new EncryptedCookieAuthRequestStorage({ secret: required("AUTH_REQUEST_SECRET"), }), resourceServer, scope: required("AUTH_SCOPES"), defaultReturnTo: "/", }); const app = new Hono(); app.use(requestLogger()); app.route("/auth", bff.routes()); app.use("/api/*", bff.protect()); app.get("/api/message", (c) => c.json({ message: "Authenticated" })); export default app; ``` `AUTH_REQUEST_SECRET` is an app-generated high-entropy secret shared by every instance of this application. It protects the pending state and PKCE verifier across the redirect. Store it in your deployment's secret manager. The example keeps the BFF's HTTPS cookie and CSRF defaults enabled. `requestLogger()` redacts every query value on both log lines, including callback `code` and `state`. Configure reverse proxies and tracing separately; this middleware cannot redact logs emitted by other systems. Use `bff.protect("read")` for a scope required throughout an API mount, or layer `resourceServer.requireScope(...)` on individual routes after authentication. Authentication alone does not check whether a user owns a specific record; that authorization check belongs in your application. For a separate API process, use `bff.proxy(fixedApiUrl, options)` and validate tokens in that API. See [the proxy example](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md#when-the-api-is-a-separate-service). ## Connect the browser ```ts import { BffClient } from "@udibo/oauth2/client"; const auth = new BffClient(); const session = await auth.getSession(); if (!session.isAuthenticated) { const { url } = await auth.login({ returnTo: "/" }); location.assign(url); } const response = await auth.fetch("/api/message"); ``` `BffClient` sends the BFF's CSRF header. A custom fetch client must send the same header on credentialed requests. Do not disable CSRF to make a browser request work. React apps can use `OAuth2Provider`, `useOAuth2`, and `RequireAuth` from `@udibo/oauth2/react`; the [external-auth Juniper example](https://github.com/udibo/oauth2/blob/main/examples/juniper/app-with-external-auth/README.md) shows that wiring. A client-side guard controls rendering; your API still requires server-side authorization. The BFF's local logout ends the application session and attempts upstream token revocation. Ending the identity provider's SSO session is a separate operation; configure and verify that behavior for your application if you need it. ## Test the integration Before pointing it at production: - Complete sign-in and sign-out in a real browser with the registered callback. - Verify unauthenticated requests receive `401` and insufficient scopes receive `403`. - Expire an access token and check that a refresh preserves the session. - Revoke the session and check that an in-flight refresh cannot restore it. - Run `runSessionStoreContractTests` from `@udibo/oauth2/hono/bff/testing` against your store. Stateful updates must reject expired, missing, or revoked sessions. - Confirm callbacks work when login and callback requests reach different application instances. ## Troubleshooting | Symptom | Check | | ------------------------------------- | ------------------------------------------------------------------------------------------- | | Callback rejected before sign-in | Exact callback registration: scheme, host, port, path, and query | | `invalid_client` | Backend client ID/secret and whether this is the correct registration | | Unknown state or missing login cookie | Shared pending-login storage, cookie attributes, and whether callback uses the same browser | | Credentialed BFF request gets `403` | CSRF header and same-origin request configuration | | Refresh gives `invalid_grant` | Expiry or revocation; start a new login instead of retrying the credential indefinitely | | API rejects a token | Issuer, intended audience, token format, and required scopes | See [environment configuration](https://github.com/udibo/oauth2/blob/main/docs/guides/deploy-across-environments.md) and [application deployment](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md) for the remaining app-side setup. Hosted-service administration and account provisioning are documented separately from this package. --- # Quickstart: run auth for your application Run the Hono example to see login, an OAuth2 authorization server, a BFF, and a protected API working together. Everything runs locally; no Udibo account, external database, or email service is required. If you want Udibo to host sign-in, use [the managed-service guide](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md). Udibo is currently in private beta with a [waitlist](https://udibo.com). ## Run the example Install Deno 2 and Git, then check out the public package repository: ```sh git clone https://github.com/udibo/oauth2.git cd oauth2 deno ci deno task serve:app-with-own-auth ``` Open . Use `user` / `password` for the regular demo user, or `admin` / `password` to exercise the admin scope. These credentials and all in-memory data belong only to this local demo. ## Follow a sign-in 1. Select **Sign in**. The browser visits the BFF's login route, which starts an authorization-code flow with state and PKCE. 2. Enter a demo user's credentials at the app's login page. 3. Review the consent page. The example limits the requested scopes to the user's allowed scopes. 4. The callback exchanges the code on the server and creates an HttpOnly session cookie. Access and refresh tokens stay in the BFF. 5. Call the API from the homepage. Requests without a session are rejected; the regular user cannot call the admin-scoped endpoint. 6. Sign out and confirm the protected request is rejected again. The example also has signup, password reset, and email-verification pages. Delivery hooks print links to the terminal in place of sending mail. Restarting the process resets users, sessions, codes, and tokens. ## Read the implementation Start with the [example README](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/README.md), then follow these files: | File | What to learn | | --------------------------------------------------------------------------- | -------------------------------------------------------- | | [oauth2/server.ts](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/oauth2/server.ts) | Clients, grants, token storage, and BFF configuration | | [main.ts](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/main.ts) | Endpoint mounting, session lookup, and consent decisions | | [oauth2/identity.ts](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/oauth2/identity.ts) | Login, reset, verification, and delivery hooks | | [sessions.ts](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/sessions.ts) | The example's application session | | [main.test.ts](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/main.test.ts) | Successful flows and refused requests | Run its tests from the repository root: ```sh deno task test:app-with-own-auth ``` ## Start your own application For an existing Hono app, use [host authorization for your app](https://github.com/udibo/oauth2/blob/main/docs/guides/become-an-oauth-provider.md) and [add login](https://github.com/udibo/oauth2/blob/main/docs/guides/add-login.md). They explain which interfaces your database must implement and how to connect your existing session and login pages. For a new React app, start with the [Juniper](https://github.com/udibo/oauth2/blob/main/templates/juniper/README.md) or [React Router](https://github.com/udibo/oauth2/blob/main/templates/react-router/README.md) template. For an API without a browser frontend, use [protect an API](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md). Before deployment, replace the demo stores, credentials, console delivery, and local HTTP settings. Follow the [deployment guide](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md) and [checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md); the demo is not a production configuration. --- # Host authorization for your application Use this guide when your own app needs to authenticate its users and issue OAuth2 tokens for its frontend, API, CLI, or other registered application clients. You provide your users, login pages, and persistent stores; the package handles authorization requests, PKCE, token exchange, refresh, and revocation. If you want Udibo to host sign-in, use [the managed-service guide](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md) instead. This guide covers an application's authorization server, not an identity platform or hosted-service control plane. Start with the [quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md). The runnable reference is [Hono with own auth](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/README.md). The snippets below show how to replace its development configuration with your application's services. `declare const` marks an implementation your app supplies. ## The seams: your entities, your storage The framework owns the protocol; you own the data. Every server and grant is generic over your `Client` and `User` types, and all storage flows through service interfaces you implement against your own database. Your client entity extends `ClientInterface` from `@udibo/oauth2/server` — the minimum the framework inspects: ```ts interface ClientInterface { id: string; grants?: string[]; redirectUris?: string[]; } ``` `grants` lists the grant types the client may use (a grant-type request outside this list is rejected), and `redirectUris` is the allowlist for the authorization-code flow: entries are matched exactly, unless an entry is a wildcard pattern (`https://myapp-*.myorg.deno.net/cb`), which needs the `isPublicSuffix` seam wired or it stays inert. Add whatever else your app needs — name, secret hash, owner — the framework never sees those fields. Three services back the server: - **`ClientServiceInterface`** — `get(id)`, `getAuthenticated(id, secret?)`, and `getUser(client)` (the subject of a client-credentials token). Returning `undefined` is the conformant default: RFC 6749 §4.4 has no resource owner, so the grant issues a token with no user and the client itself is the subject (RFC 9068 §2.2). Return a user only when that user is a principal of its own — a per-application service account. Returning the human who owns the application hands the machine that person's identity, which is the _Client Impersonating Resource Owner_ attack of RFC 9700 §4.15. - **`TokenServiceInterface`** — token generation, storage, and revocation. Extend `AbstractTokenService` (from `@udibo/oauth2/server/authorization`) and implement the five storage methods (`getToken`, `getRefreshToken`, `save`, `revoke`, `revokeCode`); generation, expiry math, and scope acceptance come with overridable defaults. Two optional methods, `getRevokedRefreshToken` and `revokeFamily`, enable refresh-token reuse detection (covered below). - **`AuthorizationCodeServiceInterface`** — single-use code storage for the authorization-code grant; extend `AbstractAuthorizationCodeService`. In-memory implementations of all of these ship in `@udibo/oauth2/testing` (`MemoryClientService`, `MemoryTokenService`, `MemoryAuthorizationCodeService`, `MemoryUserService`) — use them to get running today, then swap in DB-backed implementations and prove them with the contract test runners in `@udibo/oauth2/testing/contract`. ## Construct the server `HonoAuthorizationServer` (from `@udibo/oauth2/hono/authorization-server`) takes the same options as the core `AuthorizationServer` and adds the Hono mounting helpers. Configuration flows through one seam: `resolve(request)` returns the services the server's own endpoints need plus the issuer and endpoint URLs. For an application server, return the configured issuer and persistent services. Each grant resolves its own services the same way. Keep issuer selection in trusted application configuration. ```ts import { HonoAuthorizationServer } from "@udibo/oauth2/hono/authorization-server"; import { AuthorizationCodeGrant, type AuthorizationCodeServiceInterface, type ClientServiceInterface, RefreshTokenGrant, type TokenServiceInterface, } from "@udibo/oauth2/server/authorization"; import type { ClientInterface } from "@udibo/oauth2/server"; interface AppClient extends ClientInterface { id: string; } interface AppUser { id: string; } declare const clientService: ClientServiceInterface; declare const tokenService: TokenServiceInterface; declare const authorizationCodeService: AuthorizationCodeServiceInterface< AppClient, AppUser >; const issuer = "https://auth.example.com"; const services = { clientService, tokenService }; const authServer = new HonoAuthorizationServer({ resolve: () => ({ services, issuer, authorizationEndpoint: `${issuer}/oauth2/authorize`, tokenEndpoint: `${issuer}/oauth2/token`, revocationEndpoint: `${issuer}/oauth2/revoke`, introspectionEndpoint: `${issuer}/oauth2/introspect`, }), grants: { authorization_code: new AuthorizationCodeGrant({ resolve: () => ({ clientService, tokenService, authorizationCodeService, }), allowRefreshToken: true, }), refresh_token: new RefreshTokenGrant({ resolve: () => ({ clientService, tokenService }), }), }, scopesSupported: ["openid", "profile", "email", "read", "write"], }); ``` Any endpoint you omit defaults to `${issuer}${path}` (e.g. `${issuer}/token`), so a server whose endpoints sit at the issuer root needs only `issuer`. The explicit URLs above exist because this guide mounts everything under `/oauth2`. ## Mount the endpoints `routes()` returns a Hono app with every standard endpoint at its conventional path: `POST /token`, `GET /authorize`, `POST /revoke`, `POST /introspect`, `POST /device_authorization`, `GET /.well-known/oauth-authorization-server`, `GET /.well-known/openid-configuration`, `GET /jwks`, and `/userinfo` (GET and POST). The last three go live when OIDC issuance is configured. The one thing `routes()` cannot decide for you is who the user is. The authorize endpoint asks your `authenticateUser` callback, which receives the Hono `Context` and returns one of three things: `{ user }` when a session exists, a `Response` (typically a redirect to your login page) to short-circuit the flow, or `null` for an explicit denial: ```ts import { Hono } from "hono"; import type { Context } from "hono"; import type { HonoAuthorizationServer } from "@udibo/oauth2/hono/authorization-server"; import type { UserServiceInterface } from "@udibo/oauth2/server/authorization"; import type { ClientInterface } from "@udibo/oauth2/server"; interface AppClient extends ClientInterface { id: string; } interface AppUser { id: string; } declare const authServer: HonoAuthorizationServer; declare const userService: UserServiceInterface; declare function readSessionUserId(c: Context): string | undefined; const app = new Hono(); app.route( "/oauth2", authServer.routes({ authenticateUser: async (c) => { const userId = readSessionUserId(c); if (!userId) { const url = new URL(c.req.url); const returnTo = encodeURIComponent(`${url.pathname}${url.search}`); return c.redirect(`/login?return_to=${returnTo}`); } const user = await userService.get(userId); if (!user) { return c.redirect("/login"); } return { user }; }, }), ); ``` The login redirect carries the full authorize URL as `return_to`, so after your login form authenticates the user it sends the browser back to `/oauth2/authorize` with the original query intact and the flow resumes. Reserve `null` for "the user said no" — it redirects to the client's `redirect_uri` with `error=access_denied`. The well-known documents are registered relative to the mount, so the layout above serves discovery at `/oauth2/.well-known/oauth-authorization-server`. Mount `routes()` at the root, or mount the individual handler factories (`tokenHandler()`, `authorizeHandler()`, `metadataHandler()`, …) at custom paths, if you want discovery at the origin-root well-known location. With this much mounted, the authorization-code + PKCE flow works end to end: a client sends the user to `GET /oauth2/authorize` with `response_type=code`, `client_id`, `redirect_uri`, `state`, `scope`, and a S256 `code_challenge`; your login authenticates them; the browser returns to the client's `redirect_uri` with a single-use `code`; and the client exchanges it at `POST /oauth2/token` with its `code_verifier`. ## Security defaults Two protections are on by default, deliberately stricter than RFC 6749: - **`state` is required** at the authorize endpoint. Requests without it are rejected with `invalid_request` rather than degrading CSRF protection to optional. - **PKCE is required** for the authorization-code grant, for all clients including confidential ones, matching OAuth 2.1. The only challenge method registered by default is `S256`. Set `requirePKCE: false` on the `AuthorizationCodeGrant` only if you must support a legacy confidential client that cannot send a challenge. - **The challenge's format is enforced too.** For `S256` and `plain` — the two methods the IANA PKCE registry defines and whose output shape RFC 7636 §4.2 fixes — `/authorize` rejects a `code_challenge` that is not 43–128 characters of `[A-Z] / [a-z] / [0-9] / - / . / _ / ~`, with `invalid_request`. The check runs after the method check, so an unsupported method still reports `unsupported code_challenge_method`. A method **you** register in `challengeMethods` is deliberately left alone: its challenge shape is yours to define. `validateCodeChallenge` is exported from `@udibo/oauth2/server/authorization` if you mint codes by calling the grant directly and want the same check. `AuthorizationCodeGrant` takes four options worth setting deliberately: | Option | Default | What it does | | ----------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | | `allowRefreshToken` | `true` | Whether a code exchange also issues a refresh token | | `requirePKCE` | `true` | Rejects an authorize request with no `code_challenge`, and a token request with no `code_verifier` | | `challengeMethods` | `{ S256 }` | The PKCE methods this grant accepts. What you register here is what `code_challenge_methods_supported` advertises | | `requireClientAuthentication` | `true` | Whether a client presenting a `code_verifier` must **also** present its `client_secret` | **Confidential clients authenticate alongside PKCE by default.** A valid `code_verifier` does not replace the registered client secret. Public clients have no secret and continue to use PKCE. The explicit `requireClientAuthentication: false` option is only for legacy integrations. Authorization-code replay is also handled: exchanging a code twice revokes every token the first exchange issued (RFC 6819 §4.4.1.1), via your token service's `revokeCode`. See [Known Limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) for the full list of deliberate deviations. ## Consent: optional by design `handleConsent` is optional. **With no handler, the framework treats the request as consented** and grants the accepted scope — the right behavior for a server whose clients are all first-party. Configure a handler only when untrusted third-party clients need a prompt (or a denial): ```ts import type { Context, Hono } from "hono"; import type { HonoAuthenticateUserFn, HonoAuthorizationServer, } from "@udibo/oauth2/hono/authorization-server"; import type { AbstractScope, ClientInterface } from "@udibo/oauth2/server"; interface AppClient extends ClientInterface { id: string; } interface AppUser { id: string; } declare const app: Hono; declare const authServer: HonoAuthorizationServer; declare const authenticateUser: HonoAuthenticateUserFn; declare function takePendingConsent( c: Context, user: unknown, client: AppClient, requestedScope: AbstractScope | undefined, ): Promise<"deny" | "approve" | undefined>; declare function renderConsentPage( c: Context, client: AppClient, requestedScope: AbstractScope | undefined, ): Response; app.route( "/oauth2", authServer.routes({ authenticateUser, handleConsent: async (c, client, requestedScope, user) => { const decision = await takePendingConsent( c, user, client, requestedScope, ); if (decision === undefined) { return renderConsentPage(c, client, requestedScope); } if (decision === "deny") { return { approved: false }; } return { approved: true }; }, }), ); ``` The handler returns `{ approved: true, scope? }` (optionally narrowing the grant per RFC 6749 §3.3), `{ approved: false }` (redirects to the client with `error=access_denied`), or a `Response` rendering your consent page. The page flow is two visits to `/oauth2/authorize`: on the first, the handler finds no recorded decision and returns the consent page; the page POSTs to your own `/consent` route, which records the decision server-side — one-time, bound to the user, client, and scope — and redirects back; on the second visit the handler consumes that record. Never carry the decision in the authorize URL's query parameters: the client builds that URL, so any client could append an approval and skip the prompt. The full pattern lives in [`examples/hono/app-with-own-auth/routes/consent.ts`](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/routes/consent.ts). Users who should not be able to grant everything they're asked for are handled here too: intersect the requested scope with what the user may grant and return it as the narrowed `scope`. ## Refresh-token rotation and reuse detection The `RefreshTokenGrant` rotates on every refresh: it revokes the presented token and saves its replacement, carrying the original `familyId` **and** `familyCreatedAt` forward so an entire rotation chain shares one family and one anchor. Your token service has to round-trip both fields — persist them on `save` and return them from `getRefreshToken` / `getRevokedRefreshToken`. Dropping `familyId` disables reuse detection; dropping `familyCreatedAt` disables the absolute family cap below, and neither failure announces itself: rotation keeps working and the tests you would write against a single refresh still pass. ## Capping a rotation family's absolute lifetime Rotation renews the refresh token's lifetime every exchange, so a client that refreshes often enough holds a credential forever. Implement the optional `refreshTokenFamilyExpiresAt(client, user, familyCreatedAt, scope?)` to put an absolute ceiling on the family. That gives you the sliding-window-with-maximum shape: each exchange renews the refresh token, but the whole chain still dies at a fixed point measured from when the family started. ```ts ignore class AppTokenService extends AbstractTokenService { override refreshTokenFamilyExpiresAt( client: AppClient, _user: AppUser, familyCreatedAt: Date, ): Promise { return Promise.resolve( client.refreshTokenMaxLifetime == null ? undefined : new Date( familyCreatedAt.getTime() + client.refreshTokenMaxLifetime * 1000, ), ); } } ``` Every rotation's access and refresh expiries are clamped to the date you return, and once it has passed the exchange answers `invalid_grant`, so the client must obtain a fresh authorization. `AbstractTokenService` implements the method from its own `refreshTokenMaxLifetime` option (service-wide, and rejected at construction if it is shorter than `refreshTokenLifetime`); override it, as above, to cap per client. **The method is the whole cap** — leave it off, or return `undefined`, and families are never capped. A record with no `familyCreatedAt` (one stored before you added the cap) rotates once uncapped and is anchored from then on. Reuse detection activates when your token service implements the two optional methods: keep revoked refresh-token records findable via `getRevokedRefreshToken`, and implement `revokeFamily(familyId)` to kill every token in a chain. When a rotated-out token is replayed — stolen, or a client raced itself — the grant revokes the whole family so neither party keeps a live session, then invokes your `onTokenReuse` hook so the event lands in your audit log: ```ts import { type ClientServiceInterface, RefreshTokenGrant, type TokenServiceInterface, } from "@udibo/oauth2/server/authorization"; import type { ClientInterface } from "@udibo/oauth2/server"; interface AppClient extends ClientInterface { id: string; } interface AppUser { id: string; } declare const clientService: ClientServiceInterface; declare const tokenService: TokenServiceInterface; declare const auditLog: { record(entry: Record): Promise; }; const refreshGrant = new RefreshTokenGrant({ resolve: () => ({ clientService, tokenService }), onTokenReuse: async (event) => { await auditLog.record({ type: "refresh_token_reuse_detected", familyId: event.familyId, clientId: event.client.id, userId: event.user?.id, familyRevoked: event.familyRevoked, }); }, }); ``` If the token service omits those two methods, reuse detection is silently disabled and a replayed token simply fails with `invalid_grant` — rotation still happens, but a theft can't be distinguished from a typo. `MemoryTokenService` implements both, so the detection path is exercisable in tests. ## Revocation and introspection Both endpoints require client authentication, both take `token` and an optional `token_type_hint`, and on both the hint **orders the lookup rather than restricting it**: a token labelled `access_token` that turns out to be a refresh token is still found, and vice versa. A mislabelled token is therefore still revoked, and still introspected. **`POST /revoke` only revokes tokens issued to the authenticated client.** The server resolves the presented token, compares its `client.id` against the client that authenticated, and revokes only on a match — RFC 7009 §2.1. It answers `200` regardless: for a match, for another client's token, for an unknown token, and for one already revoked. That uniformity is deliberate (§2.2) — the endpoint must not become an oracle for whether a token exists or who owns it — which also means **a caller cannot tell a successful revocation from a refused one.** Revocation uses the resolved token kind, not the hint. This puts a real requirement on your token service: `getRefreshToken` must return live refresh tokens. A service whose `getRefreshToken` returns `undefined` for tokens that exist will silently no-op every refresh-token revocation and report `active: false` for every live refresh token, with a `200` either way and nothing in the logs. **Configure `canIntrospectToken` to authorize introspection.** The callback on `AuthorizationServerOptions` receives the authenticated client, the resolved token and its actual kind (`access_token` or `refresh_token`). Return false to answer only `{ active: false }`; claims enrichment runs only after authorization. An error fails the request closed. Without this policy, the endpoint preserves its separate resource-server behavior: every admitted client can inspect any live token. Choose the policy when mounting the endpoint, and remember that a public client ID does not authenticate its holder. See [Known Limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md#spec-level-gaps). Introspection answers for **refresh** tokens too: a live one reports `active: true` with `exp` from its own expiry and carries **no** `token_type`, while an access token carries `token_type: "Bearer"`. A caller that must distinguish them checks `token_type`; one that treats any `active: true` as a valid access token will accept a refresh token presented as a bearer. ## Discovery metadata `GET /.well-known/oauth-authorization-server` serves the RFC 8414 document built from your resolved context: `issuer` (required — metadata requests fail without it), the endpoint URLs, `grant_types_supported` (the keys of your `grants` map), `scopes_supported`, `code_challenge_methods_supported`, and the token-endpoint auth methods (`client_secret_basic`, `client_secret_post`, `none`). `code_challenge_methods_supported` is **derived** from the registered authorization-code grant's own `challengeMethods` — only own, callable entries count — so registering an extra method advertises it and dropping `S256` un-advertises it, and the field is omitted entirely when no authorization-code grant is registered. It reports what the server will actually accept rather than a fixed string. `token_endpoint_auth_methods_supported` always carries `none` — the RFC 7591 method for a client with no secret — because the token endpoint accepts it unconditionally: a request that presents only `client_id` reaches your `clientService.getAuthenticated` with no secret (an authorization-code exchange carrying a `code_verifier` looks the client up with `clientService.get` instead, when `requireClientAuthentication` is explicitly `false`), and resolving a public client there is part of that interface's contract. There is no option that switches it off, and `requireClientAuthentication` on the authorization-code grant does not (it constrains confidential clients that send a `code_verifier` in place of their secret). Whether a given client may authenticate with `none` is still decided per client, by whether it has a secret; discovery describes the endpoint, not which clients you registered. Clients that discover — including this package's `DirectClient` via `discover()` — configure themselves from it, so keep the advertised endpoints matching where you actually mounted the routes. ## Turning on OIDC issuance Everything above is plain OAuth2: clients get access tokens but learn nothing portable about the user. OpenID Connect adds the `id_token` — a signed JWT asserting who authenticated — plus the JWKS and UserInfo endpoints. The whole surface switches on with one option: `signingKeys`. ### Generate and persist a signing key Keys are ES256 (see [Known Limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) — there is no RS256 option; every mainstream client library accepts ES256). Generate a key **once**, export the private JWK, and store it as a secret; every instance of a multi-instance deploy must load the same key, because an ephemeral per-instance key would fail verification across instances — see [the multi-instance rule](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#the-multi-instance-rule) for the other state this applies to. The package's CLI generates one. Run it once and put the printed JWK in your secret manager as `OIDC_SIGNING_KEY`: ```sh deno run jsr:@udibo/oauth2/cli oidc keygen ``` The JWK goes to stdout as a single line ready to paste into a secret store; the notes about handling it go to stderr, so piping stdout straight into your secret store — `… oidc keygen | gh secret set OIDC_SIGNING_KEY`, or your provider's equivalent — carries the key and nothing else. Nothing is written to disk, and the command needs **no Deno permissions** — run it without `-A`; a key generator asking for permissions is a reason to stop and look. The exported JWK contains the private key material — guard it like any credential. At boot, load it: ```ts import { importSigningKeyJwk, StaticSigningKeyProvider, } from "@udibo/oauth2/server/authorization"; const signingKey = await importSigningKeyJwk( JSON.parse(Deno.env.get("OIDC_SIGNING_KEY")!), ); const signingKeys = new StaticSigningKeyProvider(signingKey); ``` `StaticSigningKeyProvider` covers the single-key case. For rotation, implement the two-method `SigningKeyProvider` interface yourself: `getSigningKey()` returns the key new tokens sign with, and `getPublicJwks()` returns every public key a verifier may still need (current plus not-yet-expired old keys). What that costs if you don't, and what the package does not yet ship, is spelled out in [Signing keys and rotation](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#signing-keys-and-rotation). ### Configure claims Add `signingKeys` — plus the two claim hooks — to the server options: ```ts import { HonoAuthorizationServer } from "@udibo/oauth2/hono/authorization-server"; import type { AuthorizationServerGrants, AuthorizationServerServices, SigningKeyProvider, } from "@udibo/oauth2/server/authorization"; import type { BasicScope, ClientInterface } from "@udibo/oauth2/server"; interface AppClient extends ClientInterface { id: string; } interface AppUser { id: string; username?: string; name?: string; email?: string; emailVerified?: boolean; } declare const resolve: () => { services: AuthorizationServerServices; issuer: string; }; declare const grants: AuthorizationServerGrants; declare const signingKeys: SigningKeyProvider; const authServer = new HonoAuthorizationServer({ resolve, grants, scopesSupported: ["openid", "profile", "email", "read", "write"], signingKeys, subjectOf: (user) => user.id, userClaims: (user, scope) => { const claims: Record = {}; if (scope?.has("profile")) { claims.preferred_username = user.username; claims.name = user.name; } if (scope?.has("email") && user.email) { claims.email = user.email; claims.email_verified = user.emailVerified ?? false; } return claims; }, }); ``` `subjectOf` maps a user to the `sub` claim (it defaults to the user's `id` property, so you can omit it when that's right). `userClaims` releases the optional claims, and the scope parameter is how you honor the standard `profile` and `email` scopes — release each claim only when its governing scope was granted. The protocol claims always win: `sub`, `iss`, `aud`, `iat`, `exp`, and `nonce` are stamped over anything `userClaims` returns. ### What switches on With `signingKeys` configured: - **The token endpoint mints an `id_token`** alongside the access token for user-bound grants whose scope includes `openid`. If the client sent a `nonce` on the authorize request, it is bound to the code and echoed into the `id_token` for the client to verify — replay protection the relying party enforces. - **`GET /jwks`** serves your public keys, and **`GET`/`POST /userinfo`** answers bearer-token requests (the token must carry the `openid` scope and a user) with `sub` plus your `userClaims`. - **`GET /.well-known/openid-configuration`** serves the discovery document with the OIDC members (`jwks_uri`, `userinfo_endpoint`, `id_token_signing_alg_values_supported: ["ES256"]`). Without `signingKeys`, this endpoint — and JWKS and UserInfo — return 404, so relying parties fail fast instead of reading a document missing its required members. Add `jwksEndpoint` and `userinfoEndpoint` to your `resolve` context if the issuer-derived defaults (`${issuer}/jwks`, `${issuer}/userinfo`) don't match where you mounted the routes. The same `signingKeys` can also back **JWT access tokens**: wire `createJwtAccessTokenGenerator({ signingKeys, issuer, audience })` in as your token service's `generateAccessToken`, and resource servers can validate offline against your JWKS endpoint — see [Protect an API](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md#strategy-2-local-jwt-validation-against-the-jwks-endpoint). The token store still persists the JWT string, so revocation and introspection keep working exactly as with opaque tokens. The generator's `userClaims` option is the same seam the server's `userClaims` option gives the id_token, so one claims computation (roles, permissions, an organization) can feed both. It runs only when a resource owner is behind the token — a client-credentials token never carries user claims — and the protocol claims (`iss`, `sub`, `aud`, `client_id`, `iat`, `exp`, `jti`, `scope`) always win over anything it returns. ## Where to go next - [Protect an API](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md) — the consumer side of the tokens you now issue. The same `HonoAuthorizationServer` instance also exposes `protect()` / `requireScope()`, so one process can issue tokens and guard its own API routes. - [Deploy and Operate in Production](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md) — configuration inventory, the stores you provision and their retention, TLS and proxy assumptions, security headers, and the [hardening checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md). - [Known Limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) — ES256-only signing, the introspection field subset, no dynamic client registration, no provider-side OIDC logout (`end_session_endpoint`), and the other honest walls. - The [README](https://github.com/udibo/oauth2/blob/main/README.md) covers error-format configuration (`errorFormat: "problem-details"`), the `resolve` hook for application-specific and proxy deployments, and testing with `createMemoryAuthorizationServer`. --- # Add login to an existing app Wire sign-up, sign-in, password reset, email verification, and brute-force protection into an app that already has a users table. By the end you have an `IdentityService` running over your own database and sessions, with delivery hooks feeding your mailer, explicitly configured rate limits and lockout, and authentication events for your audit log. The library owns the flows and the crypto; your app keeps owning its schema, its ORM, its session cookie, and its UI. If you are starting from scratch instead, the [quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md) gets you to a first login on in-memory stores; this guide is the production version of the same wiring. ## How the pieces fit `IdentityService` orchestrates a handful of primitives, each of which touches your app through a small interface you implement: | Seam | You implement | Backs | | --------------------------------- | ----------------------- | ----------------------------- | | `IdentityUserStore` | over your users table | all flows | | `TokenFlowStore` | one small table | reset / verify / unlock links | | `RevocableSessionService` | over your session store | revocation on password reset | | `ListableSessionService` | same store (optional) | "where you're signed in" list | | `DeliveryHooks` | your mailer | emailed action links | | `RateLimitStore` / `LockoutStore` | Redis or DB (optional) | shared-state protection | | `IdentityEventHook` | your audit log | observability | There is no adapter package per database and no migration the library imposes. Each seam is a few dozen lines over whatever data layer you already run — the snippets below use Drizzle-style queries over Postgres; any ORM or raw SQL can implement the contracts. Everything below imports from `@udibo/oauth2/identity`. ## Implement `IdentityUserStore` over your database This is the one required seam. The service addresses users only by opaque `id`, so your schema stays untouched — you add two credential columns (or a credentials table) and implement its required members: ```ts import type { IdentityUser, PasswordCredential } from "@udibo/oauth2/identity"; interface IdentityUserStore { create( profile: Record, credential: PasswordCredential, ): Promise; findByIdentifier(identifier: string): Promise; findByEmail(email: string): Promise; getCredential(userId: string): Promise; setCredential(userId: string, credential: PasswordCredential): Promise; replaceCredential?( userId: string, expected: PasswordCredential | undefined, credential: PasswordCredential, ): Promise; markEmailVerified?(userId: string, email?: string): Promise; } ``` The interface also carries the optional migration hooks `getLegacyCredential` / `clearLegacyCredential`, used only when importing hashes from another provider — see [migrate-from-another-provider.md](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md). Each method's contract, including what it must do for enumeration safety: - **`create(profile, credential)`** — insert the user and the hashed credential in one step (one transaction; no window where the user exists without a password). `profile` is whatever your sign-up form posted, so validate it here — it is your app's fields, not the library's. Enforce email uniqueness with a database constraint, not a pre-check, and translate the violation to `IdentityError("identifier_taken")` so the mounted routes return a `409`. - **`findByIdentifier(identifier)`** — the sign-in lookup. Normalize the same way you normalize at write time (trim, lowercase email) or users will fail to sign in with the address they registered. If sign-in accepts email _or_ username, branch here — `classifyIdentifier` from the same subpath tells them apart. - **`findByEmail(email)`** — the reset/verification-request lookup. Return `undefined` for an unknown email and do nothing else; the service already guarantees `requestPasswordReset` resolves identically either way, so your store must not log, throw, or otherwise behave observably differently. - **`getCredential(userId)`** — return the stored `{ hash, salt }`, or `undefined` for accounts with no password (e.g. SSO-only). The service burns a comparable hashing delay on `undefined` so a password-less account is not distinguishable by response timing — you just return what's there. - **`setCredential(userId, credential)`** — overwrite the credential; called by password reset. Nothing else: session revocation and lockout clearing are the service's job. - **`replaceCredential(userId, expected, credential)`** — optional atomic compare-and-set for automatic rehash and imported-password upgrades. Compare the stored hash, salt and params with `expected`; `undefined` means no native credential may exist. Return `false` without writing if anything changed. Without this hook sign-in still works, but automatic upgrades are skipped. - **`markEmailVerified(userId, email?)`** — flip your verified flag, for the address the verification link was minted for. Optional; omit it if you don't verify email. Predicate the update on both the id **and** the address (see below) — a one-argument implementation still compiles and is still vulnerable. Over Drizzle/Postgres: ```ts ignore import { and, eq, isNull } from "drizzle-orm"; import { IdentityError, type IdentityUserStore } from "@udibo/oauth2/identity"; import { db } from "./db.ts"; import { type AppUser, users } from "./schema.ts"; function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } async function findUserByEmail(email: string): Promise { const [user] = await db.select().from(users) .where(eq(users.email, normalizeEmail(email))); return user; } export const userStore: IdentityUserStore = { async create(profile, credential) { const email = normalizeEmail(String(profile.email ?? "")); try { const [user] = await db.insert(users).values({ email, name: String(profile.name ?? ""), passwordHash: credential.hash, passwordSalt: credential.salt, passwordParams: credential.params ?? null, }).returning(); return user; } catch (error) { if (isUniqueViolation(error)) { throw new IdentityError("identifier_taken", undefined, { cause: error, }); } throw error; } }, findByIdentifier: (identifier) => findUserByEmail(identifier), findByEmail: (email) => findUserByEmail(email), async getCredential(userId) { const [user] = await db.select().from(users).where(eq(users.id, userId)); if (!user?.passwordHash) return undefined; return { hash: user.passwordHash, salt: user.passwordSalt, params: user.passwordParams ?? undefined, }; }, async setCredential(userId, credential) { await db.update(users).set({ passwordHash: credential.hash, passwordSalt: credential.salt, passwordParams: credential.params ?? null, }).where(eq(users.id, userId)); }, async replaceCredential(userId, expected, credential) { const unchanged = expected ? and( eq(users.passwordHash, expected.hash), eq(users.passwordSalt, expected.salt), expected.params ? eq(users.passwordParams, expected.params) : isNull(users.passwordParams), ) : and( isNull(users.passwordHash), isNull(users.passwordSalt), isNull(users.passwordParams), ); const updated = await db.update(users).set({ passwordHash: credential.hash, passwordSalt: credential.salt, passwordParams: credential.params ?? null, }).where(and(eq(users.id, userId), unchanged)).returning({ id: users.id }); return updated.length === 1; }, async markEmailVerified(userId, email) { await db.update(users).set({ emailVerified: true }) .where(and(eq(users.id, userId), eq(users.email, email!))); }, }; ``` Two details in that store are load-bearing. **`markEmailVerified` takes the address the link was minted for.** The service passes the email carried on the verification token, and the update above only marks the account verified when that address is _still_ the account's email. A one-argument implementation compiles and behaves exactly as before — which means it is still vulnerable: a user can request a link for `a@example.com`, change their address to `b@example.com`, then click the old link and have `b@example.com` marked verified without ever proving control of it. A verified address gates account recovery and account linking, so that is a takeover primitive. Predicate on the address. **Persist `credential.params`.** A credential is self-describing: it records the algorithm and work factor it was minted with, which is what lets the work factor be raised later without a forced reset. A credential stored with no `params` is read as the original PBKDF2-SHA-256 at 100,000 iterations and transparently rehashed on that user's next successful sign-in when `replaceCredential` is implemented. If a store **drops** `params` from a new or upgraded credential, verification uses the wrong work factor and the next sign-in fails. Persist the complete credential together; one `jsonb`/`text` column for `params` is enough. ## Password hashing You don't wire hashing so much as stop doing it yourself. `IdentityService` constructs a `PasswordIdentityService` by default — PBKDF2-SHA-256 at `DEFAULT_PBKDF2_ITERATIONS` (600,000), fresh random salt per hash, constant-time verify — and calls it before your store ever sees a credential. Your obligations are the three columns above (`hash` and `salt`, both hex strings, plus `params`) and never accepting a plaintext password through any other code path. **The work factor is configurable and upgrades one login at a time.** Pass `new PasswordIdentityService({ iterations })` as the service's `passwords` option to raise or lower it. Raising it later is free: each credential records its own work factor in `params`, so a credential minted under an older setting still verifies, and `IdentityService` uses `replaceCredential` to rehash it after it verifies on the owner's next successful sign-in. A credential with no `params` at all is treated as `LEGACY_PBKDF2_ITERATIONS` (100,000) and upgraded the same way. A persist failure during that rehash is logged and swallowed — a storage hiccup must never deny a sign-in the credential just proved. An atomic replacement that returns `false` means a newer credential superseded the one just checked, so the service re-reads it and re-verifies the password: a sign-in that lost the race to another correct sign-in still succeeds, while one that lost to a reset or a change to a different password is rejected. Measure hashing and verification on your deployment hardware and choose a work factor consistent with your security and latency requirements. **Bring your own hasher.** `passwords` is typed as `PasswordHasherLike`, not as the concrete class, so argon2id or scrypt from your own dependency drops in: ```ts ignore import type { PasswordCredential, PasswordHasherLike, } from "@udibo/oauth2/identity"; const argon2: PasswordHasherLike = { hash: (password) => argon2id.hash(password), verify: (password, credential) => argon2id.verify(password, credential), needsRehash: (credential: PasswordCredential) => isBelowPolicy(credential), }; ``` Implement `hash` and `verify` and you are done; implement the optional `needsRehash` together with the store's `replaceCredential` to enable rehash-on-successful-sign-in. This is the answer to "the package only ships PBKDF2" — PBKDF2 is the zero-dependency floor, not a ceiling. Construct it explicitly only when something else needs the same hasher — for example a legacy sign-in path you're migrating: ```ts import { PasswordIdentityService } from "@udibo/oauth2/identity"; declare const plaintext: string; const passwords = new PasswordIdentityService(); const credential = await passwords.hash(plaintext); const ok = await passwords.verify(plaintext, credential); ``` Pass it via the service's `passwords` option so both paths share one implementation. ## Store reset and verification tokens Password reset, email verification, and account unlock share one token mechanic: mint a high-entropy single-use token, store **only its SHA-256 hash**, email the raw token, consume it once. `TokenFlowService` owns that lifecycle; you give it a `TokenFlowStore`: ```ts import type { TokenFlowRecord } from "@udibo/oauth2/identity"; interface TokenFlowStore { save(record: TokenFlowRecord): Promise; get(tokenHash: string): Promise; markConsumed(tokenHash: string, consumedAt: number): Promise; deleteBySubject?(purpose: string, subject: string): Promise; } ``` One table backs it — `tokenHash` (primary key), `purpose`, `subject`, `data` (jsonb), `expiresAt`, `consumedAt`, `createdAt` — mapping one-to-one onto `TokenFlowRecord`. Because only hashes are stored, a leaked table or log line can't be replayed as a working link. Implement the optional `deleteBySubject`: the service uses it (via `invalidateExisting`) to void a user's outstanding reset links whenever a new one is issued, so only the latest emailed link works. ```ts import { TokenFlowService } from "@udibo/oauth2/identity"; import type { TokenFlowStore } from "@udibo/oauth2/identity"; declare const tokenStore: TokenFlowStore; const tokens = new TokenFlowService(tokenStore); ``` A `MemoryTokenFlowStore` ships for development and tests. ## Sessions: visibility and revocation The library never creates sessions — after `signIn` returns a user, setting your cookie is your code. What the flows need back from your session layer is revocation, expressed as `RevocableSessionService`: ```ts interface RevocableSessionService { revokeAllByUser(userId: string): Promise; revokeOthers(userId: string, keepSessionId: string): Promise; } ``` Implement both over your session storage and pass it as the service's `sessions` option. `resetPassword` then calls `revokeAllByUser` — without this, a compromised account's other devices stay signed in after the rightful owner resets the password. `revokeOthers` powers "sign out everywhere else" on your own settings page: it keeps the session the user is sitting in and ends the rest, which is what someone who has just changed their password expects. `resetPassword` also voids the subject's outstanding **passwordless** credentials — a pending sign-in link and a pending sign-in code — so a magic link an attacker triggered before the reset stops working. Both calls are best-effort: `TokenFlowStore.deleteBySubject` is optional, and a store that does not implement it leaves outstanding links redeemable until they expire. See [known limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md). ### Listing sessions Revocation without visibility is half a feature: a "where you're signed in" screen is how a user notices the device they don't recognize, and it is the only thing that makes the revoke buttons meaningful. The listing half of the same seam is `ListableSessionService`, an **optional** capability a stateful store opts into: ```ts interface SessionSummary { id: string; createdAt: Date; lastSeenAt: Date; userAgent?: string; // raw header; the UI parses it into a device label location?: string; // coarse place resolved from IP — never the raw address } interface ListableSessionService { listByUser(userId: string): Promise; } ``` Implement it on the same object as `RevocableSessionService` and your "where you're signed in" screen has one typed shape to render and to wire its revoke buttons against — instead of every app inventing a different list shape and making the same mistakes in it. The screen itself is yours to build: the package ships the seam and the row type, not a prebuilt list component, because the layout, the device labelling, and the confirmation flow are all product decisions. A stateless store that can't enumerate its sessions simply omits the method; `supportsSessionListing(sessions)` narrows to the capability, so you offer the screen only when the store can back it. `SessionSummary` is deliberately spare: id, created-at and last-seen timestamps, and coarse client context. `userAgent` is the raw header your UI summarizes into a device label; `location` is a human-readable place resolved from the IP at creation — not the raw address. Both are optional; a store with nothing coarse to report leaves them unset rather than reaching for a precise value that would turn a security screen into a tracking log. The current-session marker is yours: compare each `SessionSummary.id` against the id of the session backing the request so the UI can label that row and make "revoke" on it mean "sign out". Two rules keep the list honest: - **Revocation must be immediate, not advisory.** Deleting the row has to end the session on the next request. If your sessions are self-contained tokens the server doesn't look up, a revoke button is a lie — keep a server-side record, or check a revocation list on every request. - **Reads are cheap and constant; writes are not.** Bump `lastSeenAt` on a throttle (once a minute, say) rather than on every request, or the sessions table becomes your hottest write path. ### Step-up before the destructive ones Revoking sessions, changing a password, and disabling MFA are the actions an account takeover wants. Demand fresh proof of presence in front of them: ```ts import { isRecentlyAuthenticated } from "@udibo/oauth2/identity"; import type { Context } from "hono"; declare function redirectToReauthentication(c: Context): Response; export function requireRecentAuth( c: Context, session: { authenticatedAt: number }, ) { if (!isRecentlyAuthenticated(session.authenticatedAt, 5 * 60_000)) { return redirectToReauthentication(c); } } ``` `authenticatedAt` is the moment the session last actively authenticated — set it at sign-in and again after a successful re-authentication, and don't refresh it on ordinary requests, or the window never closes. ### Back-channel logout, when the IdP owns the session If your app consumes an external IdP through `@udibo/oauth2/hono/bff` rather than owning its login, the provider can end sessions from its side via OIDC Back-Channel Logout. Configure `backchannelLogout.verifyLogoutToken` to verify the signature against the trusted issuer's keys and check issuer, audience, timestamps, event claim, and replay policy. The BFF calls that verifier before it calls `destroyByLogout({ sub, sid })` on your `SessionStore` — a capability stateful stores opt into by implementing it. `MemorySessionStore` does; `EncryptedCookieSessionStore` cannot, because a stateless store has no sessions to enumerate, and the route stays unavailable. The caveat worth knowing: matching prefers the id_token's `sid` claim and falls back to `sub` only when the logout token carries no `sid`. So your session record must **carry `sid` forward across token refreshes** — the BFF's own refresh path preserves it, and a custom store that drops the field on update will silently stop matching. The failure mode is quiet and open: no error, and the sessions that survive a logout the provider believes succeeded are exactly the long-lived ones. A DB-backed store should index both `sid` and `sub`. ## Delivery hooks: emailing the links Transport is yours; the service mints the token, builds the URL, and hands you a `DeliveryMessage`: ```ts import type { DeliveryHooks } from "@udibo/oauth2/identity"; declare const mailQueue: { enqueue(job: { to: string; template: string; url?: string; expiresAt: number; }): void; }; const delivery: DeliveryHooks = { sendPasswordReset(message) { mailQueue.enqueue({ to: message.to, template: "password-reset", url: message.url, expiresAt: message.expiresAt, }); }, sendEmailVerification(message) { mailQueue.enqueue({ to: message.to, template: "verify-email", url: message.url, expiresAt: message.expiresAt, }); }, sendAccountUnlock(message) { mailQueue.enqueue({ to: message.to, template: "unlock-account", url: message.url, expiresAt: message.expiresAt, }); }, }; ``` Two rules: - **Enqueue; don't await your mail provider in-request.** The `requestPasswordReset` response body is enumeration-safe, but if the known- account path awaits an SMTP round-trip and the unknown-account path doesn't, response _timing_ reveals which emails have accounts. Hand the message to a queue and return. - **`message.url` is built from the `baseUrl` you configure** — an origin (or origin + prefix) you control, e.g. `https://app.example.com`. Never derive emailed links from the incoming request's `Host` header; a host-header injection would poison reset emails with attacker-controlled links. The default paths are `/reset-password`, `/verify-email`, and `/unlock-account` (see `buildResetUrl` and friends to change them), so those routes in your app are where the links land. Omit `baseUrl` to receive only the raw `message.token` and compose URLs yourself. During development, hooks that `console.log` the URL make every flow testable with no mailer — that is exactly what the [quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md#follow-a-sign-in) and the [example apps](https://github.com/udibo/oauth2/tree/main/examples/hono/app-with-own-auth) do. ## Password policy and breached passwords `signUp` and `resetPassword` check the policy before hashing (and before consuming a reset token, so a weak password never burns the user's link). A violation throws `IdentityError("weak_password")` → `422`: ```ts import { breachedPasswordValidator } from "@udibo/oauth2/identity"; const passwordPolicy = { minLength: 8, validators: [breachedPasswordValidator()], }; ``` The defaults follow the NIST "length over composition rules" guidance: a length floor (`minLength` 8, `maxLength` 256 as a hash-input DoS guard), no character-class requirements. They apply even when you pass no `passwordPolicy` at all — the check is never skipped, so every password your app can store is bounded. `signIn` is deliberately outside the policy: it hashes whatever the request carries, because the equal-work path that hides an unknown identifier has to run on the submitted password as-is. PBKDF2 pre-hashes an oversized HMAC key once and then iterates over fixed-size blocks, so a very long submitted password costs little more than a short one. `breachedPasswordValidator` rejects passwords found in the Have I Been Pwned corpus using the k-anonymity range API — only the first five characters of the password's SHA-1 ever leave your process. It **fails open** by default (an unreachable HIBP shouldn't take down your sign-up flow); pass `failOpen: false` to invert that, and pass the `onEvent` hook you gave `IdentityService` so each fail-open reaches your audit store as a `password_policy.check_unavailable` event instead of a `console.warn` — [production deployment](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#rate-limiting-lockout-and-password-policy). ## Rate limiting and lockout Two distinct protections, both consulted by `signIn`: - **`RateLimiter`** throttles attempts per fixed window, keyed by identifier. Defaults: 10 attempts per 15 minutes. Unknown and known identifiers throttle identically, so the `429` can't be used to enumerate accounts. When exceeded it throws `IdentityError("rate_limited", …, { retryAfterMs })`, which the Hono routes map to `429` with a `Retry-After` header. A successful sign-in resets the window. - **`AccountLockout`** counts _consecutive failures per account_ and locks the account once a threshold is crossed. Defaults: 10 failures → 15-minute lock. A locked account's sign-in returns the same uniform, timing-equalized `null` an unknown identifier gets — lockout must not become an oracle for "this account exists and the password was close". ```ts import { AccountLockout, RateLimiter } from "@udibo/oauth2/identity"; const rateLimiter = new RateLimiter(); const lockout = new AccountLockout(); ``` Both default to in-memory stores, which are per-process — one instance of [the multi-instance rule](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#the-multi-instance-rule). Running more than one instance, back them with shared state by implementing the small `RateLimitStore` / `LockoutStore` interfaces over Redis or your database (lockout columns on the users table also give admins visibility). Both `increment` contracts require atomicity — a read-modify-write lets concurrent attempts lose counts, undershooting the lockout threshold or slipping a burst past the limiter. If you want a different _algorithm_ rather than different storage, the `rateLimiter` and `lockout` options are typed against structural interfaces — `RateLimiterLike` (`check` / `reset`) and `AccountLockoutLike` (`status` / `recordFailure` / `reset`). Pass a plain object implementing either and the built-in classes step aside entirely; that is the seam for a sliding window, a token bucket, or a limiter you already operate. `rateLimiter` is the default for all six throttled flows; `rateLimiters` gives any of them (`signIn`, `passwordReset`, `emailVerification`, `accountUnlock`, `signInLink`, `signInCode`) its own limiter instead, which is how you throttle "make my server send an email" harder than sign-in — see [protections](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#rate-limiting-lockout-and-password-policy). IP-based limiting deliberately stays out of the service: it has no request context. Apply it at the route layer in front of these endpoints, keyed on whatever your infrastructure knows (IP, IP+identifier). ### Roll out in log-only mode `protectionMode: "log-only"` arms both protections without blocking anyone: every threshold crossing still emits its event (with `enforced: false`), so you can watch a week of real traffic, confirm the defaults don't catch legitimate users, then flip to `"enforce"` (the default) with numbers in hand. ```ts ignore protectionMode: "log-only", ``` ### Self-service unlock The service only knows opaque user ids, so the unlock email starts from your event hook, where you look up the address: ```ts import type { IdentityEventHook, IdentityService, IdentityUser, } from "@udibo/oauth2/identity"; declare const identity: IdentityService; declare function getUserById( id: string, ): Promise<{ email?: string } | undefined>; declare const auditLog: { record(event: unknown): Promise }; const onEvent: IdentityEventHook = async (event) => { if (event.type === "lockout") { const user = await getUserById(event.userId); if (user?.email) { await identity.requestAccountUnlock({ userId: event.userId, email: user.email, }); } } await auditLog.record(event); }; ``` The link lands on your `/unlock-account` route, which consumes it: ```ts import type { IdentityService, IdentityUser } from "@udibo/oauth2/identity"; declare const identity: IdentityService; declare const token: string; declare function identifierFor(userId: string): string; const result = await identity.unlockAccount(token); if (result.status === "success") { await identity.resetSignInThrottle(identifierFor(result.userId)); } ``` `unlockAccount` clears the lock; `resetSignInThrottle` clears the rate-limit window for the identifier, which is otherwise still full of the failed attempts that caused the lock. (`resetPassword` clears the lockout on its own — an emailed reset token proves the same account ownership — but call `resetSignInThrottle` there too.) The discriminated result (`success` / `expired` / `invalid`) lets the page offer a fresh link for an expired one. ## The audit seam: `onEvent` Every flow outcome emits one `IdentityEvent`: `sign_in.succeeded`, `sign_in.failed` (with the internal reason), `sign_in.rate_limited`, `sign_in.locked`, `lockout`, `sign_up`, and the requested / completed / failed lifecycle of password reset, email verification, and account unlock. Persist them in your own audit store via the `onEvent` option. The hook is awaited but isolated: a rejection is logged and swallowed, so a down audit sink can never break sign-in. Two consequences: don't rely on it for control flow, and report hook failures through your own channel if you need delivery guarantees. Events are for **server-side capture only**. They record internal outcomes — including whether an identifier resolved to an account — so surfacing their contents to the end user turns your audit trail into the enumeration oracle every response in this layer is designed not to be. ## Putting it together ```ts import { AccountLockout, breachedPasswordValidator, IdentityService, RateLimiter, TokenFlowService, } from "@udibo/oauth2/identity"; import type { DeliveryHooks, IdentityUser, IdentityUserStore, LockoutStore, RateLimitStore, RevocableSessionService, TokenFlowStore, } from "@udibo/oauth2/identity"; interface AppUser extends IdentityUser { email: string; } declare const userStore: IdentityUserStore; declare const tokenStore: TokenFlowStore; declare const sessionService: RevocableSessionService; declare const delivery: DeliveryHooks; declare const rateLimitStore: RateLimitStore; declare const lockoutStore: LockoutStore; declare const auditLog: { record(event: unknown): void }; const identity = new IdentityService({ users: userStore, tokens: new TokenFlowService(tokenStore), sessions: sessionService, delivery, baseUrl: "https://app.example.com", passwordPolicy: { minLength: 8, validators: [breachedPasswordValidator()], }, rateLimiter: new RateLimiter({ store: rateLimitStore }), lockout: new AccountLockout({ store: lockoutStore }), protectionMode: "enforce", onEvent: (event) => auditLog.record(event), }); ``` Expose the flows however your app routes. In Hono, `honoIdentityRoutes` from `@udibo/oauth2/hono/identity` mounts the five **password-credential** POST endpoints — `/signup`, `/signin`, `/password/reset-request`, `/password/reset`, `/email/verify` — and maps `IdentityError` codes to statuses (`invalid_credentials` 401, `invalid_token` 400, `rate_limited` 429, `weak_password` 422, `identifier_taken` 409, `forbidden_origin` 403); your `onAuthenticated` hook creates the session. Those routes carry a same-origin guard on unsafe methods by default — see the [quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md) for the `csrf` option. In any other framework — or when you want your own request shapes — call the service methods directly from your handlers, as the flows are plain async methods. The [quickstart](https://github.com/udibo/oauth2/blob/main/docs/quickstart.md) shows the mounted version; the [Hono](https://github.com/udibo/oauth2/tree/main/examples/hono/app-with-own-auth) and [Juniper](https://github.com/udibo/oauth2/tree/main/examples/juniper/app-with-own-auth) examples show direct calls from server-rendered form routes. The factory scope stops at the password path **by design**. Passwordless, MFA, and social carry policy the factory can't guess — where pending state lives, the enumeration-safe response shape, transient `state`/PKCE custody, account linking — so they stay hand-routed. Each is documented as a call-the-service path: [passwordless](https://github.com/udibo/oauth2/blob/main/docs/guides/passwordless.md), [MFA](https://github.com/udibo/oauth2/blob/main/docs/guides/add-mfa.md), and [social sign-in](https://github.com/udibo/oauth2/blob/main/docs/guides/social-sign-in.md). ## Before going live The security checklist that used to live here is now the "if your app runs its own login" section of the [hardening checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md#if-your-app-hosts-login) — same items, alongside everything else a deployment has to answer for (configuration, stores, TLS, cookies, headers, backups, observability), each linked to the section of [Deploy and Operate in Production](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md) that explains it. One thing that page makes explicit and this guide's snippets can obscure: `rateLimiter` and `lockout` are **opt-in**. Construct both, or the flows run with no throttling and no lockout. `passwordPolicy` is the exception — it runs whether or not you pass one, at its 8–256 character defaults. --- # Protect an API with Bearer Tokens This guide takes you from an unprotected Hono app to an API that validates OAuth2 bearer tokens (RFC 6750), gates routes by scope, and answers failures with spec-correct `WWW-Authenticate` challenges. You will set up a resource server with the Hono adapter, pick between the two token-validation strategies the package supports, and call the protected API from a client that holds a token. The runnable version of everything here is [`examples/hono/api-service/`](https://github.com/udibo/oauth2/tree/main/examples/hono/api-service) — a backend service that validates tokens against an external identity provider. ## The pieces `ResourceServer` (from `@udibo/oauth2/server/resource`) is framework-agnostic: it validates bearer tokens on web `Request` objects and knows nothing about routing. For a Hono app you use its subclass `HonoResourceServer` (from `@udibo/oauth2/hono/resource-server`), which adds the `protect()` and `requireScope()` middleware and the `getContext()` accessor. The resource server itself never talks to a database or an identity provider. It delegates token lookup to a `tokenService` — any object implementing `TokenReaderInterface` from `@udibo/oauth2/server`: ```ts import type { AbstractScope, BasicScope, ClientInterface, Token, } from "@udibo/oauth2/server"; interface TokenReaderInterface< Client extends ClientInterface, User, Scope extends AbstractScope = BasicScope, > { getToken( accessToken: string, ): Promise | undefined>; } ``` Return the token record (with its `client`, optional `user`, `scope`, and `accessTokenExpiresAt`) for a live token, or `undefined` for an unknown one. Choosing how `getToken` answers is choosing your validation strategy. ## Strategy 1: Introspection against the authorization server The built-in `IntrospectionTokenReader` validates tokens by calling the authorization server's RFC 7662 introspection endpoint. This is the standard choice when your API is a separate service from the identity provider: every request costs one HTTP call to the introspection endpoint, and revocation takes effect immediately because the authorization server is the source of truth. RFC 7662 only fixes a minimal response shape, so the reader takes two mappers — `getClient` and `getUser` — that project the introspection response into your app's own `Client` / `User` types. Both may be async if you want to enrich from your database. ```ts import { IntrospectionTokenReader } from "@udibo/oauth2/server/resource"; interface Client { id: string; } interface User { id: string; username?: string; } const tokenReader = new IntrospectionTokenReader({ introspectionEndpoint: "https://auth.example.com/oauth2/introspect", clientId: "my-api", clientSecret: Deno.env.get("INTROSPECTION_CLIENT_SECRET")!, getClient: (data) => ({ id: data.client_id ?? "" }), getUser: (data) => data.sub ? { id: data.sub, username: data.username } : undefined, }); ``` A machine token — one issued by the client-credentials grant, which has no resource owner — carries no `sub`; its `client_id` identifies the caller. So `data.sub` is present only when a person is behind the token, and a machine caller resolves to no user (RFC 9700 §4.15.1). The reader distinguishes failure modes so your API doesn't misreport them: an inactive token resolves to `undefined` (the caller gets `invalid_token`), an unreachable or 5xx introspection endpoint throws `TemporarilyUnavailableError` (503 — the identity provider is down, the caller's token isn't necessarily bad), and a 4xx throws `ServerError` (your introspection credentials are misconfigured). **Audience caveat:** the introspection response this package's authorization server emits omits `aud`, `iat`, `nbf`, and `jti`, so a resource server cannot enforce audience restriction through introspection. If you need in-token audience claims, use JWT access tokens (strategy 2). See [Known Limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md). **The call is bounded; it is not cached.** This reader takes `fetchTimeoutMs` (default 5000), the same option, default, and semantics as `JwksTokenReader`, so an issuer that accepts connections and then stops answering fails at the deadline instead of stalling. It still caches nothing — it calls the authorization server on every authenticated request. The `fetch` option remains the seam for mTLS, retries, or a different bound; note that an injected `fetch` which ignores `AbortSignal` also ignores `fetchTimeoutMs`: ```ts ignore const tokenReader = new IntrospectionTokenReader({ // … fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(2_000) }), }); ``` An aborted call surfaces as `TemporarilyUnavailableError` (503), which is the honest answer — the issuer is down, the caller's token is not necessarily bad. **This package's introspection endpoint answers for refresh tokens too**, with `active: true` and no `token_type` (an access token carries `token_type: "Bearer"`). The shipped reader does not currently check `token_type`, so if your resource server introspects against an issuer that reports refresh tokens as active, a refresh token presented as `Authorization: Bearer …` validates. That widens what a leaked refresh token is good for and bypasses rotation and reuse detection; verify `token_type` in your `getClient` mapper (throw or return a client your authorization checks reject) if refresh tokens circulate anywhere near your API surface. ## Strategy 2: Local JWT validation against the JWKS endpoint When the authorization server issues **signed JWT access tokens** (RFC 9068 `at+jwt`, enabled server-side via `createJwtAccessTokenGenerator` — see [Become an OAuth Provider](https://github.com/udibo/oauth2/blob/main/docs/guides/become-an-oauth-provider.md)), your API can validate tokens offline: fetch the public keys from the server's JWKS endpoint once, then verify signatures locally with no per-request network call. `JwksTokenReader` is the shipped implementation. It takes the same `getClient` / `getUser` mappers as the introspection reader — the claims replace the introspection response — so swapping strategies is a constructor change and nothing else: ```ts import { JwksTokenReader } from "@udibo/oauth2/server/resource"; interface Client { id: string; } interface User { id: string; username?: string; } const tokenReader = new JwksTokenReader({ issuer: "https://auth.example.com", audience: "https://api.example.com", getClient: (claims) => ({ id: String(claims.client_id) }), getUser: (claims) => (claims.sub ? { id: claims.sub } : undefined), }); ``` `issuer` and `audience` are required: RFC 9068 §4 obliges a resource server to reject tokens minted for another issuer or another audience, and with local validation there is no authorization server in the request path to do it for you. `audience` is your API's identifier — pass an array to accept several. Prefer an identifier that names the API (`https://api.example.com`) over a client id: `createJwtAccessTokenGenerator` defaults `aud` to the client id when you leave its `audience` unset, and while listing client ids works, it is the same value an `id_token` carries in `aud` — see the `id_token` warning below before combining that with a relaxed `types`. `jwksUri` is optional: omit it and the reader discovers the key set from `issuer`, trying `/.well-known/oauth-authorization-server` and then `/.well-known/openid-configuration` inserted ahead of the issuer's path component (RFC 8414 §3.1), then the OIDC Core form with `/.well-known/openid-configuration` appended. Issuers without a path component — the common case — only cost one extra request. Pass `jwksUri` explicitly to skip discovery. A token is accepted only when all of these hold: - its `typ` header is `at+jwt`, - it carries a client-id claim — `client_id`, which RFC 9068 §2.2 makes REQUIRED. Point `clientIdClaim` at the vendor claim for issuers that deviate (Okta's `cid`, Azure AD v1's `appid`); there is no way to drop the requirement, - it carries no `id_token`-only claim (`nonce`, `at_hash`, `c_hash`, `s_hash`) and no `crit` header (RFC 7515 §4.1.11 — this reader implements no JWS extensions, so it must reject any token that requires one). `auth_time`, `acr` and `amr` are fine: RFC 9068 §2.2.1 permits them on an access token, - its `alg` is an asymmetric algorithm you accept **and** the one the matched key publishes. Defaults to every algorithm Web Crypto covers (`ES256`/`384`/ `512`, `RS256`/`384`/`512`, `PS256`/`384`/`512`); pin it with `algorithms` when you know what your issuer signs with. `none` and `HS*` are never accepted, and symmetric keys in a JWKS are ignored, so an algorithm-confusion token cannot verify, - its signature verifies against a published key, - `iss` and `aud` match the configuration, `exp` is in the future and `nbf` (if present) is in the past, both within the reader's `clockSkewSeconds` (default 30). `ResourceServer` then re-checks `exp` itself, with its **own** `ResourceServerOptions.clockSkewSeconds` — which defaults to `0`. Set both to tolerate drift end to end; the resource-server option is also the only one that reaches an `IntrospectionTokenReader`, which has no notion of skew. Anything else resolves to `undefined` — the same `invalid_token` response as an unknown opaque token, with no detail leaked about which check failed. **Don't let an `id_token` in.** The three checks above — `typ`, the required client-id claim, and the `id_token`-claim rejection — exist because an `id_token` is signed by the same issuer with the same keys, and accepting one as an access token hands your API a token the browser was allowed to see. `typ` is the strongest of the three, so prefer configuring your issuer to stamp `at+jwt` over relaxing `types`. If your issuer only stamps a plain `JWT` and you must set `types: ["at+jwt", "jwt"]`, then also give this API an `audience` that is **not** one of your client ids — an `id_token`'s `aud` _is_ a client id, so an audience of `"my-client"` plus a relaxed `types` would leave the client-id claim as the only thing standing between the two token kinds. Most IdPs let you register a distinct API identifier for exactly this reason. **Key rotation.** Keys are cached on the reader instance for `cacheMaxAgeMs` (default 10 minutes). A token that no cached key verifies triggers one refetch, and concurrent requests share it — including tokens with no `kid`, so a single-key issuer that publishes its JWK without one still rotates promptly. The exception is a token whose `kid` matched a cached key and still failed to verify: that's a forgery, not a rotation, and it provokes no fetch at all. Refetches are throttled to one per `minFetchIntervalMs` (default 30 seconds) so a flood of junk tokens can't turn your API into a DoS amplifier against the JWKS endpoint; the cost is that a rotation is picked up at most one interval late. A refresh that fails is absorbed — the cached keys keep serving — and once keys are cached the refresh happens **in the background**: requests are answered from the cache rather than blocked behind the issuer. Requests are bounded by `fetchTimeoutMs` (default 5 seconds), so a blackholed JWKS host fails fast instead of hanging every request that joins the shared fetch. **Errors.** An unreachable or 5xx JWKS endpoint throws `TemporarilyUnavailableError` when nothing is cached (a down issuer must not read as a bad token); a 4xx, a non-JWKS body, or metadata without a `jwks_uri` throws `ServerError`, because those are your misconfiguration rather than a verdict on the caller's token. **The trade-off versus introspection:** local validation cannot see revocation — a revoked JWT stays valid until it expires. Keep access tokens short-lived, or introspect on the endpoints where revocation latency matters. A third option worth naming: if your API shares a database with the authorization server, implement `TokenReaderInterface` directly against that token table — no HTTP and no JWT parsing. The [`app-with-own-auth`](https://github.com/udibo/oauth2/tree/main/examples/hono/app-with-own-auth) example uses this in-process shape. ## A minimal protected API With a token reader in hand, construct the Hono resource server and mount its middleware. The `resolve` option supplies the services for each request; for a application API it's a constant function returning the same reader every time. ```ts import { Hono } from "hono"; import { HonoResourceServer, type HonoResourceServerVariables, } from "@udibo/oauth2/hono/resource-server"; import type { TokenReaderInterface } from "@udibo/oauth2/server"; interface Client { id: string; } interface User { id: string; username?: string; } declare const tokenReader: TokenReaderInterface; const resourceServer = new HonoResourceServer({ resolve: () => ({ services: { tokenService: tokenReader } }), realm: "Example API", }); const app = new Hono<{ Variables: HonoResourceServerVariables; }>(); app.get("/api/public", (c) => c.json({ message: "No token required." })); app.use("/api/private", resourceServer.protect()); app.get("/api/private", (c) => { const { client, user, scope } = resourceServer.getContext(c); return c.json({ client: client.id, user: user?.id, scope: scope?.toString(), }); }); app.use("/api/write", resourceServer.protect("write")); app.get("/api/write", (c) => c.json({ message: "Token carries write scope." })); export default app; ``` `protect()` validates the bearer token from the `Authorization` header and, on success, stores the authenticated context on the Hono context; `getContext(c)` reads it back, typed via `HonoResourceServerVariables`. Passing a scope to `protect("write")` additionally requires that scope. ## Layering scopes under one authenticated mount When routes under a single mount need different scopes, authenticate once with a bare `protect()` and layer `requireScope(scope)` per route. `requireScope` asserts the scope against the context `protect` already populated — no second token lookup — and emits the identical `insufficient_scope` response: ```ts import type { Handler, Hono } from "hono"; import type { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; import type { ClientInterface } from "@udibo/oauth2/server"; declare const app: Hono; declare const resourceServer: HonoResourceServer; declare const listItems: Handler; declare const createItem: Handler; app.use("/api/items/*", resourceServer.protect()); app.get("/api/items", resourceServer.requireScope("read"), listItems); app.post("/api/items", resourceServer.requireScope("write"), createItem); ``` This layering fails safe: every route under the mount is authenticated regardless, and a route missing its `requireScope` is merely under-scoped, never wide open. For handlers that want to authenticate inline instead of via middleware, `resourceServer.authenticate(c, scope?)` throws typed errors on failure; pair it with `resourceServer.handleAuthError(error)` to convert them to responses. ## Checking the person, not just the client: the Authorization object Scope answers what the **client application** was delegated. When the issuer also stamps user-authorization claims into its tokens — `roles`, `permissions`, and the active organization's `org_id`/`org_slug`/`org_roles` — the authenticated context carries them as one checkable object, `context.authorization`, built the same way from either validation strategy (the JWT's verified payload, or the introspection response's extension fields — the authorization server's `introspectionClaims` option is what puts them there): ```ts import type { Handler, Hono } from "hono"; import type { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; import type { ClientInterface } from "@udibo/oauth2/server"; declare const app: Hono; declare const resourceServer: HonoResourceServer; declare const createPost: Handler; declare const removePost: Handler; app.use("/api/*", resourceServer.protect()); app.post( "/api/posts", resourceServer.require({ scope: "posts:write", permission: "posts:write" }), createPost, ); app.delete( "/api/orgs/acme/posts/:id", resourceServer.require({ organization: "acme", orgRole: "admin" }), removePost, ); ``` `require(conditions)` layers under `protect()` exactly like `requireScope`: AND semantics across keys, arrays mean all-of. A failed `scope` condition answers the same `insufficient_scope` challenge `requireScope` sends; a failed permission, role, or organization condition answers a plain 403 whose body carries `insufficient_permissions` (RFC 6750 registers no challenge code for it). Any-of checks are deliberately not expressible in the middleware — branch on the object in a handler, where the semantics stay visible: ```ts import type { Context } from "hono"; import type { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; import type { ClientInterface } from "@udibo/oauth2/server"; declare const c: Context; declare const resourceServer: HonoResourceServer; const { authorization } = resourceServer.getContext(c); authorization.can("posts:write"); // permission — the recommended check authorization.hasRole("editor"); // tenant-wide role authorization.hasOrgRole("admin"); // role in the active organization authorization.inOrganization("acme"); // active-organization match (id or slug) authorization.hasScope("posts:read"); // client delegation ``` Predicates never throw; they return booleans. A machine token (client credentials — no resource owner) has empty roles and permissions and no organization, so every non-scope condition refuses it. A token whose reader supplied no claims (a plain DB-backed token store) behaves the same: only its scope answers. What the credential's own claims cannot answer stays behind a network call — an organization the credential was not issued in, and any resource-level question, because a grant on one of your resource instances never rides a credential. Freshness is not on that list for every reader, because it is a property of the surface rather than of the token. Strategy 1 gets claims computed for the request in hand, so against an issuer whose hooks answer from live state they are not a stale answer; strategy 2 holds what was computed when the token was signed — the same lag as [the revocation trade-off above](https://github.com/udibo/oauth2/blob/main/docs/guides/protect-an-api.md#strategy-2-local-jwt-validation-against-the-jwks-endpoint). `checkPermissions` from `@udibo/oauth2/client` wraps a bearer-authenticated check endpoint for these. **It answers only for the scope you name**, so it replaces a claim only when it names the claim's scope: pass `resource: { type: "organization", id: org_id }` to ask what a claim resolved for that organization answered. Leave `resource` off — `permissions` is always required, so a request without it is a 400 rather than a wider answer — and you get the endpoint's default scope, which need not be the claim's; where that default is narrower, a permission the subject holds through an organization comes back `false`. That applies to either strategy, because an introspected claim carries the same organization-granted half. One cost of echoing: an `org_id` signed into a token can name an organization deleted since, which answers 404 and reaches you as a thrown `ServerError`. ## What failures look like on the wire The middleware produces RFC 6750-conformant responses; you don't build these yourself: - **No token sent** → `401` with `WWW-Authenticate: Bearer realm="Example API"`. Per RFC 6750 §3.1, a request with no credentials gets a bare challenge with no error code. - **Invalid or expired token** → `401` with `WWW-Authenticate: Bearer realm="Example API", error="invalid_token", error_description="..."`. The `DirectClient`'s `fetch` wrapper keys its silent-refresh retry off exactly this challenge. - **Valid token, missing scope** → `403` with `WWW-Authenticate: Bearer realm="Example API", error="insufficient_scope", scope="write"`, naming the scope the route required. The challenge carries an `error` code only for the three codes RFC 6750 §3.1 registers — `invalid_request`, `invalid_token`, `insufficient_scope`. Any other code, including the `access_denied` raised when a request presents no credentials at all, yields a bare `Bearer realm="…"`, because §3.1 says a challenge must not carry an unregistered code and must not carry one at all when no credentials were presented. The consequence for your own guards: throw `InsufficientScopeError` (403) when you mean "the token is fine, the scope is not", and `InvalidTokenError` (401) when you mean "this token is no good". Throw anything else and the client sees a bare challenge, which it will read as "credentials missing" and answer by starting a fresh sign-in. Response bodies default to the RFC 6749 JSON shape (`error`, `error_description`, `error_uri`). Pass `errorFormat: "problem-details"` to the constructor to emit RFC 9457 Problem Details (`application/problem+json`) instead — the same fields, with `type`/`detail` carrying the URI and message. Errors thrown from your own services (any `OAuth2Error` subclass, which are all `HttpError` instances) are converted through the same path, and only an error's `exposedMessage` reaches the wire, so internal diagnostic detail stays in your logs. ```ts import { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; import type { TokenReaderInterface } from "@udibo/oauth2/server"; interface Client { id: string; } interface User { id: string; username?: string; } declare const tokenReader: TokenReaderInterface; const resourceServer = new HonoResourceServer({ resolve: () => ({ services: { tokenService: tokenReader } }), realm: "Example API", errorFormat: "problem-details", }); ``` ## Calling the API with a token **Direct clients** (a service or SPA holding its own tokens) use `DirectClient` from `@udibo/oauth2/client`. After completing a grant, its `fetch` wrapper attaches `Authorization: Bearer ` and, on a 401 whose challenge says `invalid_token`, silently refreshes and retries once: ```ts import { DirectClient } from "@udibo/oauth2/client"; const client = new DirectClient({ clientId: "my-service", clientSecret: Deno.env.get("CLIENT_SECRET")!, endpoints: { authorization: "https://auth.example.com/oauth2/authorize", token: "https://auth.example.com/oauth2/token", revocation: "https://auth.example.com/oauth2/revoke", }, }); await client.getClientCredentialsToken({ scope: "read" }); const response = await client.fetch("https://api.example.com/api/private"); ``` `fetch` takes the same arguments the global one does, including a `Request`: that request's own headers **and** its body reach the server, `init.headers` win on a name collision, and a caller-set `Authorization` header is left alone. A `Request`-shaped POST is cloned before the first send, so its body is replayed on the post-refresh retry rather than arriving empty. The same rules hold for `BffClient.fetch`, which additionally adds the `x-csrf` header only when the request does not already carry one. A retry that fails at the transport level now rejects; only a failed _refresh_ falls back to reporting the original `401`. `DirectClient` also re-resolves discovery metadata as it ages. Constructed with an `issuer` and no `discoveryCache`, it re-discovers after `DEFAULT_DISCOVERY_TTL_MS` (1 hour); given a `discoveryCache`, `resolve` reports `{ metadata, expiresAt }` and the client holds the document until that `expiresAt`, so the cache is read once per entry lifetime rather than once per endpoint lookup. A remote `DiscoveryCache` implementation therefore needs no memo of its own — it just reports the entry's own expiry, never later. **Browser frontends** should not hold tokens at all — put a `HonoBff` (from `@udibo/oauth2/hono/bff`) in front. The BFF keeps tokens server-side against a session cookie, and `bff.protect(scope?)` guards an API route by accepting **either** the session cookie (resolved to the stored access token, refreshed near expiry, then validated against your resource server) **or** an inbound bearer token, so browsers and machine-to-machine clients share one guard: ```ts import type { Handler, Hono } from "hono"; import type { HonoBff } from "@udibo/oauth2/hono/bff"; declare const app: Hono; declare const bff: HonoBff; declare const listItems: Handler; declare const createItem: Handler; app.use("/api/*", bff.protect()); app.get("/api/items", bff.requireScope("read"), listItems); app.post("/api/items", bff.requireScope("write"), createItem); ``` When you'd rather compose the chain yourself — for example, a downstream middleware expects the bearer token in the `Authorization` header — `bff.attachToken()` resolves the session and sets the header on the inbound request, then your `resourceServer.protect()` validates it as usual: ```ts import type { Hono } from "hono"; import type { HonoBff } from "@udibo/oauth2/hono/bff"; import type { HonoResourceServer } from "@udibo/oauth2/hono/resource-server"; import type { ClientInterface } from "@udibo/oauth2/server"; declare const app: Hono; declare const bff: HonoBff; declare const resourceServer: HonoResourceServer; app.use("/api/*", bff.attachToken(), resourceServer.protect()); ``` Prefer `bff.protect()` unless you need the header: it doesn't mutate the request, so middleware ordering doesn't matter. **They differ on an inbound bearer, and the difference is deliberate.** `protect()` checks for `Authorization: Bearer …` on the incoming request first and validates _that_ token, so one guard serves both the BFF's own frontend (session cookie, no header) and machine-to-machine callers. `attachToken()` overwrites the header with the session's token whenever a session resolves, so a mount behind it can only ever act as the signed-in browser user. Pick on that: `protect()` for a mount that must accept `client_credentials` callers, `attachToken()` for one that must not. Neither is attacker-forceable from a browser — the package writes no `Access-Control-*` header anywhere, so a cross-origin page cannot put an `Authorization` header on a credentialed request, and the CSRF header check runs before either path. Both BFF examples ([`app-with-own-auth`](https://github.com/udibo/oauth2/tree/main/examples/hono/app-with-own-auth), [`app-with-external-auth`](https://github.com/udibo/oauth2/tree/main/examples/hono/app-with-external-auth)) run this pattern end to end. ### When the API is a separate service `protect()` and `attachToken()` both assume the resource server is **co-located** — validation happens in this process. When the API is a different service, `bff.proxy()` forwards the call instead: it reads the session, attaches the access token server-side, and streams the response back. This is the full proxying BFF the IETF "OAuth 2.0 for Browser-Based Apps" BCP §6.1.1 recommends for sensitive apps: the token never reaches the browser, and since the browser only talks to its own origin, there is no CORS to configure. ```ts import type { Hono } from "hono"; import type { HonoBff } from "@udibo/oauth2/hono/bff"; declare const app: Hono; declare const bff: HonoBff; app.all( "/api/*", bff.proxy("https://api.example.com/v1", { stripPrefix: "/api" }), ); ``` `GET /api/things?page=2` becomes `GET https://api.example.com/v1/things?page=2` with `Authorization: Bearer `. What it does, and what it deliberately does not do: | Concern | Behavior | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Target | Fixed at mount time. Only the path and query come from the request, so the proxy can't be steered at another host. A query parameter pinned on the target (`https://api.example.com/v1?tenant=acme`) is merged into every call and wins over an inbound parameter of the same name. | | Auth | Session cookie only. An inbound `Authorization` is ignored **and** never forwarded — point machine-to-machine clients at the resource server directly. | | Refresh | Proactive near expiry (as `protect()`), plus one retry after an upstream `401` carrying `error="invalid_token"`. Concurrent requests share one exchange, so a rotating refresh token is never presented twice. The session ends only when the IdP says the grant is dead — a network blip, 5xx, or 429 answers `502` and leaves the session intact. | | CSRF | The same custom-header check as the rest of the credentialed BFF surface, so mounting a proxy can't reopen it. | | Request headers | An allowlist (`DEFAULT_PROXY_FORWARD_HEADERS`, extendable via `forwardHeaders`). The session cookie, `Authorization`, `Host`, and hop-by-hop never cross. | | Response headers | Everything except upstream `Set-Cookie` and hop-by-hop — both the fixed set and whatever the response's `Connection` field names. Content coding and length are dropped only when a body is streamed back (`fetch` already decoded it), so a `HEAD` keeps its `Content-Length`. `Location` / `Content-Location` pointing under the target are rewritten into the mount's namespace, so a `201` is followable and the upstream's hostname stays private. The response is marked `private`, `Vary: Cookie`, and `nosniff` — it is cookie-authenticated and served from your origin. Status and problem-details bodies pass through verbatim. | | Bodies | Streamed both ways — nothing is buffered. Bodied requests are not retried, since the body has already been sent. | | Redirects | Returned to the browser, never followed, so the token isn't replayed to an unvetted `Location`. | | Paths | An encoded separator inside a segment (`/files/a%2Fb`) is forwarded as-is, since it's a legitimate resource id. A segment that decodes to a `..` — or whose percent-escape is malformed, like the overlong `%C0%AF` — is refused, because a lenient upstream decoder could reconstruct `../` from it. | The BFF generates only four responses of its own: `401` `invalid_token` (no session, or the refresh failed), `403` `csrf_validation_failed`, `400` `invalid_request` (a rejected path segment), and `502` `temporarily_unavailable` (upstream unreachable). Mount it before any middleware that reads the request body — the proxy forwards `c.req.raw.body` as a stream, and a middleware that already consumed it leaves nothing to forward. One thing the proxy cannot do for you: an upstream that returns `text/html` renders on **your** origin, same-origin with your session cookie. `nosniff` stops content-type sniffing, but a genuine `Content-Type: text/html` is still honored. Proxy APIs that return data, not documents — or add a `Content-Security-Policy` on the mount. [`app-with-external-auth`](https://github.com/udibo/oauth2/tree/main/examples/hono/app-with-external-auth) runs both topologies side by side: in-process `/api/*` and proxied `/remote-api/*`. ## Where to go next - [Become an OAuth Provider](https://github.com/udibo/oauth2/blob/main/docs/guides/become-an-oauth-provider.md) — stand up the authorization server these tokens come from. - [Known Limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) — the honest list, including the introspection field subset and ES256-only signing. - The package [README](https://github.com/udibo/oauth2/blob/main/docs/guides/testing.md) covers testing protected routes without a live identity provider. --- # Use authentication in React Use `@udibo/oauth2/react` to share session state and sign-in actions with your components. For a browser app with a backend, connect it to `BffClient` so tokens stay on the server. First mount your BFF using [the integration guide](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md) or the [app-owned authorization example](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-own-auth/README.md). ## Provide the client once ```tsx import { BffClient } from "@udibo/oauth2/client"; import { OAuth2Provider, RequireAuth, useOAuth2 } from "@udibo/oauth2/react"; const client = new BffClient(); function Account() { const { user, logout } = useOAuth2(); return (

Signed in as {String(user?.name ?? user?.sub ?? "user")}

); } export function App() { return ( Checking your session…

}>
); } ``` `RequireAuth` starts sign-in when the session is unauthenticated. Use `useOAuth2()` directly when a page should stay public and offer an explicit sign-in button. Construct the client once per browser app, not on every render. For server rendering, derive any initial auth state from the current request; do not share a token-owning `DirectClient` across users. A rendering guard does not protect data. Keep bearer/session validation, required scopes, and record-level authorization on the server. Use the same `BffClient.fetch` for credentialed API calls so the CSRF header is included. ## Forms for an app that hosts its own login Applications using Udibo redirect to hosted sign-in. Use the optional components below only on login pages your own application hosts. They provide fields and submission state; they do not create routes, sessions, or an identity service. | Component | Use | | ----------------------------------------------- | ----------------------------------------- | | `SignInForm`, `SignUpForm` | App-owned password login and registration | | `RequestPasswordResetForm`, `ResetPasswordForm` | Request and complete a password reset | | `MfaEnrollmentForm`, `MfaChallengeForm` | Display enrollment and challenge controls | | `UserMenu` | User display and sign-out action | ```tsx import { SignInForm } from "@udibo/oauth2/react/components"; export function SignInPage() { return ( { const response = await fetch("/auth/signin", { method: "POST", headers: { "content-type": "application/json", "x-csrf": "1" }, body: JSON.stringify(values), }); if (!response.ok) { return { error: "Sign-in failed. Check your credentials.", }; } }} onSuccess={() => location.assign("/")} /> ); } ``` This assumes your application has mounted that sign-in endpoint. Validate the request and create a session in the backend's authentication hook. If MFA is enabled, route first-factor success to the challenge instead of immediately navigating to an authenticated page; see [MFA](https://github.com/udibo/oauth2/blob/main/docs/guides/add-mfa.md). `onSubmit` returns nothing for success or `{ error, fieldErrors }` for a form failure. `onSuccess` is a UI side effect, not an authorization gate; errors in that callback are isolated. For a no-JavaScript form post, `action`/`method` identify the backend route and that route must return the appropriate redirect. ## Styling and custom markup Components are unstyled. Use `className`, the per-slot `classNames` map, or stable `data-oauth2-*` attributes. `AuthFormClassNamesProvider` supplies shared form classes; an individual form can override a slot. `UserMenu` has its own styling props. For full markup control, use `useAuthForm` or a component's render-prop children. Keep labels, error associations, focus behavior, and autocomplete attributes when replacing markup. The [component API](https://jsr.io/@udibo/oauth2/doc/react/components) lists the slots and props. See the [Juniper](https://github.com/udibo/oauth2/blob/main/templates/juniper/README.md) and [React Router](https://github.com/udibo/oauth2/blob/main/templates/react-router/README.md) templates for complete apps, and [testing](https://github.com/udibo/oauth2/blob/main/docs/guides/testing.md) for mock providers and BFF session fixtures. --- # Test your authentication integration Test application decisions, persistent adapters, and browser flows separately. The package's protocol tests do not prove your registered callbacks, database transactions, reverse proxy, or app authorization rules are configured correctly. ## Application routes For an app that exports its token reader or client instance, stub the relevant method with `@std/testing/mock` to exercise accepted tokens, rejected tokens, and issuer failures. Avoid a global fetch stub that also intercepts unrelated application traffic. For a protocol integration test, inject fetch through the client/reader constructor or run the [local identity provider](https://github.com/udibo/oauth2/blob/main/docs/guides/run-a-local-identity-provider.md). | Helper | Purpose | | ----------------------------------------------------------------- | --------------------------------------------------------------------- | | `createMemoryAuthorizationServer` from `/testing` | Run the real protocol implementation with isolated in-memory services | | `createAuthenticatedTestSession` from `/hono/bff/testing` | Register an access token and create a corresponding BFF session | | `createTestSession` from `/hono/bff/testing` | Seed a session for BFF session-endpoint tests | | `createMockBffClient`, `MockOAuth2Provider` from `/react/testing` | Render React behavior with controlled auth state | A protected BFF request needs both a session and an access token accepted by the resource server. The following test assumes you have constructed an isolated app and its token service: ```ts import { assertEquals } from "@std/assert"; import { BasicScope } from "@udibo/oauth2/server"; import { createAuthenticatedTestSession } from "@udibo/oauth2/hono/bff/testing"; import type { HonoBff } from "@udibo/oauth2/hono/bff"; import type { TokenServiceInterface } from "@udibo/oauth2/server/authorization"; import type { Hono } from "hono"; declare const app: Hono; declare const bff: HonoBff; declare const tokenService: TokenServiceInterface< { id: string }, { id: string } >; Deno.test("an authenticated user can read the protected route", async () => { const cookie = await createAuthenticatedTestSession(bff, { tokenService, client: { id: "app" }, user: { id: "user-1" }, scope: new BasicScope("read"), claims: { sub: "user-1" }, }); const response = await app.request("/api/me", { headers: { cookie, [bff.csrfHeaderName!]: "1" }, }); assertEquals(response.status, 200); await response.body?.cancel(); }); ``` Use the same token service that the app's resource server validates against. For a custom persistent session store, seed through that store's test setup. Also cover no session, insufficient scope, and a user trying to access another user's record. Keep the CSRF guard enabled in tests that claim to exercise browser credential handling. ## Persistent storage contracts Use the exported suites against an isolated database or equivalent real store: ```ts import { runOtpStoreContractTests } from "@udibo/oauth2/testing/contract"; import type { OtpStore } from "@udibo/oauth2/identity"; declare function freshOtpStore(): Promise; runOtpStoreContractTests({ describeName: "Application OTP store", makeStore: freshOtpStore, }); ``` Each `makeStore` must provide fresh state. Follow the suite's options for the other interfaces: tokens, authorization codes, device codes, MFA, rate limits, lockout, token flows, and token readers. The BFF session suite lives in `@udibo/oauth2/hono/bff/testing`. In addition to the shared contracts, test your adapter's transaction boundaries: - Two consumers of one OTP/code/refresh token cannot both succeed. - A refresh completing after logout cannot recreate a revoked session. - A password upgrade cannot replace a credential changed by a reset. - A competing token save cannot restore a revoked refresh-token family. - Records from another application or user cannot satisfy an app-specific lookup. A contract suite checks its scenarios, not every isolation failure your database can exhibit. Close connections and dispose request bodies so test resource sanitizers remain enabled. ## Browser verification Use a real browser for callback cookies, CSRF, and redirect behavior. Start login and finish it in the same browser; attempt a mismatched callback as a refusal case. Test production cookie/proxy settings on HTTPS and repeat with requests reaching different app instances. The [external-auth example](https://github.com/udibo/oauth2/blob/main/examples/hono/app-with-external-auth/README.md) and [local provider](https://github.com/udibo/oauth2/blob/main/docs/guides/run-a-local-identity-provider.md) support this without a hosted account. ## Package contributor checks From the repository root: ```sh deno task check deno task test:all ``` `check` validates types, lint, formatting, API docs, snippets, links, generated agent docs, the JSR payload, and an external consumer. `test:all` runs the package, script, example, and template suites. These tasks publish nothing. --- # Add MFA to an app that owns its login Add a TOTP second factor with recovery codes to an app already running `IdentityService`. By the end you have enrollment behind a confirmation code, a challenge step every sign-in path passes through, single-use recovery codes for the lost-phone case, and a throttle that makes a six-digit code un-guessable. The library owns the TOTP math, the replay guard, and the code-burning rules; your app keeps owning the rows, the routes, and the **policy** — whether MFA is optional or required, and where in your sign-in flow the challenge happens. Everything below imports from `@udibo/oauth2/identity/mfa`, except the step-up helper (`@udibo/oauth2/identity`) and the forms (`@udibo/oauth2/react/components`). > **Hand-routed by design.** The `honoIdentityRoutes` factory mounts only the > password-credential path (`/signup`, `/signin`, password reset, email verify). > MFA is not mounted, because _when_ a challenge is required, where the pending > challenge state lives, and the cookie shape are app policy the factory can't > own for you — so you call `MfaService` from your own routes, as shown below. ## How the pieces fit | Seam | You implement | Backs | | ------------- | ------------------------------ | ------------------------- | | `MfaStore` | one table keyed by user id | secrets + recovery hashes | | `RateLimiter` | in-memory, Redis, or your DB | throttling `verify` | | `onEvent` | your audit log | `mfa.*` outcomes | | your routes | enrollment, challenge, step-up | when a code is demanded | `MfaService` is the orchestrator. It has no opinion about sessions: it answers "is this code valid for this user", and your sign-in path decides what that means. ## Implement `MfaStore` One table keyed by user id, holding the active secret, the replay guard, the pending (unconfirmed) secret, and the unused recovery-code hashes: ```ts import type { MfaTotpRecord } from "@udibo/oauth2/identity/mfa"; interface MfaStore { getTotp(userId: string): Promise; setPendingTotp(userId: string, secretBase32: string): Promise; activateTotp( userId: string, secretBase32: string, lastStep: number, ): Promise; clearTotp(userId: string): Promise; advanceLastStep(userId: string, step: number): Promise; getRecoveryHashes(userId: string): Promise; setRecoveryHashes(userId: string, hashes: string[]): Promise; consumeRecoveryHash(userId: string, hash: string): Promise; } ``` Two rules decide whether your implementation is sound. **Encrypt the secrets at rest.** `secretBase32` and `pendingSecretBase32` are symmetric keys, not hashes — anyone holding the column can generate valid codes forever. Seal them with whatever your app already uses for secrets (`seal`/`unseal` from `@udibo/oauth2/crypto` over a key from your secret manager is enough) and unseal them inside the store. Recovery codes are the opposite: the library hands you hashes and never asks for the plaintext back. **Three methods must be atomic.** `activateTotp`, `advanceLastStep`, and `consumeRecoveryHash` are the concurrency guards of the whole design, and a read-then-write defeats each of them: - `advanceLastStep(userId, step)` is the TOTP replay guard — `UPDATE … SET last_step = $step WHERE last_step IS NULL OR last_step < $step`, returning whether a row changed. Two requests submitting the same intercepted code must not both win. - `consumeRecoveryHash(userId, hash)` is what makes a recovery code single-use — delete the hash and report the row count, never filter-then-write. - `activateTotp(userId, secret, lastStep)` promotes the pending secret **only when it is still the one the confirmation code was verified against**, so a second enrollment started mid-flow can't be activated by the first one's code. `MemoryMfaStore` implements the contract for development and tests. ## Construct the service ```ts import { RateLimiter } from "@udibo/oauth2/identity"; import { MemoryMfaStore, MfaService } from "@udibo/oauth2/identity/mfa"; export const mfa = new MfaService({ store: new MemoryMfaStore(), rateLimiter: new RateLimiter({ limit: 5, windowMs: 5 * 60_000 }), onEvent: (event) => console.log(event.type), }); ``` The `rateLimiter` is not optional in spirit: a six-digit code is one in a million, and an unthrottled `verify` endpoint is a brute-force target. It is keyed `mfa:verify:`, and a successful verification resets the window so a legitimate user who fat-fingers a code never accumulates toward a block. When the limit is hit, `verify` throws `IdentityError("rate_limited")` — map it to `429` with `Retry-After`, or set `protectionMode: "log-only"` to watch real traffic for a week before enforcing. Configure TOTP `digits`, `periodSeconds`, and `algorithm` consistently with enrollment; changing them requires a migration or re-enrollment. `windows` only controls accepted clock drift and does not change authenticator provisioning. The defaults are 6 digits, 30 seconds, SHA-1, and ±1 accepted time step. ## Enrollment: start, then confirm These service calls assume an authenticated, recently verified application user. Resolve the user from your session, authorize enrollment changes for that user, and enforce CSRF on the enclosing POST routes. Never use an arbitrary submitted `userId`. `MfaService` verifies MFA credentials; it does not authenticate the HTTP request or decide who may enroll, disable, or regenerate recovery codes. Enrollment is two steps on purpose. `startEnrollment` stores a **pending** secret that `verify` ignores, so an abandoned enrollment can never lock a user out of their account: ```ts import type { MfaEnrollmentStart, MfaService, } from "@udibo/oauth2/identity/mfa"; export function beginEnrollment( mfa: MfaService, user: { id: string; email: string }, ): Promise { return mfa.startEnrollment(user.id, { issuer: "Example", accountName: user.email, }); } ``` You get back `base32` (for manual entry) and `otpauthUri` (to render as a QR code — the package ships no QR dependency). Neither is a session-independent credential yet. `confirmEnrollment` proves the authenticator actually works before anything is activated. On success the secret becomes active and a fresh recovery-code set is generated; the plaintext codes are returned **once** and only their hashes are stored: ```ts import type { MfaService } from "@udibo/oauth2/identity/mfa"; export async function confirmEnrollment( mfa: MfaService, userId: string, code: string, ): Promise<{ ok: boolean; recoveryCodes?: string[] }> { const result = await mfa.confirmEnrollment(userId, code); if (!result.confirmed) return { ok: false }; return { ok: true, recoveryCodes: result.recoveryCodes }; } ``` Show those codes on the next screen and nowhere else — not in an email, not in a log line, never again from the database. If storing them fails, the just-activated credential is rolled back and the error rethrown: a failure leaves the user _not_ enrolled and free to retry, never enrolled without the recovery codes they were promised. Re-enrolling requires `disable` first — `startEnrollment` and `confirmEnrollment` throw `IdentityError("mfa_already_enrolled")` (409) while an active credential exists, so a hijacked session cannot silently swap the user's authenticator for its own. ## The challenge: one gate, every sign-in path `verify` is the whole challenge API: ```ts import type { MfaService } from "@udibo/oauth2/identity/mfa"; export type SignInStep = "session" | "challenge"; export async function nextStep( mfa: MfaService, userId: string, ): Promise { return await mfa.isEnrolled(userId) ? "challenge" : "session"; } ``` With Hono identity routes, put this decision in `onAuthenticated`: ```ts import { honoIdentityRoutes } from "@udibo/oauth2/hono/identity"; import type { IdentityService } from "@udibo/oauth2/identity"; import type { MfaService } from "@udibo/oauth2/identity/mfa"; import type { Context } from "hono"; declare const identity: IdentityService<{ id: string }>; declare const mfa: MfaService; declare function beginPendingMfa(c: Context, userId: string): Promise; declare function createApplicationSession( c: Context, userId: string, ): Promise; const routes = honoIdentityRoutes(identity, { onAuthenticated: async (c, user) => { if (await mfa.isEnrolled(user.id)) return await beginPendingMfa(c, user.id); return await createApplicationSession(c, user.id); }, }); ``` The declared functions are application code. `beginPendingMfa` stores a short-lived, one-use pending login bound to this browser; it must not grant API access. The challenge route validates that pending state and CSRF, resolves the account again, calls `mfa.verify`, and creates a session only after a valid result. Passwordless and social callbacks must follow the same application decision. Two rules govern that implementation: **Put the gate in front of session creation, not after it.** The user has passed the first factor but is not signed in yet. Carry the pending state in a short-lived, `HttpOnly`, path-scoped sealed cookie with a server-side expiry — not in a session — and mint the session only after `verify` returns valid. A "logged in but not yet MFA'd" session is a session an attacker can use. **Every login method goes through the same gate.** Password sign-in, passwordless codes and links, and social callbacks all end with "we believe this is user X" — and every one of them must ask the same question before minting a session. A second factor that only the password form enforces is a second factor an attacker routes around by clicking "email me a link". Re-resolve the user at the end of the flow, too. An account deleted or disabled between the first factor and the challenge must fail the sign-in; the pending cookie only carries an id. ## TOTP versus recovery codes `verify` tries TOTP against the active secret first, then falls back to a recovery code. Restrict it when your UI knows which one the user is submitting: ```ts import type { MfaService, MfaVerification } from "@udibo/oauth2/identity/mfa"; export function verifyAuthenticator( mfa: MfaService, userId: string, code: string, ): Promise { return mfa.verify(userId, code, { method: "totp" }); } ``` Passing `method: "totp"` on the authenticator field means a mistyped entry can never silently burn a recovery code; the dedicated "use a recovery code" screen passes `method: "recovery"`. A successful recovery redemption reports `remainingRecoveryCodes` — surface it, and prompt for regeneration when it runs low: ```ts import type { MfaService } from "@udibo/oauth2/identity/mfa"; export async function lowRecoveryCodeWarning( mfa: MfaService, userId: string, code: string, ): Promise { const result = await mfa.verify(userId, code); if ( result.valid && result.method === "recovery" && result.remainingRecoveryCodes <= 2 ) { return "Running low on recovery codes — generate a new set."; } } ``` `regenerateRecoveryCodes(userId)` replaces the whole set (every previously issued code stops working) and throws `IdentityError("mfa_not_enrolled")` (409) for a user with no active credential. Without an active credential every code is rejected, so stale hashes can never authenticate a user whose MFA was turned off. A rejected code that _was_ mathematically valid — a replay of a spent time step — emits `mfa.verify.failed` with `reason: "replayed"`. That is the signal of an intercepted code or a duplicated submission, and it is worth alerting on differently from a typo. ## Step-up: gate the destructive routes Disabling MFA and regenerating recovery codes are what an account takeover wants most. Demand fresh proof of presence in front of both. Either check the session's last authentication: ```ts import { isRecentlyAuthenticated } from "@udibo/oauth2/identity"; export function needsReauthentication(authenticatedAt: number): boolean { return !isRecentlyAuthenticated(authenticatedAt, 5 * 60_000); } ``` …or collect the credential in the same request as the action — a password for accounts that have one, a TOTP or recovery code for accounts that don't. The in-request form is stricter: freshness is exact rather than a window, and there is no elevated-session flag to steal. Whichever you pick, throttle it on the same per-user bucket the challenge uses; step-up is another place a code can be guessed. ## How MFA interacts with reset and lockout - **Password reset does not clear MFA.** `resetPassword` sets the credential, revokes sessions, and clears the failed-attempt lockout; the second factor still applies on the next sign-in. That is the point — an attacker with mailbox access must not be able to reset their way past the factor. - **A wrong code never locks the account.** `AccountLockout` counts wrong _passwords_. MFA failures are throttled per user instead, so a challenge form can't be used to lock a victim out of their own account. - **Losing both factors needs an operator path.** The library's only reset is `disable(userId)`, and it ships no "email me past MFA" flow, deliberately — such a flow reduces MFA to email possession. Build an identity-checked support path, gate it behind step-up, and audit the `mfa.disabled` event. - **Reset the sign-in throttle after an unlock**, exactly as in [add-login.md](https://github.com/udibo/oauth2/blob/main/docs/guides/add-login.md#self-service-unlock); MFA changes nothing there. ## Wiring the prebuilt forms `MfaEnrollmentForm` renders the secret, your QR node, and the one-time recovery codes, then collects the confirmation code: ```tsx import { MfaEnrollmentForm } from "@udibo/oauth2/react/components"; export function EnrollmentPanel(props: { secret: string; otpauthUri: string; recoveryCodes: string[]; }) { return ( { const res = await fetch("/auth/mfa/enroll/confirm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code }), }); if (!res.ok) return { error: "That code was not valid." }; }} /> ); } ``` `MfaChallengeForm` collects the code and forwards which credential it is, so your endpoint can pass the matching `method` through to `verify`: ```tsx import { MfaChallengeForm } from "@udibo/oauth2/react/components"; export function Challenge() { return ( { const res = await fetch("/auth/mfa/verify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(values), }); if (!res.ok) return { error: "That code was not valid." }; }} /> ); } ``` Both ship unstyled with per-slot `className` hooks and a render-prop escape hatch; see the [prebuilt components section](https://github.com/udibo/oauth2/blob/main/docs/guides/react.md#forms-for-an-app-that-hosts-its-own-login) of the README for theming and the headless `useAuthForm` path. ## Security checklist Before going live: - [ ] **Secrets are encrypted at rest.** `secretBase32` and `pendingSecretBase32` are keys, not hashes; a dump of the table must not yield working codes. - [ ] **The three atomic store methods are conditional writes.** `advanceLastStep`, `consumeRecoveryHash`, and `activateTotp` are single statements whose affected-row count is the answer. - [ ] **`verify` is rate limited** with a shared store if you run more than one instance, and `protectionMode` is `"enforce"`. - [ ] **Every sign-in path passes the gate** — password, passwordless link, passwordless code, and every social callback — and the session is minted only after it. - [ ] **Pending MFA state is a sealed, short-lived, path-scoped cookie**, never a session, and the user is re-resolved before the session is created. - [ ] **Recovery codes are displayed once**, stored only as hashes, and the authenticator field passes `method: "totp"` so it can't burn one. - [ ] **Disable and regenerate are behind step-up**, throttled, and audited. - [ ] **`mfa.*` events reach a durable audit store**, with `mfa.verify.failed` / `reason: "replayed"` alerting separately from an ordinary wrong code. --- # Passwordless sign-in: magic links and email codes Let users sign in with something they can prove they own — their mailbox — instead of a password. `IdentityService` ships both shapes: a **magic link** (`requestSignInLink` → `consumeSignInLink`) and a **one-time email code** (`requestSignInCode` → `verifySignInCode`). Successful verification consumes the credential and returns a user ID; your app decides whether to require MFA and create a session. Request responses should not reveal account existence, but delivery and storage timing still require attention. This guide assumes the wiring from [add-login.md](https://github.com/udibo/oauth2/blob/main/docs/guides/add-login.md): an `IdentityUserStore`, a `TokenFlowService`, and delivery hooks. Passwordless adds one store (`OtpStore`, for codes only) and two delivery hooks. > **Hand-routed by design.** The `honoIdentityRoutes` factory mounts only the > password-credential path (`/signup`, `/signin`, password reset, email verify). > Passwordless is not mounted, because the enumeration-safe response shape, > whether `rate_limited` is swallowed, and IP throttling are app policy the > factory can't own for you — so you call the service methods from your own > routes, as shown below. ## Which one, and when | | Magic link | Email code | | ------------------ | ----------------------------------------------------- | --------------------------------------------- | | User action | click | type 6 digits | | Cross-device | breaks — the link opens on whatever device reads mail | works — read on the phone, type on the laptop | | Wrong-guess budget | not applicable (high-entropy token) | 5 attempts, then the code is dead | | Link prescanners | corporate mail scanners can consume the token first | unaffected | | Default lifetime | 15 minutes | 10 minutes | | Needs | `tokens` | the `otp` option | Codes survive the two failure modes that make magic links frustrating: a user signing in on a device that isn't the one holding their mail, and a security appliance that "clicks" every link in an inbound message and burns the single-use token before the human sees it. Links win on one thing — no typing. Shipping both and letting the user pick is a reasonable default; shipping only codes is a defensible one. ## Wire it up ```ts import { IdentityService, MemoryOtpStore, MemoryTokenFlowStore, RateLimiter, TokenFlowService, } from "@udibo/oauth2/identity"; import type { IdentityUserStore } from "@udibo/oauth2/identity"; export function createIdentityService( users: IdentityUserStore<{ id: string }>, mail: { enqueue(message: Record): void }, ): IdentityService<{ id: string }> { return new IdentityService({ users, tokens: new TokenFlowService(new MemoryTokenFlowStore()), otp: { store: new MemoryOtpStore() }, baseUrl: "https://app.example.com", rateLimiter: new RateLimiter(), delivery: { sendSignInLink: (message) => mail.enqueue({ to: message.to, template: "sign-in-link", url: message.url, expiresAt: message.expiresAt, }), sendSignInCode: (message) => mail.enqueue({ to: message.to, template: "sign-in-code", code: message.code, expiresAt: message.expiresAt, }), }, }); } ``` Three things to notice. `sendSignInCode` receives a `CodeDeliveryMessage`, not a `DeliveryMessage`: there is no `url`, because a code is typed rather than clicked. Putting the code behind a link in the email defeats the point — it re-introduces the cross-device problem you chose codes to avoid. The magic link lands on `/signin-link?token=…` under your `baseUrl` (see `buildSignInUrl` to change the path). As with every other emailed link, build it from configuration and never from the request's `Host` header. `otp` takes only storage and the code shape (`digits`, `ttlMs`, `maxAttempts`) — **not** a rate limiter. The service's own `rateLimiter` throttles both halves of the code flow. Omit `otp` and `requestSignInCode` throws; omit `tokens` and `requestSignInLink` throws. ## Implement `OtpStore` Codes need a different store from links because they carry an attempt budget: ```ts import type { OtpRecord } from "@udibo/oauth2/identity"; interface OtpStore { create(record: OtpRecord): Promise; findActive(email: string, purpose: string): Promise; recordAttempt(id: string): Promise; consume(id: string): Promise; invalidateById(id: string): Promise; invalidate(email: string, purpose: string): Promise; } ``` One table maps onto `OtpRecord` — `id`, `email`, `purpose`, `codeHash`, `expiresAt`, `attempts`, `maxAttempts`, `createdAt`. Only the **hash** is stored (SHA-256, domain-bound to email + purpose). The stored hash cannot be submitted as a working code, but a six-digit code has a small enough search space to recover through offline guessing after a database disclosure. Restrict access to these records and never log codes or hashes. Contract details a working implementation depends on: - **`consume` atomically claims an active code and returns `true` only once.** Concurrent and repeated claims return `false`; a read followed by a write is insufficient. Use a conditional delete/update with `RETURNING`. - **`recordAttempt` must be atomic and return the new count** — `UPDATE … SET attempts = attempts + 1 … RETURNING attempts`. The service reserves the attempt _before_ comparing the code, so at most `maxAttempts` guesses ever reach the comparison. A read-then-write lets concurrent verifies each test a guess against a stale count and blow straight through the budget. - **`findActive` must still return expired records.** Expiry is the service's concern — it needs to see the row to report `expired` distinctly from `invalid`. - **"Active" means neither consumed nor invalidated.** Deleting the row on consume/invalidate is the simplest way to satisfy that; reap expired rows on a schedule or opportunistically on insert. `MemoryOtpStore` implements the contract for development and tests. ## The request half: identical for everyone Both request methods resolve the same way whether or not the email maps to an account. Your route must not undo that: ```ts import { isIdentityError } from "@udibo/oauth2/identity"; import type { IdentityService } from "@udibo/oauth2/identity"; export async function requestCode( identity: IdentityService<{ id: string }>, email: string, ): Promise<{ status: number; body: { ok: true } }> { try { await identity.requestSignInCode(email); } catch (error) { if (!isIdentityError(error) || error.code !== "rate_limited") throw error; } return { status: 200, body: { ok: true } }; } ``` Catching `rate_limited` and returning the same success shape is deliberate: a `429` on the request endpoint tells an attacker which addresses are worth retrying. Keep the copy flat too — "If an account exists for that address, we've sent a code" — for both branches. The **return value** is uniform; the timing is not quite. Only the known-email branch awaits token minting and delivery, so an attacker measuring response latency has a residual oracle. Two mitigations, both at your route: - **Enqueue the mail, don't await your provider.** A queue hand-off is microseconds; an SMTP round-trip is hundreds of milliseconds of signal. - **Throttle by IP as well as by email.** The service has no request context — that seam is deliberately yours — and per-email limits alone don't stop a sweep across many addresses. ## The verify half Codes report only success or invalid: ```ts import type { IdentityService } from "@udibo/oauth2/identity"; export async function verifyCode( identity: IdentityService<{ id: string }>, email: string, code: string, ): Promise { const result = await identity.verifySignInCode({ email, code }); return result.status === "success" ? result.userId : null; } ``` `VerifySignInCodeResult` is deliberately narrower than the underlying `VerifyOtpResult`. `EmailOtpService.verify` distinguishes `invalid`, `expired`, and `locked`; `verifySignInCode` collapses all three — plus "no such email" — into `invalid`, because "your code expired" tells the sender that the address has an account. The precise reason is still recorded server-side on the `signin_code.failed` event, which is where it belongs. Links get the fuller result, because a high-entropy token is not enumerable and "this link expired, request a new one" is a real usability win: ```ts import type { IdentityService } from "@udibo/oauth2/identity"; export async function consumeLink( identity: IdentityService<{ id: string }>, token: string, ): Promise<{ userId: string } | { retry: boolean }> { const result = await identity.consumeSignInLink(token); if (result.status === "success") return { userId: result.userId }; return { retry: result.status === "expired" }; } ``` Whichever half returned a `userId`, three things are still your job: 1. **Re-resolve the user.** An account deleted or disabled since the code or link was minted must be treated as a failed sign-in, not signed in. 2. **Run your MFA gate.** Passwordless must not be the path around a second factor — see [add-mfa.md](https://github.com/udibo/oauth2/blob/main/docs/guides/add-mfa.md#the-challenge-one-gate-every-sign-in-path). 3. **Create the session.** The library never does. ## Throttling keys Both flows throttle on the service's `rateLimiter`, keyed by case-folded email so casing variants share one window: | Flow | Key | | ---------------------------------------- | -------------------- | | `requestSignInLink` | `pwless:` | | `requestSignInCode` / `verifySignInCode` | `otp:signin:` | The code flow deliberately shares one key between request and verify: an attacker who can burn attempts _and_ mint fresh codes at will gets an unbounded number of guesses at six digits. A successful `verifySignInCode` resets the window. On top of that, each individual code carries its own budget (`maxAttempts`, default 5) and dies when it is spent — so the two limits cover "guess this code" and "keep asking for new codes" separately. Running more than one instance, back the limiter with a shared `RateLimitStore` over Redis or your database; a per-process counter multiplies every limit by your instance count. ## A password reset voids both `IdentityService.resetPassword` drops the subject's pending sign-in links and the pending sign-in code once the password changes, so a link an attacker requested before the reset cannot be walked in on afterwards. It needs the optional `TokenFlowStore.deleteBySubject` to do the link half — a store without it leaves outstanding links redeemable until they expire — and it reaches the email-keyed code through the `data.email` that `requestPasswordReset` records on the reset token, so an app that mints reset tokens through `TokenFlowService.create` itself gets the link half only. ## Codes for other purposes `EmailOtpService` is purpose-generic — `purpose` is a free string — so the same machinery covers step-up confirmation, email change, or high-value action approval without a second implementation: ```ts import { EmailOtpService, MemoryOtpStore } from "@udibo/oauth2/identity"; const otp = new EmailOtpService({ store: new MemoryOtpStore(), ttlMs: 5 * 60_000, maxAttempts: 3, }); export async function confirmPayout( email: string, code: string, ): Promise { const result = await otp.verify({ email, purpose: "payout", code }); return result.status === "success"; } ``` Used standalone it takes its own `rateLimiter`; used through `IdentityService` it does not, because the service throttles for it. Sequential requests for a `(email, purpose)` pair invalidate earlier codes. Invalidation and creation are separate operations, so concurrent requests can leave multiple live codes. Serialize issuance across instances if your app requires only one outstanding code. Atomic `consume` ensures each individual code can succeed only once. A six-digit code has only one million possible values. Hash it at rest, restrict access to the store, and retain it briefly, but do not treat the hash as protection against offline guessing after a database disclosure. ## Security checklist Before going live: - [ ] **Request responses are uniform** in status, body, copy, and — as far as you can manage — timing. `rate_limited` is caught and reported as the same success. - [ ] **Mail is enqueued, never awaited in-request**, and the route is throttled by IP in addition to the service's per-email limit. - [ ] **`recordAttempt` is a single atomic statement** returning the new count. - [ ] **Codes are stored hashed**; nothing logs the raw code, including your delivery callback's error paths. - [ ] **Links are built from configured `baseUrl`**, arrive over HTTPS, and the landing route consumes the token on a **POST** (or immediately redirects without the token in the URL) so it doesn't leak through `Referer` or browser history. - [ ] **Codes are not embedded in a link** in the email body. - [ ] **The user is re-resolved and the MFA gate runs** before any session is created. - [ ] **`signin_link.*` and `signin_code.*` events are captured** server-side only; their `reason` fields are enumeration-grade detail. --- # Social and OIDC sign-in Add "Continue with Google", "Continue with GitHub", "Sign in with Apple", "Continue with Discord", any spec-compliant OpenID Connect provider, or any plain-OAuth2 provider to an app that owns its login. `@udibo/oauth2/identity/external` drives the redirect dance — CSRF `state`, PKCE, OIDC nonce, code exchange, id_token validation, profile normalization — and stops at a verified `ExternalProfile`. What happens next (create a user, link to an existing one, refuse) is app policy, and it is where every account-takeover bug in this feature lives, so most of this guide is about that half. The library stores **nothing**. The only state between the two legs is a JSON-serializable transient you keep. > **Hand-routed by design.** The `honoIdentityRoutes` factory mounts only the > password-credential path (`/signup`, `/signin`, password reset, email verify). > Social is not mounted, because the transient `state`/PKCE custody and the > account-resolution/linking policy — where the account-takeover bugs live — are > app policy the factory can't own for you, so you wire the start/callback legs > in your own routes, as shown below. ## How the pieces fit | Piece | Owned by | Job | | ----------------------- | -------- | --------------------------------------------- | | `ExternalProvider` | library | wire protocol for one provider | | `ExternalAuthFlow` | library | `state`, PKCE, nonce, expiry, error surfacing | | `ExternalAuthTransient` | you | per-attempt state, in a sealed cookie | | identity table | you | `(provider, subject)` → user id | | linking policy | you | what a profile is allowed to do to an account | ## Configure a provider `@udibo/oauth2/identity/external` ships six connectors. Pick by what the provider speaks, not by how popular it is: | Connector | Provider shape | Needs | | ----------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ | | `googleProvider` | OIDC, fixed endpoints | client id + secret | | `githubProvider` | plain OAuth2 (no `id_token`) | client id + secret | | `discordProvider` | plain OAuth2 (no discovery, no `id_token`) | client id + secret | | `appleProvider` | OIDC-shaped, but a signed-JWT client secret | Services ID, Team ID, Key ID, `.p8` private key ([below](https://github.com/udibo/oauth2/blob/main/docs/guides/social-sign-in.md#sign-in-with-apple)) | | `oidcProvider` | any issuer with a discovery document | issuer URL, client id, optional secret | | `oauth2Provider` | any plain-OAuth2 provider, no discovery, no OIDC | both endpoints, a userinfo endpoint, and a `mapProfile` | The Google and GitHub presets need only a client id and secret: ```ts import { ExternalAuthFlow, githubProvider, googleProvider, } from "@udibo/oauth2/identity/external"; export const google = new ExternalAuthFlow({ provider: googleProvider({ clientId: Deno.env.get("GOOGLE_CLIENT_ID")!, clientSecret: Deno.env.get("GOOGLE_CLIENT_SECRET")!, }), }); export const github = new ExternalAuthFlow({ provider: githubProvider({ clientId: Deno.env.get("GITHUB_CLIENT_ID")!, clientSecret: Deno.env.get("GITHUB_CLIENT_SECRET")!, }), }); ``` Anything else that speaks OIDC works through the generic connector, which resolves its endpoints from the issuer's discovery document: ```ts import { ExternalAuthFlow, MemoryDiscoveryCache, oidcProvider, } from "@udibo/oauth2/identity/external"; const discoveryCache = new MemoryDiscoveryCache(); export function acmeFlow(clientSecret: string): ExternalAuthFlow { return new ExternalAuthFlow({ provider: oidcProvider({ id: "acme", displayName: "Acme SSO", issuer: "https://sso.acme.example", clientId: "my-app", clientSecret, discoveryCache, }), }); } ``` Pass a shared `discoveryCache` whenever connectors are rebuilt per request — a server resolving provider config from a database, for instance — or every sign-in leg re-fetches the discovery document. Omit `clientSecret` for a public client; PKCE is on either way. **Getting the credentials.** Both presets need an OAuth client registered with the provider, and the redirect URI you register must match the `redirectUri` you pass to `start` **exactly** — scheme, host, port, path, no trailing slash mismatch. A mismatch shows up as a `provider_error` naming `redirect_uri_mismatch`. - **Google** — Google Cloud console → APIs & Services → Credentials → OAuth client ID, application type "Web application". Add `https://app.example.com/auth/social/google/callback` (plus your localhost variant) as an authorized redirect URI. - **GitHub** — Settings → Developer settings → OAuth Apps → New OAuth App. One callback URL per app, so development usually gets its own app. - **Generic OIDC** — whatever the provider's console calls a confidential client; you need the issuer URL, a client id, and a secret. Register the localhost callbacks separately rather than pointing production credentials at a development host. ### Sign in with Apple Apple is OpenID-Connect-shaped but does not fit `oidcProvider`, which is why it has its own connector. Three things are different, and all three are handled for you: - **The client secret is a signed ES256 JWT, not a string.** Apple wants a short-lived JWT signed with a `.p8` key you download once. `appleProvider` builds and caches it (`generateAppleClientSecret` / `createAppleClientSecretFactory` are exported if you need one directly), with a default lifetime of ~180 days under Apple's 6-month cap (`APPLE_CLIENT_SECRET_MAX_TTL_SECONDS`). No `openssl` step, no cron job to rotate a string, no dependency — Web Crypto signs it. - **The client authenticates in the token body** (`client_secret_post`), not with a Basic header. - **`response_mode=form_post` is required** whenever a profile scope is requested, so Apple returns to your `redirect_uri` with a **POST**, not a GET. Your callback route must accept `POST` and read `code` / `state` from the form body. This is the single most common way an Apple integration fails. ```ts import { appleProvider, ExternalAuthFlow, } from "@udibo/oauth2/identity/external"; export const apple = new ExternalAuthFlow({ provider: appleProvider({ clientId: Deno.env.get("APPLE_SERVICES_ID")!, teamId: Deno.env.get("APPLE_TEAM_ID")!, keyId: Deno.env.get("APPLE_KEY_ID")!, privateKey: Deno.env.get("APPLE_PRIVATE_KEY")!, }), }); ``` Four values, from three places in the Apple Developer console: the **Services ID** (Certificates, Identifiers & Profiles → Identifiers, of type "Services IDs" — _not_ your app's bundle id) is the `clientId`; the **Team ID** is top-right on the membership page; the **Key ID** and the `.p8` file come from Keys → a key with "Sign in with Apple" enabled. The `.p8` is downloadable exactly once — store its PKCS#8 PEM contents as a secret. Both the full `-----BEGIN PRIVATE KEY-----` block and the bare base64 body are accepted. The connector verifies the returned `id_token`'s signature against Apple's published JWKS and checks `iss`, `aud`, `azp`, `exp` and the `nonce` before trusting any claim. Generic `oidcProvider` has a different validation boundary, described below. Two Apple-specific facts about the profile that reaches your code: - **The user's name arrives once, and never in the `id_token`.** Apple puts it in the `form_post` body's `user` field on the _first_ authorization only. The connector returns what the `id_token` carries (subject, email), so capturing the name is your callback route's job — it is the only code that sees the form body. Miss it and it is gone; Apple will not send it again. - **Private-relay addresses are ordinary verified emails.** `…@privaterelay.appleid.com` comes back as-is. Treat it as a real, verified address (it forwards), and note that the user can turn forwarding off later. The raw claims, including `is_private_email`, stay on `profile.raw`. ### Discord, and other plain-OAuth2 providers Discord speaks plain OAuth2 — no discovery document, no `id_token` — so `discordProvider` is a thin preset over the generic `oauth2Provider`, pinned to Discord's endpoints. It reads the profile from `GET /users/@me`: the subject is the Discord user id, `emailVerified` reflects Discord's `verified` flag, and the display name prefers the global display name over the legacy username. ```ts import { discordProvider, ExternalAuthFlow, } from "@udibo/oauth2/identity/external"; export const discord = new ExternalAuthFlow({ provider: discordProvider({ clientId: Deno.env.get("DISCORD_CLIENT_ID")!, clientSecret: Deno.env.get("DISCORD_CLIENT_SECRET")!, }), }); ``` Anything else that speaks plain OAuth2 gets the same treatment through `oauth2Provider` directly. You supply the endpoints and a `mapProfile` that turns the provider's userinfo payload into an `ExternalProfile`; the connector runs the exchange (`client_secret_post`), fetches the profile with the bearer token, and hands it to your mapper: ```ts import { ExternalAuthFlow, oauth2Provider, } from "@udibo/oauth2/identity/external"; export const twitch = new ExternalAuthFlow({ provider: oauth2Provider({ id: "twitch", displayName: "Twitch", authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", tokenEndpoint: "https://id.twitch.tv/oauth2/token", userInfoEndpoint: "https://id.twitch.tv/oauth2/userinfo", clientId: Deno.env.get("TWITCH_CLIENT_ID")!, clientSecret: Deno.env.get("TWITCH_CLIENT_SECRET")!, defaultScopes: ["openid", "user:read:email"], usesPkce: true, mapProfile: ({ profile }) => ({ subject: String(profile.sub) }), }), }); ``` `mapProfile` is a trigger point with a sharp edge: a missing or empty `subject` in what you **return** surfaces as `provider_error`, but a mapper that **throws** surfaces raw. Return, don't throw. `usesPkce` defaults to `false` because many plain-OAuth2 providers reject the parameters — turn it on when the provider supports S256, as Twitch does. **Validation depends on the connector.** Apple verifies the ID-token signature against its JWKS and validates the claims. Generic `oidcProvider` and Google validate issuer, audience, expiry, nonce, and applicable authorized-party claims, while relying on the direct HTTPS token exchange for token integrity; they do not verify the ID-token signature. Generic OIDC rejects insecure non-loopback endpoints and provider-fetch redirects. Never feed it an ID token received through another channel. Plain OAuth2 connectors obtain profiles through authenticated provider API requests. In either case, the provider must be trusted for the identities it asserts. A verified email flag alone does not make an arbitrary provider trusted. ## The two routes `start` builds the provider URL and hands back the transient. Persist the transient and redirect: ```ts import type { ExternalAuthFlow } from "@udibo/oauth2/identity/external"; export async function beginSignIn( flow: ExternalAuthFlow, origin: string, seal: (value: unknown) => Promise, formPost = false, ): Promise { const { url, transient } = await flow.start({ redirectUri: `${origin}/auth/social/${flow.provider.id}/callback`, }); return new Response(null, { status: 302, headers: { location: url, "set-cookie": `social_transient=${await seal({ transient })}; ` + `Path=/auth/social; HttpOnly; Secure; SameSite=${ formPost ? "None" : "Lax" }; Max-Age=600`, }, }); } ``` For Apple, call `beginSignIn` with `formPost: true`, because its callback is a cross-site form POST. That transient cookie needs `SameSite=None; Secure`. Other redirect-based connectors use `SameSite=Lax`. This choice belongs to the configured provider, never to a browser-supplied parameter. The callback must read and unseal the browser's transient cookie, reject a missing or invalid value, and clear it before finishing the attempt. Accept form POSTs only for providers configured to use them, require the form content type, and apply a request-body size limit. Validate the transient even though Apple's POST cannot pass an ordinary same-origin form guard. `finish` validates state and provider binding, then returns the normalized profile: ```ts import type { ExternalAuthFlow, ExternalAuthTransient, ExternalProfile, } from "@udibo/oauth2/identity/external"; export async function completeSignIn( flow: ExternalAuthFlow, request: Request, transient: ExternalAuthTransient, ): Promise { const params = request.method === "POST" ? new URLSearchParams(await request.text()) : new URL(request.url).searchParams; return await flow.finish({ params, transient }); } ``` Between those two calls the flow has enforced the transient's max age (10 minutes by default, `maxTransientAgeMs`), compared `state` in constant time, surfaced any provider `error` parameter, exchanged the code, and — for OIDC providers — validated the id_token's `iss`, `aud`, `azp`, `exp`, and `nonce`. Route each provider's callback to the flow built with _that_ provider; a mismatched transient is rejected as a `configuration` error rather than silently trusted. ## Transient custody The transient holds the `state`, the PKCE verifier, and the nonce. It is not a credential, but it is the CSRF defense, so treat the cookie carrying it as one: - **Sealed, not plaintext.** `sealJson`/`unsealJson` from `@udibo/oauth2/crypto` over a server-held key is enough; a readable transient lets an attacker craft a matching `state`. - **`HttpOnly`, `Secure`, and path-scoped** to the callback. Use `SameSite=Lax` for redirects and `SameSite=None` for Apple's form POST. - **Short-lived.** Give the cookie a `Max-Age` no longer than `maxTransientAgeMs`, so the browser drops it around when the flow would reject it anyway. - **One-shot.** Clear it at the top of the callback, before any branch can return. A transient that survives its callback is a replayable attempt. A session works too if you already have one for anonymous visitors, but a cookie keeps the sign-in flow stateless and is what the flow's design assumes. ## Resolving the profile to an account `ExternalProfile` carries `provider`, `subject`, `email`, `emailVerified`, some display fields, and the provider's `raw` claims. Two rules govern what you do with it. **Key identities by `(provider, subject)`, never by email.** `subject` is the provider's stable id (OIDC `sub`, GitHub user id). Email addresses change, get reassigned inside a company domain, and — from a provider you don't control — are simply a string the provider chose to send you. Store a separate identities table so one user can hold several sign-in methods. **Treat `emailVerified: false` as attacker-controlled input.** It is `false` unless the provider positively asserted verification (`email_verified: true`, or GitHub's flag on the chosen primary address). Which gives three branches: ```ts import type { ExternalProfile } from "@udibo/oauth2/identity/external"; export type Resolution = | { action: "sign_in"; userId: string } | { action: "auto_link"; userId: string } | { action: "create" } | { action: "verify_first" }; export async function resolveProfile( profile: ExternalProfile, store: { findIdentity( provider: string, subject: string, ): Promise<{ userId: string } | null>; findUserByEmail( email: string, ): Promise<{ id: string; emailVerified: boolean } | null>; }, ): Promise { const identity = await store.findIdentity(profile.provider, profile.subject); if (identity) return { action: "sign_in", userId: identity.userId }; const collision = profile.email ? await store.findUserByEmail(profile.email) : null; if (!collision) return { action: "create" }; if (!profile.emailVerified || !collision.emailVerified) { return { action: "verify_first" }; } return { action: "auto_link", userId: collision.id }; } ``` The `verify_first` branch is the one that matters. Auto-linking on an **unverified** address on either side is an account takeover: anyone who can make a provider assert `victim@example.com` inherits the victim's account without presenting a credential. When either side is unverified, refuse the sign-in and tell the user to sign in with their existing method and connect the provider from their account settings — an explicitly initiated link, from an authenticated session, is safe where an implicit one is not. Some further hardening the branches don't show: - **Insert the identity row under a unique constraint** on `(provider, subject)` and handle the `409` by re-reading the winner. Two concurrent first-time callbacks otherwise create two users. - **Notify the account owner whenever a sign-in method is attached**, auto-linked or not. It is the only signal a user gets that a new key to their account exists. - **Start user-initiated linking from a same-origin POST**, not a GET. A GET link-start can be laundered cross-site into a forced-linking attack (RFC 6819 §4.4.1.13); a cross-site POST carries no cookie under `SameSite=Lax`. - **Refuse to link from an impersonated or elevated support session.** A link outlives the session that created it. ## Trust configured providers Configure provider endpoints in trusted application code or deployment settings. Do not let a sign-in request choose an issuer, profile endpoint, or automatic account-linking policy. Only enable automatic linking for providers your app trusts to verify that address; otherwise require the user to authenticate with an existing method before linking. ## When it goes wrong Every failure is an `ExternalAuthError` whose message names the provider, what failed, and the likely fix. Switch on `code` to decide who the failure belongs to: ```ts import { isExternalAuthError } from "@udibo/oauth2/identity/external"; export function describeFailure(error: unknown): string { if (!isExternalAuthError(error)) throw error; switch (error.code) { case "configuration": console.error("[social] miswired provider:", error.message); return "Sign-in with that provider is unavailable right now."; case "provider_error": case "invalid_callback": case "state_mismatch": case "transient_expired": case "nonce_mismatch": return "That sign-in attempt didn't complete. Please try again."; } } ``` `configuration` is a deploy-time bug — alert an operator, because retrying will not help. The other five are per-attempt failures: log the detail server-side and show the user one flat "try again". Don't echo the provider's error text into your UI; `access_denied` (the user pressed Cancel) and `redirect_uri_mismatch` (your registration is wrong) both arrive as `provider_error`, and only one of them is the user's business. ## Rendering the buttons The prebuilt sign-in and sign-up forms render a social section automatically when you pass `socialProviders`. Use `socialHref` so each button is a full-page navigation to your start route — a `fetch` can't follow the provider's redirect: ```tsx import { SignInForm } from "@udibo/oauth2/react/components"; const providers = [ { id: "google", name: "Google" }, { id: "github", name: "GitHub" }, ]; export function SignIn() { return ( `/auth/social/${provider.id}`} onSubmit={async (values) => { const res = await fetch("/auth/sign-in", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(values), }); if (!res.ok) return { error: "Those credentials didn't work." }; }} /> ); } ``` Pass `iconSlot` on a provider to render a logo before the label, and `classNames.socialButton` to theme them. ## Security checklist Before going live: - [ ] **Redirect URIs are registered exactly**, per environment, and production credentials never point at a development host. - [ ] **The transient cookie is sealed, `HttpOnly`, `Secure`, has the appropriate SameSite setting, path-scoped, short-lived, and cleared at the top of the callback.** - [ ] **Identities are keyed by `(provider, subject)`** under a unique constraint, never by email. - [ ] **Auto-link requires `emailVerified` on both sides**; anything else falls through to verify-first. - [ ] **User-initiated linking is a same-origin POST from an authenticated, non-impersonated session**, and the owner is notified on every link. - [ ] **Provider configuration is trusted application configuration**; users cannot supply arbitrary issuers or linking rules. - [ ] **The MFA gate runs before the session is minted** — see [add-mfa.md](https://github.com/udibo/oauth2/blob/main/docs/guides/add-mfa.md#the-challenge-one-gate-every-sign-in-path) — and the user is re-resolved for a disabled account. - [ ] **`configuration` errors alert an operator**; the rest show one flat message and log the detail server-side. --- # Migrate existing passwords into your application Import existing password hashes when moving your application to its own login. This requires an export you are authorized to use and a verifier for its format. When those are available, users can keep their passwords. `@udibo/oauth2/identity` verifies each user's existing (foreign-format) password hash on their first sign-in and, on a match, transparently rehashes the password into the package's native format. Users keep signing in with the password they already have; the migration completes one login at a time. - **[The model: upgrade-on-login](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md#the-model-upgrade-on-login)** - **[Which algorithms are built-in vs. bring-your-own](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md#which-algorithms-are-built-in-vs-bring-your-own)** - **[Wiring it up](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md#wiring-it-up)** - **[Bring-your-own verifiers (bcrypt / argon2 / scrypt)](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md#bring-your-own-verifiers)** - **[Bulk-importing your users](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md#bulk-importing-your-users)** - **[Auditing the migration](https://github.com/udibo/oauth2/blob/main/docs/guides/migrate-from-another-provider.md#auditing-the-migration)** Automatic upgrades require the optional `IdentityUserStore.replaceCredential` capability. It atomically compares the currently stored credential with the expected credential before replacing it; `undefined` expects no native credential during legacy import. If a password reset won the race, return `false`. Stores without this capability still authenticate, but skip automatic rehashing and legacy migration. Explicit resets use `setCredential`. ## The model: upgrade-on-login You import each user with their **foreign password hash** stored in a `legacyCredential` column and **no native credential**. On sign-in, `IdentityService`: 1. Checks the native credential first (absent for a not-yet-migrated user). 2. Reads the foreign hash via `IdentityUserStore.getLegacyCredential` and runs it through the configured `legacyVerifiers` (the first whose `canVerify` accepts the hash string). 3. On a match: rehashes the plaintext into the native PBKDF2 format (`replaceCredential`), clears the foreign hash (`clearLegacyCredential`), emits a `password.upgraded` event, and signs the user in. 4. From then on the native path is used — the foreign verifier is never called for that user again. The response is uniform (`null`), the return value never reveals whether an account exists, and the timing does not either: a wrong password for a not-yet-migrated account runs your (possibly slow, BYO) verifier where a migrated one runs a single native verify, so every rejected `signIn` is held to `failedSignInFloorMs` — 250 ms by default, measured from the start of the call rather than added to its work. Unpadded, that difference would name exactly the accounts still carrying an imported hash to any unauthenticated caller. **Size that floor against your slowest verifier.** A branch that outruns it returns as soon as its own work finishes, and whatever it spends above the floor is visible again — bcrypt at a high cost factor, or an argon2 verifier, can easily cost more than 250 ms, so measure yours and raise the option to suit. Per-IP throttling at your route is still worth having during the migration window; it bounds the volume the floor makes uniform. Native credentials are authoritative: once a user has a native credential (after their first legacy sign-in, or any password reset/change), the imported hash is never consulted again — a wrong password can't fall back to a stale foreign hash — so a reset can't be silently reverted. ## Which algorithms are built-in vs. bring-your-own The package ships **zero new runtime dependencies** — it uses Web Crypto only. That single constraint decides what can be built-in: | Family | Status | Why | | -------------------------- | ------------ | ------------------------------------------------------------------------------------------------------ | | **PBKDF2** (SHA-1/256/512) | **Built-in** | PBKDF2 is a Web Crypto primitive (`crypto.subtle.deriveBits`); `pbkdf2Verifier()` handles it dep-free. | | **bcrypt** | BYO | No Web Crypto primitive; needs a bcrypt implementation (Blowfish key schedule). | | **argon2** (id/i/d) | BYO | No Web Crypto primitive; memory-hard, needs a native/WASM argon2. | | **scrypt** | BYO | Not exposed by Web Crypto **or** `@std/crypto`; memory-hard (Salsa20/8 core). | | **MD5-crypt / SHA-crypt** | BYO | `$1$` / `$5$` / `$6$` use custom multi-round mixing, not a single digest. | **Built-in** means the package can verify it dep-free. **BYO (bring-your-own)** means you implement the tiny `LegacyPasswordVerifier` seam using your app's own dependency and pass it in. This is deliberate: shipping bcrypt/argon2/scrypt would force a dependency on every consumer, and the package stays a thin, composable layer — the same seam philosophy as `SessionStore` and the rate limiter. Most providers store **bcrypt**, so in practice you add one small BYO verifier. `parsePhc` is exported to parse the PHC / modular-crypt strings (`$argon2id$v=19$m=…$salt$hash`, `$scrypt$…`) those algorithms use, so a BYO verifier is only a few lines. ## Wiring it up Add a `legacyCredential` column to your users table (nullable text) and implement the two legacy hooks below. The existing store must also implement atomic `replaceCredential` as shown in [add-login.md](https://github.com/udibo/oauth2/blob/main/docs/guides/add-login.md#implement-identityuserstore-over-your-database); without it passwords verify but are never migrated. ```ts import { IdentityService, type IdentityUserStore, } from "@udibo/oauth2/identity"; import type { IdentityUser } from "@udibo/oauth2/identity"; import { type LegacyPasswordVerifier, pbkdf2Verifier, } from "@udibo/oauth2/identity/migration"; interface User extends IdentityUser { email: string; } declare const existingStore: & IdentityUserStore & Required, "replaceCredential">>; declare const bcryptVerifier: LegacyPasswordVerifier; declare const db: { users: { find(id: string): Promise<{ legacyCredential: string | null } | undefined>; update( id: string, patch: { legacyCredential: string | null }, ): Promise; }; }; const users: IdentityUserStore = { ...existingStore, async getLegacyCredential(userId) { const row = await db.users.find(userId); return row?.legacyCredential ?? null; }, async clearLegacyCredential(userId) { await db.users.update(userId, { legacyCredential: null }); }, }; const identity = new IdentityService({ users, legacyVerifiers: [pbkdf2Verifier(), bcryptVerifier], }); ``` `legacyVerifiers` is tried in order; put the formats you actually imported in the list. When it's unset (or the store lacks `getLegacyCredential`), the legacy path is skipped entirely and sign-in behaves exactly as before — imported-only users simply have no usable password until you enable it. ## Bring-your-own verifiers The seam is three members: a stable `id`, a synchronous `canVerify` format sniff, and an async constant-time `verify`. ### bcrypt (the common case) The app adds a bcrypt dependency — e.g. `npm:bcryptjs` (pure JS, no native build) — the package never does: ```ts ignore import { compare } from "bcryptjs"; import type { LegacyPasswordVerifier } from "@udibo/oauth2/identity/migration"; const bcryptVerifier: LegacyPasswordVerifier = { id: "bcrypt", canVerify: (phc) => /^\$2[aby]?\$/.test(phc), verify: (password, phc) => compare(password, phc), }; ``` ### argon2 ```ts ignore import { verify as argon2Verify } from "@node-rs/argon2"; import type { LegacyPasswordVerifier } from "@udibo/oauth2/identity/migration"; const argon2Verifier: LegacyPasswordVerifier = { id: "argon2", canVerify: (phc) => phc.startsWith("$argon2"), verify: (password, phc) => argon2Verify(phc, password).catch(() => false), }; ``` ### scrypt `parsePhc` gives you the parameters and salt/hash bytes; feed them to your scrypt dependency and compare: ```ts ignore import { scrypt } from "node:crypto"; import { timingSafeEqual } from "@std/crypto/timing-safe-equal"; import { type LegacyPasswordVerifier, parsePhc, } from "@udibo/oauth2/identity/migration"; const scryptVerifier: LegacyPasswordVerifier = { id: "scrypt", canVerify: (phc) => phc.startsWith("$scrypt$"), verify: (password, phc) => new Promise((resolve) => { const parsed = parsePhc(phc); if (!parsed?.salt || !parsed.hash) return resolve(false); const N = Number( parsed.params.ln ? 2 ** Number(parsed.params.ln) : parsed.params.N, ); const r = Number(parsed.params.r); const p = Number(parsed.params.p); scrypt( password, parsed.salt, parsed.hash.length, { N, r, p }, (err, dk) => { resolve(!err && timingSafeEqual(dk, parsed.hash!)); }, ); }), }; ``` ## Bulk-importing your users > **Handle the export as password material.** A foreign hash export is not a > harmless identifier list — each hash is an offline-crackable representation of > a real password. Move it over an encrypted channel, keep it out of logs and > object storage, delete it once the import lands, and lock down (authn + authz) > whatever endpoint performs the import — anyone who can write > `legacyCredential` can set a password hash they control on any account. Export your users from the old system and insert them with the foreign hash in `legacyCredential` and no native credential. Nothing special is required — it's your own insert: ```ts declare const oldExport: { email: string; emailVerified: boolean; passwordHash: string; }[]; declare const db: { users: { insert(row: Record): Promise }; }; for (const record of oldExport) { await db.users.insert({ email: record.email, emailVerified: record.emailVerified, passwordHash: null, // native credential — filled on first login legacyCredential: record.passwordHash, // the foreign PHC/modular-crypt string }); } ``` Then set `legacyVerifiers` for the formats present in the export. As users sign in, `legacyCredential` clears and `passwordHash` fills. You can optionally sweep rows that still have a non-null `legacyCredential` after a cutoff and send those users a password-reset link. Before the cutover, do a dry-run validation pass with `verifyLegacyPassword` to confirm your verifiers accept the exported hashes (against a known test account). ## Auditing the migration Every upgrade emits a `password.upgraded` event carrying the `verifierId` that matched, so you can watch migration progress through your existing `onEvent` audit sink: ```ts import { IdentityService } from "@udibo/oauth2/identity"; import type { IdentityUser, IdentityUserStore } from "@udibo/oauth2/identity"; import { type LegacyPasswordVerifier, pbkdf2Verifier, } from "@udibo/oauth2/identity/migration"; interface User extends IdentityUser { email: string; } declare const users: IdentityUserStore; declare const bcryptVerifier: LegacyPasswordVerifier; declare const metrics: { increment(name: string, tags: Record): void; }; declare const auditLog: { write(event: unknown): void }; const identity = new IdentityService({ users, legacyVerifiers: [pbkdf2Verifier(), bcryptVerifier], onEvent: (event) => { if (event.type === "password.upgraded") { metrics.increment("auth.password_upgraded", { from: event.verifierId }); } auditLog.write(event); }, }); ``` This guide documents the package's password-verification API. Export availability, account provisioning, and migration into Udibo's hosted service follow that service's own documentation and beta support process. --- # Configure local, preview, and production environments This guide applies when your app delegates sign-in to Udibo or another OAuth2/OIDC server. Use the same integration code with different configuration for each environment. Udibo is in [private beta](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md). ## Keep these values together | Setting | Example | Where it belongs | | ---------------------------- | --------------------------------------- | ------------------------------------------------------- | | Issuer | `https://auth.example.com` | Backend configuration; copy the issuer exactly | | App origin | `https://app.example.com` | Configured public origin, not arbitrary request headers | | Callback | `https://app.example.com/auth/callback` | Client registration and `DirectClient.redirectUri` | | Client ID and secret | Registration-specific values | Backend secret/configuration store | | Requested scopes | `openid profile read` | Scopes allowed by that registration | | Pending-login encryption key | A generated secret | Shared by instances in this environment | | Session store | Your application's persistent store | Shared by instances serving this application | Keep production credentials and data separate from development and previews. The issuer's actual registration policy determines which callback URLs and scopes it accepts; the package does not define a hosted-service management API. ## Local development Use a separate development registration, or the [local identity provider](https://github.com/udibo/oauth2/blob/main/docs/guides/run-a-local-identity-provider.md). Register the precise callback your browser will use, including the port. For a local HTTP app only, set the BFF's session cookie and its pending-login cookie factory to `secure: false`. Restore HTTPS defaults for deployed apps. `localhost` and `127.0.0.1` are different hosts; use one consistently in the app origin, registered callback, and browser address bar. ```ts import { EncryptedCookieAuthRequestStorage, HonoBff, } from "@udibo/oauth2/hono/bff"; import type { DirectClient } from "@udibo/oauth2/client"; declare const client: DirectClient; declare const localDevelopmentSecret: string; const bff = new HonoBff({ client, cookie: { secure: false }, authRequestStorage: new EncryptedCookieAuthRequestStorage({ secret: localDevelopmentSecret, cookie: { secure: false }, }), }); ``` This snippet uses the default in-memory session store and is local-only. It deliberately leaves state, PKCE, and CSRF protection enabled. ## Production Use a stable HTTPS app origin and an exact registered HTTPS callback. Configure the public origin explicitly when your reverse proxy uses internal HTTP to reach the application. Trust forwarded host/scheme headers only when your deployment validates them at a known proxy boundary. Use persistent sessions and pending-login storage that survives process restarts and works across replicas. An encrypted pending-login cookie is one option; `MemoryAuthRequestStorage` requires the same process for login and callback. See [application deployment](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#cookies-and-sessions). ## Preview deployments Choose one policy for previews: 1. **Skip real sign-in.** Use isolated fixtures for UI tests; do not expose production data or accept test sessions on production routes. 2. **Use a stable preview origin.** Register one HTTPS callback and route the preview environment through it. 3. **Register each preview callback explicitly.** Automate this only through your identity provider's documented administration interface. Keep those credentials out of untrusted pull-request jobs and remove expired previews. A preview URL changing on every deployment does not relax callback matching. Do not accept a callback origin from a query parameter or request header. For an app hosting its own authorization server, wildcard redirects are an optional advanced registration feature. They require `isPublicSuffix` and a narrow application-owned hostname pattern; they are unnecessary for the normal exact-callback setup. See [redirect policy](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#redirect-uri-policy-in-production). ## Verify each environment - Sign in using the registered callback in the same browser that started login. - Confirm cookies are present on both the callback and later application calls. - Repeat with login and callback reaching different application instances. - Verify production does not accept a development credential or callback. - Test logout, expired sessions, and a rejected refresh token. - Check that logs contain error codes and request IDs, without tokens, cookies, authorization codes, client secrets, or pending-login records. --- # Deploy your application Use this guide for the application you are deploying. If Udibo hosts sign-in, you operate the client, BFF, sessions, and API. If your app hosts its own authorization server, you also operate its credential, code, token, and signing-key storage. The package provides protocol handling; your deployment supplies persistent storage, secrets, delivery, and application policy. ## Choose the integration boundary | Responsibility | App using Udibo | App hosting authorization | | ------------------------------------------------------- | ------------------- | ------------------------------------------ | | Callback and application sessions | Your app | Your app | | API token validation and record-level access | Your app | Your app | | Login, password reset, MFA, and email delivery | Hosted sign-in flow | Your app's configured identity flows | | Registered clients, authorization codes, token issuance | Identity service | Your authorization server | | Token signing keys | Identity service | Your authorization server, if issuing JWTs | Udibo's hosted-service setup is described in [use Udibo](https://github.com/udibo/oauth2/blob/main/docs/guides/use-udibo.md). The rest of this guide documents package integration, not hosted-service administration. ## The multi-instance rule State needed by two requests must be available to either request's process. A local example can keep it in memory; a deployment with replicas, serverless isolates, or restarts needs shared persistence or an appropriate encrypted cookie. | State | Public contract | Requirement | | ---------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------- | | BFF session | `SessionStore` | Enforce expiry and revocation; stateful `update` must never recreate a missing session | | Pending browser login | `AuthRequestStorage` / `AuthRequestStorageFactory` | Preserve state and PKCE verifier across redirects; bind completion to the initiating browser | | Authorization codes | `AuthorizationCodeServiceInterface` | Atomic single-use consumption, expiry, client and redirect binding | | Tokens | `TokenServiceInterface` | Protect stored token material; atomic revocation/rotation claims | | App credentials | `IdentityUserStore` | Persist the complete credential, including `params`; unique identifiers | | Reset and verification links | `TokenFlowStore` | Expiring, single-use records; implement invalidation capabilities your app needs | | Email codes | `OtpStore` | Atomic attempt increments and boolean `consume`; protect low-entropy code hashes | | MFA | `MfaStore` | Protect TOTP secrets; atomically consume recovery codes | | Rate limits and lockout | `RateLimitStore` / `LockoutStore` | Shared counters and expiry across instances | Run the appropriate contract suite from `@udibo/oauth2/testing/contract` or `@udibo/oauth2/hono/bff/testing` against your actual adapter. Test concurrent callers against the database implementation, not just an in-memory substitute. ## Configuration inventory Keep public app origin, issuer, registered callback URLs, client credentials, requested scopes, session lifetimes, and storage configuration in deployment configuration. Validate required values at startup. Do not silently fall back to demo secrets or localhost URLs in production. For an app-owned issuer, construct `resolve` with the configured services and issuer. Do not derive the issuer or emailed links from an unvalidated `Host` header. ## Secrets Store client secrets, cookie encryption keys, signing keys, and mail credentials in your deployment's secret store. Use different values for each environment. Keep token responses, cookies, authorization codes, PKCE verifiers, reset links, and TOTP/recovery secrets out of logs and exception telemetry. Persist signing and encryption keys across deployments. Generating replacements at startup breaks existing sessions or token verification on every restart. ## Signing keys and rotation This section applies only when your app issues OIDC or JWT tokens. Generate a signing key with `deno run jsr:@udibo/oauth2/cli oidc keygen`, then store its output as a secret. The package's issuer signs with ES256; verify that your consumers accept that algorithm. Use `RotatingSigningKeyProvider` to publish the previous public key while signing new tokens with a new key: ```ts import { importSigningKeyJwk, RotatingSigningKeyProvider, } from "@udibo/oauth2/server/authorization"; declare const currentJwk: JsonWebKey; declare const previousJwk: JsonWebKey; const signingKeys = new RotatingSigningKeyProvider({ current: await importSigningKeyJwk(currentJwk), previous: [await importSigningKeyJwk(previousJwk)], }); ``` For routine rotation, keep the previous public key available through the longest outstanding token lifetime and relevant consumer cache windows. Compromise response may require retiring a key sooner and forcing reauthentication. Offline JWT validation cannot provide immediate per-token revocation. ## Stores you provision Choose the stores required by your enabled flows. A client using Udibo does not need local password or authorization-code tables. An app hosting password login needs credential storage and session revocation; email codes additionally need an `OtpStore`, while reset links and magic links use `TokenFlowStore`. Automatic password rehash or import upgrades require `IdentityUserStore.replaceCredential`. It must atomically compare all old credential fields before writing the replacement. A failed comparison writes nothing and the password is re-verified against the credential now stored; omitting the capability skips automatic upgrades. Explicit password resets still use `setCredential`. For rotating refresh tokens, `revokeRotated` (or the fallback `revoke`) must return `true` only to the caller that consumed the old credential. Keep replay tombstones and family revocation state if you enable reuse detection. The library's calls are separate storage operations; your adapter must account for competing writes and failures. Revoking a family must also prevent a racing save from restoring it. Sequential OTP resends invalidate prior codes. Concurrent requests use separate invalidate/create calls, so that alone does not guarantee one outstanding code. Serialize issuance for each `(email, purpose)` across instances when your application requires that policy. Atomic `consume` separately ensures a given code succeeds once. ## Cookies and sessions The BFF defaults to Secure, HttpOnly, host-bound cookies and enables CSRF checks. Keep those settings on HTTPS deployments. The pending authorization cookie needs to survive the authorization server's redirect back to your app; use the provided pending-login cookie factory or a correctly scoped app-owned store. Prefer a stateful `SessionStore` when your app needs immediate revocation, session listings, or backchannel logout. `EncryptedCookieSessionStore` is an alternative with a different contract: a copied cookie remains usable until expiry, even after logout clears the original browser's cookie. It also has browser cookie-size limits. See [session limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md#sessions-udibooauth2honobff). Keep the session lifetime consistent between the store and `HonoBff.sessionMaxAgeMs`. A stateful store's update must reject revoked, expired, or missing records; a refresh finishing after logout must not insert a session again. If you expose a session list, return a separate, non-secret record ID as `SessionSummary.id`. The BFF's `cookieValue` is a credential and must never appear in listings, URLs, analytics, or browser-readable session summaries. ## TLS and the reverse proxy Serve deployed callbacks, token endpoints, and APIs over HTTPS. Configure your proxy to preserve the external origin in a trusted way and forward the request headers required by your CSRF policy. Do not trust arbitrary client-supplied forwarded headers. Generic OIDC connectors rely on direct TLS token exchange for ID-token integrity. They validate claims but do not verify signatures. Configure trusted issuers, retain endpoint HTTPS checks, and never use these connectors to accept ID tokens obtained through unrelated channels. Apple uses separate signature verification. ## CSRF, CORS, and security headers Keep the BFF and browser on the same origin where possible. `BffClient` sends the required CSRF header. Handwritten requests must follow the same contract. A React route guard changes rendering; it does not authorize an API request. `honoIdentityRoutes` includes a same-origin guard on unsafe requests. Custom login, social-linking, MFA, session-revocation, and password-change routes must apply their own authentication and CSRF policy. Bind sensitive actions to the current authenticated user rather than trusting a submitted user ID. Configure security headers for your app's content and hosting environment. Do not log or place reset tokens in third-party analytics URLs. Use a restrictive referrer policy on pages that receive credentials in URLs. ## Redirect-URI policy in production Register exact HTTPS callbacks for web applications. Keep local development registrations separate. Native loopback IP redirects have protocol-specific port matching; do not treat that exception as a general wildcard. If your own server accepts wildcard registrations, supply `isPublicSuffix` from `@udibo/oauth2/server/public-suffix` and constrain patterns to a hostname namespace your application controls. Shared hosting domains are not an ownership boundary. Exact callbacks avoid this additional policy surface. ## Rate limiting, lockout, and password policy `IdentityService` always applies password policy, but rate limiting, account lockout, and CAPTCHA are opt-in integrations. Construct the protections you need; `protectionMode: "log-only"` records decisions without enforcing them. Default deployed configuration should enforce. Apply IP/request throttling at the route layer as well as identifier-based limits within identity flows. Rate-limit mail requests and code verification. A six-digit code has only a million possibilities: storing its hash does not make a leaked code database resistant to offline guessing. For password reset, implement `TokenFlowStore.deleteBySubject` to invalidate outstanding sign-in links. Reset also attempts OTP invalidation when the reset token contains the email. These cleanup operations are best-effort; decide how your app responds to failures and monitor them. See [known limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md#identity-flows-udibooauth2identity). ## Backups and restore Back up persistent credentials and MFA records with appropriate access controls. Keep encryption keys separately protected and rehearse restoration. A restore can reintroduce sessions or tokens that were revoked after the backup; include reauthentication or explicit revocation in the recovery procedure. Apply retention to expired codes, tokens, sessions, and audit events. Retain rotation tombstones long enough for your chosen replay-detection policy. ## Observability, audit, and health checks Record request IDs, flow outcomes, authenticated client IDs, and stable error codes. Avoid logging raw request bodies on credential endpoints. Treat audit callbacks as observation: a callback whose errors are isolated is not an enforcement gate. Use the [extension reference](https://github.com/udibo/oauth2/blob/main/docs/trigger-points.md) to check when a callback runs and how failures propagate. Monitor failed callbacks, refresh rejection, storage errors, email-delivery failures, and token-validation timeouts. Test recovery from an unavailable issuer or database without relabeling those failures as bad credentials. ## Before you go live Complete the [deployment checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md) against your chosen integration. Test in the actual proxy, cookie, and storage environment; a local example passing does not validate production configuration. --- # Application deployment checklist Use the items that apply to your integration. For explanations, see [deploy your application](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md). If Udibo hosts sign-in, review your client/BFF/API boundaries; the app-owned identity and issuer sections apply only when you host those flows. ## Configuration and secrets - [ ] Issuer, app origin, and exact callbacks are configured for this environment. - [ ] Production credentials and data are separate from local and preview environments. - [ ] No demo credentials, fallback secrets, or local test-provider endpoints remain. - [ ] Client secrets, cookie keys, and signing keys stay on the backend and out of logs. ## Browser, BFF, and API - [ ] HTTPS cookie and CSRF defaults are enabled; custom browser requests send the CSRF header. - [ ] Login state and PKCE verifier survive callbacks to a different app instance. - [ ] Sessions expire consistently and stateful updates reject revoked or missing sessions. - [ ] Local logout, upstream revocation, and optional SSO logout have each been tested. - [ ] Session listings expose non-secret row IDs, never cookie credentials or token material. - [ ] The API validates issuer, token type, applicable audience, and required scopes. - [ ] Record-level authorization runs on the server, independently of React rendering guards. - [ ] The app handles invalid tokens separately from issuer outages. - [ ] Any proxy has a fixed trusted target; trusted-forwarded-header policy matches deployment. ## If your app hosts an authorization server - [ ] PKCE, state, and confidential-client authentication are enabled. - [ ] Registered callbacks and allowed grants are restricted to the app's actual clients. - [ ] Codes and refresh-token rotation claims are consumed atomically. - [ ] Replay-family revocation cannot be undone by a concurrent token save. - [ ] Client-credentials tokens represent the client or its service account, not its human owner. - [ ] Consent and scope policy match the clients you allow. - [ ] Persisted signing keys survive deployment; rotation and JWT revocation limits are understood. - [ ] Deprecated password/implicit grants are not enabled for new integrations. ## If your app hosts login - [ ] The full password credential, including `params`, is persisted. - [ ] Automatic rehash/import uses atomic `replaceCredential`; a failed comparison writes nothing, and the sign-in proceeds only if the password re-verifies against the credential now stored. - [ ] Rate limiting and lockout are explicitly configured and enforced. - [ ] Email requests and OTP/MFA attempts are throttled; concurrent resend policy is explicit. - [ ] OTP consumption and recovery-code consumption permit one successful caller. - [ ] Password reset invalidates sessions; optional sign-in-link/OTP cleanup is implemented and monitored. - [ ] Email verification is conditional on the current address matching the address proved. - [ ] MFA enrollment, disable, recovery, and verification routes authenticate and authorize the user. - [ ] A completed first factor creates pending MFA state, not a full application session. - [ ] External identities are keyed by trusted provider and subject; linking requires account proof. - [ ] Apple callbacks accept form POSTs and use the required Secure, SameSite=None transient cookie. ## Verification and maintenance - [ ] Contract suites pass against the actual persistent adapters. - [ ] Tests cover concurrent redemption, callback mismatch, expired/revoked sessions, and insufficient scope. - [ ] Backups, retention, and recovery are exercised without restoring revoked access unintentionally. - [ ] Audit records omit credentials; delivery and persistence failures are observable. - [ ] The [known limitations](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) have been reviewed for enabled features. --- # Run a Local Identity Provider `idp dev` runs a real OAuth2/OIDC authorization server on a port of your machine, seeded from a JSON file. Point any application at it — in any language — and sign in without an internet round trip, a shared development tenant, or a rate limit. ```sh deno run --allow-net --allow-read --allow-env jsr:@udibo/oauth2/cli idp dev ``` The protocol behavior is the same `AuthorizationServer` a production deployment runs: the same grants, the same PKCE enforcement, the same discovery document. What makes it a development tool is where state lives (a `Map`, dropped when the process exits) and the `/__admin/` endpoints, which mint tokens for any seeded user without their password. > **Development and CI only.** Anyone who can reach this server _and_ holds its > admin token can mint a token for any seeded user. Never point a production > application at it. ## What is enforced, and what is only documented Three things the server enforces rather than leaving to your discipline: - **The admin endpoints require a token.** `idp dev` prints one at startup (set it yourself with `--admin-token` or `IDP_ADMIN_TOKEN`), and every `/__admin/` request must carry it in the `x-admin-token` header. Because that is a custom header, a browser has to send a CORS preflight first, which this server never answers — so a page you happen to visit while developing cannot drive the admin API. Admin POSTs must also be `content-type: application/json`, which rules out the "simple" request shapes a cross-origin form can send, and any request arriving with an `Origin` header is refused outright. - **A non-loopback bind needs an explicit opt-in.** The default is `127.0.0.1`. Binding anything else — `0.0.0.0` in a container, a LAN address — fails unless you pass `--unsafe-remote-access`, and when you do, the banner says so and a warning goes to stderr. - **The issuer is fixed at startup**, never read from a request's `Host` header, so a forged `Host` cannot change what your tokens claim. Everything else is documentation, not enforcement: seeded passwords are plaintext in a config file, nothing is rate limited, and handing out tokens is the admin API's whole purpose. Treat the admin token as a gate that stops accidents, not as a boundary you would put on the internet. ## What you get out of the box With no config file, the server starts on port 9000 with one user and two clients, and prints all of it: ``` Listening on http://127.0.0.1:9000 Reachable from: this machine only (loopback) Issuer: http://127.0.0.1:9000 (derived from the bind address; pin it with --issuer) Discovery: http://127.0.0.1:9000/.well-known/openid-configuration Admin token: 4f1c… (send as the x-admin-token header) Users: alice@example.com password: password sub: user-alice Clients: dev-client secret: dev-secret grants: authorization_code, refresh_token, client_credentials redirect URIs: http://localhost:3000/callback, ... dev-public-client public client ``` Endpoints are mounted at the root, so discovery sits exactly where an OIDC client library looks for it: | Path | What it is | | ----------------------------------------- | ------------------------------- | | `/.well-known/openid-configuration` | OIDC discovery | | `/.well-known/oauth-authorization-server` | RFC 8414 metadata | | `/authorize`, `/token` | The authorization code flow | | `/userinfo`, `/jwks` | OIDC issuance | | `/revoke`, `/introspect` | RFC 7009 / RFC 7662 | | `/login`, `/logout`, `/consent` | The browser-facing pages | | `/__admin/*` | The test control surface | | `/` | A status page listing all of it | Most client libraries need nothing but the issuer. If yours wants explicit endpoints, read them from the discovery document rather than hardcoding them. ### The issuer is fixed at startup The issuer is resolved once, when the server starts: `"issuer"` from the config (or `--issuer`) if you set one, otherwise the bind address — so the default is `http://127.0.0.1:9000`. It is deliberately **not** derived from each request's `Host` header, which a client controls and can forge. The practical consequence: if your app reaches the server under a different name than it bound — `localhost` rather than `127.0.0.1`, or a container service hostname like `idp` — the tokens' `iss` will not match the URL your app used, and a strict OIDC client will reject them. Pass `--issuer` with the URL your app actually uses: ```sh deno run --allow-net --allow-read --allow-env jsr:@udibo/oauth2/cli idp dev \ --hostname 0.0.0.0 --unsafe-remote-access --issuer http://idp:9000 ``` ## Configuration Everything is declared in one JSON file — using this tool means running a program, not writing one. ```sh deno run --allow-net --allow-read --allow-env jsr:@udibo/oauth2/cli idp dev \ --config idp.json ``` ```json { "port": 9000, "hostname": "127.0.0.1", "consent": "auto", "scopesSupported": ["openid", "profile", "email", "orders:read"], "accessTokenLifetime": 3600, "grants": { "authorization_code": true, "client_credentials": true, "refresh_token": true, "password": false }, "users": [ { "id": "user-alice", "username": "alice@example.com", "password": "password", "claims": { "name": "Alice Example", "email": "alice@example.com", "email_verified": true } } ], "clients": [ { "id": "web-app", "secret": "dev-secret", "redirectUris": ["http://localhost:3000/callback"], "grants": ["authorization_code", "refresh_token"] } ] } ``` | Field | Default | Notes | | ------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `issuer` | the bind address | Pin it to the URL your app uses to reach the server. | | `hostname` / `port` | `127.0.0.1` / `9000` | `0` asks the OS for a free port. | | `consent` | `"auto"` | `"prompt"` shows a consent screen instead of granting silently. | | `scopesSupported` | `openid profile email offline_access` | Advertised in discovery. | | `accessTokenLifetime` | `3600` | Seconds. Drop it to a few seconds to test expiry handling. | | `grants` | all but `password` | Server-wide switches; each client also lists its own grants. | | `signingKey` | generated per process | An ES256 private JWK. See below. | | `users[].id` | the username | Becomes the `sub` claim. | | `users[].claims` | `{}` | Merged into the id_token and UserInfo response. | | `clients[].secret` | none | Omit for a public (PKCE-only) client. | | `clients[].redirectUris` | none | Matched **exactly** (no wildcard seam here) — list every URI. A loopback IP literal (`http://127.0.0.1`, `http://[::1]`) matches on any port, per RFC 8252 §7.3. | | `clients[].grants` | `authorization_code refresh_token` | A grant the client does not list is rejected. | | `clients[].ownerUserId` | none | Puts a user on the client's `client_credentials` token. Omit it for a machine token with no user, whose subject is the client itself. | Unknown fields are rejected rather than ignored, so a typo fails at startup instead of silently doing nothing. Flags (`--port`, `--hostname`, `--issuer`) override the file. The device authorization grant is not served — it needs a verification UI that this tool does not ship. Everything else in the package's grant set is available. ## Stable signing keys By default the server generates a key per process, so tokens signed before a restart fail verification after it. That is fine for a run-to-completion test suite and wrong for anything that caches JWKS or holds a token across a restart. Generate a key once and hand it to the server: ```sh deno run jsr:@udibo/oauth2/cli oidc keygen ``` Pass it as `OIDC_SIGNING_KEY`, or paste the JWK into the config file's `signingKey` field. `OIDC_SIGNING_KEY` wins when both are set, so CI can override a committed config without editing it: ```sh OIDC_SIGNING_KEY='{"kty":"EC",...}' deno run --allow-net --allow-read \ --allow-env jsr:@udibo/oauth2/cli idp dev --config idp.json ``` The server never writes key material to disk. When no key is supplied it says so on stderr — if you see that warning in CI, the key is not reaching the process. ## Signing in `GET /authorize` behaves the way a hosted provider does: with no session it returns a sign-in page, and after sign-in it redirects back to your `redirect_uri` with a code. The page has a `username` and `password` field, plus a one-click button per seeded user (`name="as"`), which signs that user in without a password — convenient by hand, and the fastest path in a browser test that is not about credentials. Elements carry stable ids: `#sign-in-form`, `#username`, `#password`, `#sign-in`, `#sign-in-error`. With `"consent": "prompt"`, an approval screen (`#consent-form`, `#approve`, `#deny`) appears before the code is issued. Denial redirects back with `error=access_denied`, which is the branch most applications never test. `GET /logout` drops the session. It honours `post_logout_redirect_uri` only when the value exactly matches a redirect URI one of the configured clients registered — an unregistered target is answered with a 400 rather than a redirect, so the endpoint cannot be used as an open redirect. ## The test control surface Four endpoints under `/__admin/` exist so end-to-end tests can skip the parts they are not testing. Every one of them requires the `x-admin-token` header carrying the token printed at startup, and every POST must be `content-type: application/json`. Choose the token up front with `--admin-token` or `IDP_ADMIN_TOKEN` so your tests can hold it before the server starts. **Reset between scenarios.** Drops every session, authorization code, and token, and re-seeds from the config: ```sh curl -X POST http://localhost:9000/__admin/reset \ -H "x-admin-token: $IDP_ADMIN_TOKEN" -H 'content-type: application/json' ``` **Sign a user in without the form.** Returns a session cookie; a browser context that stores it goes straight through `/authorize`: ```sh curl -X POST http://localhost:9000/__admin/session \ -H "x-admin-token: $IDP_ADMIN_TOKEN" -H 'content-type: application/json' \ -d '{"username":"alice@example.com"}' ``` **Mint tokens without a browser.** Runs the real authorization-code exchange internally and returns the ordinary token response — `access_token`, `refresh_token`, and `id_token` signed by the published key: ```sh curl -X POST http://localhost:9000/__admin/tokens \ -H "x-admin-token: $IDP_ADMIN_TOKEN" -H 'content-type: application/json' \ -d '{"clientId":"web-app","username":"alice@example.com","scope":"openid email"}' ``` Accepts `userId` instead of `username`, and `redirectUri` when the client has more than one. Omit `scope` to get every supported scope. **Inspect what is seeded.** `GET /__admin/state` (also token-gated) returns the users, clients, and live session count — useful when a test fails and you want to know whether a reset actually happened. ## In CI No container is required: the CLI is one command, and GitHub Actions can run it in the background. ```yaml - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Start the identity provider env: OIDC_SIGNING_KEY: ${{ secrets.CI_OIDC_SIGNING_KEY }} IDP_ADMIN_TOKEN: ${{ github.run_id }}-idp-admin run: | deno run --allow-net --allow-read --allow-env \ jsr:@udibo/oauth2/cli idp dev --config idp.ci.json \ --issuer http://localhost:9000 & timeout 30 bash -c \ 'until curl -sf http://localhost:9000/.well-known/openid-configuration \ >/dev/null; do sleep 0.5; done' - run: npm test env: OIDC_ISSUER: http://localhost:9000 IDP_ADMIN_TOKEN: ${{ github.run_id }}-idp-admin ``` Setting `IDP_ADMIN_TOKEN` yourself is what lets the test step reach the admin API — otherwise the token is random per run and only printed on stdout. The signing key does not have to be a secret (nothing it signs is trusted outside CI), but pinning it is what makes a cached JWKS survive a job that restarts the server. If your pipeline prefers service containers, wrap the CLI in an image: ```dockerfile FROM denoland/deno:alpine COPY idp.ci.json /idp.json EXPOSE 9000 CMD ["run", "--allow-net", "--allow-read", "--allow-env", \ "jsr:@udibo/oauth2/cli", "idp", "dev", \ "--config", "/idp.json", "--hostname", "0.0.0.0", \ "--unsafe-remote-access", "--issuer", "http://idp:9000"] ``` A container has to bind `0.0.0.0` for its port to be reachable, which is exactly the case `--unsafe-remote-access` exists to make deliberate: the admin API is now reachable by anything on that network, so keep it to a CI job's private network and set `IDP_ADMIN_TOKEN`. Pin `--issuer` to the hostname your tests use to reach the service. ## With Playwright Start the server once for the run, and reset between tests: ```ts ignore // playwright.config.ts export default defineConfig({ webServer: { command: "deno run --allow-net --allow-read --allow-env jsr:@udibo/oauth2/cli idp dev --config idp.json", env: { IDP_ADMIN_TOKEN: process.env.IDP_ADMIN_TOKEN! }, url: "http://localhost:9000/.well-known/openid-configuration", reuseExistingServer: !process.env.CI, }, }); ``` ```ts ignore const IDP = "http://localhost:9000"; const admin = { "x-admin-token": process.env.IDP_ADMIN_TOKEN! }; test.beforeEach(async ({ request }) => { await request.post(`${IDP}/__admin/reset`, { headers: admin, data: {} }); }); test("signs in through the identity provider", async ({ page }) => { await page.goto("/"); await page.getByRole("link", { name: "Sign in" }).click(); await page.fill("#username", "alice@example.com"); await page.fill("#password", "password"); await page.click("#sign-in"); await expect(page.getByText("alice@example.com")).toBeVisible(); }); test("shows the dashboard for a signed-in user", async ({ page, context }) => { await context.request.post(`${IDP}/__admin/session`, { headers: admin, data: { username: "alice@example.com" }, }); await page.goto("/dashboard"); await expect(page.getByRole("heading", { name: "Orders" })).toBeVisible(); }); ``` The second test never touches the sign-in page: the admin session cookie is stored in the browser context, so the redirect through `/authorize` completes without a stop. Tests about sign-in drive the form; tests about everything else skip it. ## How this differs from production | | `idp dev` | A production authorization server | | ------------- | ------------------------------ | ---------------------------------------------- | | Storage | In memory, dropped on exit | Your database, behind `@udibo/oauth2/server` | | Users | Seeded from a JSON file | Your identity flows (`@udibo/oauth2/identity`) | | Passwords | Plaintext in a config file | Hashed, with lockout and rate limiting | | Token minting | Anyone holding the admin token | Only the protocol | | Tenants | None | Whatever your application models | It is also not a self-host path for Udibo's hosted identity service, which adds tenants, persistence, and an administrative dashboard. `idp dev` is a development and test dependency, deliberately. To build the real thing, see [Become an OAuth provider](https://github.com/udibo/oauth2/blob/main/docs/guides/become-an-oauth-provider.md). To wire an application to any provider — this one included — see [Add login to an existing app](https://github.com/udibo/oauth2/blob/main/docs/guides/add-login.md) and [Local, preview, and production for a relying party](https://github.com/udibo/oauth2/blob/main/docs/guides/deploy-across-environments.md). ## Checklist - [ ] The server is bound to loopback, or `--unsafe-remote-access` was a deliberate choice on a network only the test job can reach. - [ ] `IDP_ADMIN_TOKEN` is set wherever a test needs the admin API, and is not shared outside that job. - [ ] `--issuer` matches the URL your application uses to reach the server. - [ ] Every redirect URI your app sends is listed on the client, exactly — including any `post_logout_redirect_uri`. - [ ] `OIDC_SIGNING_KEY` is set wherever a token or a cached JWKS has to outlive a restart. - [ ] The application reads its endpoints from discovery, not from constants. - [ ] Tests reset state between scenarios instead of depending on order. - [ ] Nothing in production configuration points at this server. --- # Extension reference: callbacks and storage contracts Use this reference when adapting the package to your application's data and policy. It lists constructor callbacks and storage interfaces, when the package calls them, and whether their return value or failure can stop a flow. The exported symbol is the stable API name; the descriptive label is only a navigation aid. ## How to read the reference Two properties decide how a trigger point behaves, and every row states both. - **Control** — what the seam can do to the flow: - **blocks** — the seam runs _inside_ the flow and can stop it. It denies by return value (`null`, `false`, `{ approved: false }`, an issue string) or by throwing; either way the flow does not continue as if nothing happened. - **fire-and-forget** — the seam is for a side effect (audit, notification, re-render). Its throw is caught and logged, never rethrown, so it _cannot_ affect the outcome. Use it when you need observability, not a gate. - **On throw** — what a thrown error actually does at the call site, traced to the code, because this is where accidental and deliberate behaviour diverge. A **blocks** seam usually surfaces the throw as a specific protocol error; a **fire-and-forget** seam swallows it. Rows call out the cases where a throw does something surprising. **Stability.** A trigger-point's export name is public API under [stability.md](https://github.com/udibo/oauth2/blob/main/docs/stability.md) — renaming one is a breaking change, and its throw contract is part of the wire behaviour the servers promise. The **Symbol** column names the exact export; that name, not the informal trigger-point label, is the stable identifier. ## Authorization server — `@udibo/oauth2/server` Seams on `AuthorizationServer` and its grants. Unless noted, a throw from a seam reached through an endpoint is caught by that endpoint and converted to an RFC 6749 §5.2 error response (or, on the authorize path once the redirect URI is validated, a redirect to the client with `error=…`). | Trigger point | Symbol | Fires | Required | Control | On throw | | --------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | introspection-authorization | `canIntrospectToken` (`AuthorizationServerOptions`) | After client authentication and live-token lookup, before claims enrichment | no (omit → all admitted clients may inspect any token) | blocks | false → inactive response; throw → error response | | services-resolver | `resolve` (`AuthorizationServerOptions`) | Top of every endpoint, to resolve per-request services | yes | blocks | Converted to an error response on token/authorize/revoke/introspect/device, and on the metadata and JWKS discovery endpoints | | authenticate | `AuthenticateUserFn` (`handleAuthorizeRequest` arg) | `GET /authorize`, after client/PKCE validation | yes | blocks | `null` → `access_denied`; a `Response` short-circuits; an OAuth2 error redirects to the client, other throws → direct error response | | consent | `HandleConsentFn` (`handleAuthorizeRequest` arg) | `GET /authorize`, only when the requested scope needs consent | no (omit → auto-grant) | blocks | `{ approved: false }` denies; same redirect/error conversion as authenticate | | token-claims | `userClaims` (`AuthorizationServerOptions`) | Assembling id_token and UserInfo claims (never the access token) | no | blocks | Converted to a token-endpoint / UserInfo error response | | subject | `subjectOf` (`AuthorizationServerOptions`) | Assembling the `sub` for id_token and UserInfo | no (defaults to `user.id`) | blocks | Converted to a token-endpoint / UserInfo error response | | signing-key | `SigningKeyProvider` (`signingKeys`) | `mintIdToken` (`getSigningKey`) and `GET /jwks` (`getPublicJwks`) | no (unset → OIDC surface off) | blocks | `getSigningKey` throw → token-endpoint `server_error`; `getPublicJwks` throw → JWKS-endpoint error response | | redirect-guard | `IsPublicSuffix` (`isPublicSuffix`) | `GET /authorize`, validating a wildcard `redirect_uri` | no (unset → wildcards refused) | blocks | Caught before a redirect URI is chosen → direct error response (bundled impl never throws). Must be sync, no I/O | | challenge-method | `ChallengeMethods` (`challengeMethods`) | PKCE verification during the code→token exchange | no (defaults to `S256`) | blocks | Converted to a token-endpoint error response | | client-store | `ClientServiceInterface` (`clientService`) | Client lookup/auth on every token/revoke/introspect/device call | yes | blocks | `undefined` → `invalid_client`; other throws converted to an error response | | token-store | `TokenServiceInterface` (`tokenService`) | Token issuance, refresh, revoke, introspect | yes | blocks | Converted to a token-endpoint error response | | token-reuse | `onTokenReuse` (`RefreshTokenGrantOptions`) | A rotated-out refresh token is replayed, after the family is revoked | no | fire-and-forget | Isolated: caught and logged, never rethrown, so a throw can't change the `invalid_grant` a replayed token yields | | refresh-cap | `refreshTokenFamilyExpiresAt` (`TokenServiceInterface`) | Refresh-token issuance and rotation | no (absent → uncapped) | blocks | A past cap → `invalid_grant`; other throws → token-endpoint error response | | accepted-scope | `acceptedScope` (`TokenServiceInterface`) | Scope narrowing during issuance | yes (on the interface) | blocks | `false` → `invalid_scope`; other throws converted | | authorization-code-store | `AuthorizationCodeServiceInterface` (`authorizationCodeService`) | Authorize (mint code) and code→token exchange | with the code grant | blocks | Missing/expired → `invalid_grant`; other throws converted | | device-code-store | `DeviceAuthorizationServiceInterface` (`deviceAuthorizationService`) | Device-authorization request and polling | with the device grant | blocks | RFC 8628 codes (`authorization_pending`, `slow_down`, `expired_token`, …); other throws converted | | user-authenticator | `UserServiceInterface` (`userService`) | `PasswordGrant.token` — the only MFA-enforcement point for the (deprecated) password grant | with the password grant | blocks | `undefined` → `invalid_grant`; a thrown OAuth2 error surfaces verbatim, other throws → `server_error` | | scope-constructor | `ScopeConstructor` (`Scope`) | Parsing/comparing scope strings | no (defaults to `BasicScope`) | blocks | Follows its host flow's conversion path | ## Resource server — `@udibo/oauth2/server/resource` `authenticate()` does not try/catch its seams; the adapter boundary (`handleAuthError`) converts what they throw. The readers deliberately keep a down issuer (`temporarily_unavailable`) distinct from an invalid token (`invalid_token`). | Trigger point | Symbol | Fires | Required | Control | On throw | | ----------------- | ----------------------------------------------- | ------------------------------------------------------ | ----------------------------- | ------- | ------------------------------------------------------------------------------------------ | | services-resolver | `resolve` (`ResourceServerOptions`) | Top of `authenticate()`, after the bearer is extracted | yes | blocks | Propagates to the handler boundary; a non-OAuth2 throw → `server_error` | | token-reader | `TokenReaderInterface` (`tokenService`) | `authenticate()` validates the access token | yes | blocks | `undefined` → `invalid_token`; a reader may throw `temporarily_unavailable`/`server_error` | | token-owner | `getClient` / `getUser` (both readers' options) | After a token validates, to resolve client/user | `getClient` yes, `getUser` no | blocks | **Not wrapped — a throw propagates out of `getToken` raw** (surfaces as `server_error`) | | reader-fetch | `fetch` (both readers' options) | Introspection POST / JWKS + discovery fetch | no | blocks | Wrapped: transport/5xx → `temporarily_unavailable`, 4xx / bad body → `server_error` | ## Identity — `@udibo/oauth2/identity` The identity protections are **opt-in**: `rateLimiter` and `lockout` do nothing unless you pass them. `passwordPolicy` is not — the policy check runs on every `signUp` and `resetPassword`, at its 8–256 character defaults when you pass nothing; the option only tightens it. A throw from a store or hook here propagates out of the `IdentityService` method you called and up to your route, unless the row says otherwise. | Trigger point | Symbol | Fires | Required | Control | On throw | | ------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | user-store | `IdentityUserStore` (`users`) | Every flow (`create` in `signUp`, `findByIdentifier`/`getCredential` in `signIn`, …) | yes | blocks | Propagates and aborts the flow. **Throwing from `create` is today's only pre-registration veto** (see Gaps). `markEmailVerified` runs _before_ the token is consumed, so a throw leaves the verification link retryable | | credential-import | `LegacyPasswordVerifier` (`legacyVerifiers`) | `signIn`, when a user has an imported hash but no native credential | no | blocks | **Fail-closed:** `canVerify`/`verify` throws are caught → verifier skipped / returns `false`; never authenticates, never aborts | | delivery | `DeliveryHooks` (`delivery`) | After a verify/reset/unlock/sign-in token or code is minted | no (omit a hook → that message disabled) | blocks (side-effect) | A throw is caught for every hook, code included: the minted token or code is invalidated (that exact one) and the flow still resolves with its uniform result — no orphaned credential, no mailer-outage enumeration oracle. Emits `delivery.failed`; `invalidated: false` means the cleanup failed too and the credential is still live | | event | `IdentityEventHook` (`onEvent`) | After every flow outcome | no | fire-and-forget | Caught and logged, never rethrown — cannot break the flow (deliberate) | | password-policy | `PasswordPolicy.validators` (`passwordPolicy`) | `signUp` and `resetPassword`, before hashing (defaults apply when unset) | no | blocks | Return an issue string to reject (→ `weak_password`); a validator that _throws_ is trapped to `weak_password` too | | rate-limiter | `RateLimiterLike` (`rateLimiter` / `rateLimiters`) | Start of each throttled flow (`check`); reset on success | no (opt-in) | blocks | A limit _hit_ under `enforce` → `rate_limited`; a limiter that _throws_ → `rate_limited` under `enforce` (fail closed), logged-and-allowed under `log-only` | | rate-limit-store | `RateLimitStore` | Backs the built-in `RateLimiter` | no | blocks | Propagates through `RateLimiter.check`/`reset` | | lockout | `AccountLockoutLike` (`lockout`) | `signIn` (`status`/`recordFailure`), reset on success/reset/unlock | no (opt-in) | blocks | Propagates and aborts the flow; enforcement itself is uniform-`null` (no lockout oracle) | | lockout-store | `LockoutStore` | Backs the built-in `AccountLockout` | no | blocks | Propagates through the `AccountLockoutLike` call sites | | token-flow-store | `TokenFlowStore` (via `tokens`) | All `request*` / `verify*` / `reset*` / `unlock*` / `consume*` flows | required for those flows | blocks | Propagates and aborts the flow | | otp-store | `OtpStore` (`otp.store`) | `requestSignInCode` / `verifySignInCode` | required for the code flow | blocks | Propagates and aborts the flow | | session-revocation | `RevocableSessionService` (`sessions`) | `resetPassword`, after the new credential is set | no | blocks (side-effect) | A throw (after the password change) emits `password_reset.failed` (`session_revocation_failed`), suppresses `password_reset.completed`, and rethrows — the reset is reported failed/retryable, not silently half-complete | | session-listing | `ListableSessionService` | App-driven (a "where you're signed in" screen) | no | n/a (app calls it) | Propagates to your caller | | captcha | `CaptchaProvider` (via `verifyCaptcha`) | App-driven, in your route before `signUp` / `signIn` / the email-sending requests | no (no provider → unchallenged pass) | blocks (you enforce it) | **Never propagates.** `verifyCaptcha` traps a provider throw and returns a decision: `failOpen: true` (default) → `"pass"`, `false` → `"fail"`, both with `degraded: true`. A `{ success: false }` return is a real rejection (`"fail"`, `degraded: false`); reject it with `IdentityError` `captcha_failed` (403) | | identifier-lookup | `IdentifierLookups` (`createIdentifierResolver`) | When you call the resolver to classify + look up an identifier | each kind optional | blocks | Propagates to your caller | | otp-deliver | `RequestOtpOptions.onDeliver` (`EmailOtpService.request`) | After the OTP hash is stored, to hand the raw code to transport | required for direct `EmailOtpService` use | blocks (side-effect) | Propagates out of `request`, after the code is stored | | breach-check | `breachedPasswordValidator` (`fetch` / `onEvent`) | The HIBP range lookup a `passwordPolicy` validator runs | both optional | blocks / fire-and-forget | `fetch` failure caught → fail-open (default) or fail-closed; `onEvent` swallowed. Safe under the no-catch validator call site | ## External providers — `@udibo/oauth2/identity/external` `ExternalAuthFlow` is deliberately storage-free and has **no** callback hooks of its own: `finish()` returns a verified `ExternalProfile` and your app does find-or-create-user, session start, and rejection _outside_ the flow, in its own `try`/`catch`. The seams below live on the provider connectors. | Trigger point | Symbol | Fires | Required | Control | On throw | | ----------------- | ------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | | external-provider | `ExternalProvider` (`provider`) | External start (`buildAuthorizationUrl`) and callback (`fetchProfile`) | yes | blocks | Propagates and aborts start/callback; built-ins throw `ExternalAuthError` | | profile-mapper | `OAuth2ProfileMapper` (`mapProfile`) | External callback, mapping the raw profile | with `oauth2Provider` | blocks | A missing/empty `subject` in the _return_ → `provider_error`; a mapper that _throws_ surfaces raw (see Findings) | | client-secret | `AppleClientSecretFactory` (`clientSecret`) | Apple callback, signing a fresh client-secret JWT | no (defaults to the built-in factory) | blocks | Not wrapped — a custom factory's throw propagates raw; the default throws `configuration` | | provider-fetch | `fetch` (every connector config) | Discovery, token exchange, UserInfo, JWKS | no (defaults to `globalThis.fetch`) | blocks | Wrapped → `provider_error`; OIDC UserInfo failure is non-fatal (swallowed → `null`) | ## MFA — `@udibo/oauth2/identity/mfa` `MfaService` decides nothing about _when_ to demand a factor — that stays your app's sign-in policy. It exposes read methods (`enrollmentStatus`, `isEnrolled`) and a `verify` you call; there is no app-supplied challenge hook to veto (see Gaps). The app implements one required interface plus the two shared optional seams. | Trigger point | Symbol | Fires | Required | Control | On throw | | ------------- | --------------------------------- | -------------------------------------------------- | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | mfa-store | `MfaStore` (`store`) | Enroll, verify, disable, regenerate | yes | blocks | Propagates; `confirmEnrollment` specially rolls back (`clearTotp`) and rethrows if recovery-code persistence fails | | rate-limiter | `RateLimiterLike` (`rateLimiter`) | Start of each `verify` (`check`); reset on success | no | blocks | A limit _hit_ under `enforce` → `rate_limited`; a limiter that _throws_ → `rate_limited` under `enforce` (fail closed), logged-and-allowed under `log-only` | | event | `IdentityEventHook` (`onEvent`) | `mfa.*` outcomes | no | fire-and-forget | Swallowed and logged | ## Hono BFF — `@udibo/oauth2/hono/bff` | Trigger point | Symbol | Fires | Required | Control | On throw | | ------------------------ | --------------------------------------------- | ------------------------------------------------- | ------------------------ | ------- | -------------------------------------------------------------------------------------------------------- | | session-store | `SessionStore` (`sessionStore`) | Callback, refresh, session read, logout | no (defaults to memory) | blocks | In `/auth/callback` → `invalid_grant`; in refresh/logout/session paths → propagates to Hono | | backchannel-logout-store | `destroyByLogout` (`SessionStore` capability) | `POST /auth/backchannel` | with `backchannelLogout` | blocks | Propagates to Hono (500) | | resolve-user | `resolveUser` (`HonoBffOptions`) | `/auth/callback`, enriching session user claims | no | blocks | **Relabeled as `invalid_grant` "code exchange failed"** even though it runs post-exchange (see Findings) | | callback-error | `onCallbackError` (`HonoBffOptions`) | `/auth/callback` cannot complete | no (default 400 JSON) | blocks | **Invoked with no guard — its own throw propagates to Hono (500)** (see Findings) | | resolve-origin | `resolveOrigin` (`HonoBffOptions`) | Login/callback/logout, to pick the trusted origin | no | blocks | Login/logout → propagates to Hono; callback → relabeled as `invalid_grant` "code exchange failed" | | auth-request-storage | `authRequestStorage` (`HonoBffOptions`) | `/auth/login` and `/auth/callback`, per request | no | blocks | Login → propagates; callback → `invalid_grant` | | logout-token-verifier | `verifyLogoutToken` (`backchannelLogout`) | `POST /auth/backchannel`, verifying the JWT | with `backchannelLogout` | blocks | Throw or `null` → `400 invalid_request` (deliberate "throw-or-null to reject" contract) | | proxy-fetch | `fetch` (`HonoBffProxyOptions`) | The upstream call in `bff.proxy` | no | blocks | Wrapped → `502` `temporarily_unavailable` | ## Hono adapters — authorization server & identity | Trigger point | Symbol | Fires | Required | Control | On throw | | ------------------- | --------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- | | authenticate | `HonoAuthenticateUserFn` (`authenticateUser`) | `GET /authorize` (Hono adapter) | yes | blocks | Passed through to the core server, which converts it (same contract as the core seam) | | consent | `HonoHandleConsentFn` (`handleConsent`) | `GET /authorize` after auth (Hono adapter) | no (omit → auto-grant) | blocks | Passed through to the core server | | post-authentication | `onAuthenticated` (`HonoIdentityOptions`) | After `signUp`/`signIn`, to mint your session | no, but **required to mount the `/signup` + `/signin` routes** | blocks | A thrown `IdentityError` → JSON error response; any other throw propagates to Hono (500) | ## Client — `@udibo/oauth2/client` | Trigger point | Symbol | Fires | Required | Control | On throw | | --------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------ | | event-listener | `OAuth2ClientEventListener` (`subscribe`) | Every client state change | no | fire-and-forget | **Swallowed** — a synchronous listener throw is isolated so other subscribers still receive the event (deliberate) | | token-storage | `TokenStorage` (`tokenStorage`) | Token read/persist/clear | no (defaults to memory) | blocks | Propagates and aborts the calling method | | refresh-token-storage | `RefreshTokenStorage` (`refreshTokenStorage`) | Refresh-token read/persist/clear | no (defaults to memory) | blocks | Propagates and aborts the calling method | | auth-request-storage | `AuthRequestStorage` (`authRequestStorage`) | `DirectClient.login` / `exchangeAuthorizationCode` | no (defaults to `SessionStorageAuthRequestStorage` in a browser document, memory elsewhere) | blocks | Propagates and aborts the calling method | | discovery-cache | `DiscoveryCache` (`discoveryCache`) | Every endpoint use of an `issuer` client whose held copy has expired. The client keeps a copy either way: until the `expiresAt` its `resolve` reports when a cache is supplied, or `DEFAULT_DISCOVERY_TTL_MS` (1 h) when one is not — so a cache is read once per entry lifetime, not once per lookup | no | blocks | Propagates to `discover()`; a rejected load is deliberately not cached | | client-fetch | `fetch` (`BaseOptions`) | Every outbound HTTP call | no (defaults to `globalThis.fetch`) | blocks | Mostly propagates; `#fetchMetadata` retries both well-known paths before rethrowing | ## React auth forms — `@udibo/oauth2/react/components` Seams on the headless `useAuthForm` hook and the prebuilt components. | Trigger point | Symbol | Fires | Required | Control | On throw | | ------------- | -------------------------------------------------------- | ------------------------------------- | -------- | --------------- | ----------------------------------------------------------------- | | form-submit | `AuthSubmitHandler` (`onSubmit`) | Form submit, after `validate` passes | yes | blocks | Caught → form-level error (`status: "error"`); does not propagate | | form-validate | `validate` (`UseAuthFormOptions`) | Synchronously at the start of submit | no | blocks | Caught → form-level error (`status: "error"`), like `onSubmit` | | form-success | `onSuccess` (`UseAuthFormOptions`) | After a submit with no errors | no | fire-and-forget | Caught → `console.error`; deliberately isolated | | social-select | `onSocialSelect` / `socialHref` (`SocialProvidersProps`) | A social button is clicked / rendered | no | blocks | Surfaces through React's event/render path | ## Known gaps These responsibilities stay in the application or use a separate API. - **No dedicated pre-registration veto.** The only way to reject a sign-up on something other than the password is to throw from `IdentityUserStore.create` (or reject the password with a `passwordPolicy` validator). There is no `beforeCreate`/allowlist/domain-restriction seam. - **No service-layer post-authentication hook.** After `signIn` verifies a credential, nothing app-supplied can inspect or veto the success (e.g. force MFA, reject a disabled user) — that gating lives in your route. - **Access-token claims use a separate API.** The authorization server's `userClaims` reaches ID tokens and UserInfo. Use `createJwtAccessTokenGenerator` and its claims options when your app issues JWT access tokens. - **No MFA-challenge seam in `IdentityService`.** `IdentityService` has zero MFA integration; MFA is the separate `@udibo/oauth2/identity/mfa` module, and _when_ to demand a factor is your app's decision. ## Failure behavior to account for - **A signing failure surfaces after the token row is persisted.** `tokenService.save` runs before `mintIdToken` / JWT-access-token generation, so a signing-key outage returns `server_error` for a token that already exists. - **Inconsistent legacy-credential contract.** A `getLegacyCredential` throw aborts sign-in, but a `clearLegacyCredential` throw is swallowed — even though the docs tell implementers to provide both together. - **BFF `resolveUser` / `resolveOrigin` throws are misclassified.** Failures in these post-exchange app hooks are relabeled `invalid_grant` "code exchange failed" because they sit inside the exchange `try`/`catch`. - **BFF `onCallbackError` is unguarded.** A throw from the error handler itself propagates to Hono (500). --- # Known Limitations Behavior to account for when choosing and deploying an integration. The API reference documents each option; this page collects cross-cutting limits and application-owned responsibilities. ## Request logging OAuth callbacks carry authorization codes and state in query parameters. Mount `requestLogger()` from `@udibo/oauth2/hono/log` before authentication routes. It replaces every query value with `[redacted]` in request and response lines; the Juniper starter and examples use it by default. For other loggers or telemetry, use `redactedRequestTarget(url)` before recording the URL. The pathname and parameter names remain visible, so do not put secrets there. The middleware does not control reverse-proxy access logs, tracing exporters, or logs your own handlers emit. Configure those separately to avoid recording credentials, request bodies, or raw callback URLs. ## Deliberate deviations (stricter than spec) - **`state` is required at the authorize endpoint.** RFC 6749 §4.1.1 lists `state` as RECOMMENDED; this server rejects authorize requests without it (`invalid_request`). This is a deliberate CSRF-hardening choice. PKCE also defaults to required (`requirePKCE: true`, enforced at both the authorize and token endpoints), matching OAuth 2.1. Keep this default for new integrations. - **Resource Owner Password grant ships but is discouraged.** It exists for migration paths; OAuth 2.1 drops it. Nothing in the adapters or examples wires it by default. - **The password grant does not enforce MFA, and cannot.** A token request has no interactive step, so `PasswordGrant` issues tokens for whatever user `UserServiceInterface.getAuthenticated` returns — including a user enrolled in a second factor. Registering the grant is therefore an opt-out of MFA unless your `getAuthenticated` enforces the policy itself: return `undefined`, or throw an OAuth2 error (`InvalidGrantError` surfaces as `invalid_grant`), for any user who owes a second factor. `@udibo/oauth2/identity/mfa` exposes `MfaService`/`MfaStore` to ask that question. ## Defaults that are looser than the specs Confidential clients authenticate alongside PKCE by default as of 0.1.0. `requireClientAuthentication: false` remains an explicit legacy opt-out and should not be enabled for new deployments. - **Both Basic and body client credentials are accepted, and the header wins silently.** `extractClientCredentials` reads the `Authorization: Basic` header first and returns as soon as it parses; `client_id` / `client_secret` in the form body are consulted only when there is no usable header. RFC 6749 §2.3.1 says a client MUST NOT use more than one authentication method and that a server receiving both SHOULD refuse the request; this server picks one instead. The consequence worth knowing is a logging one: a request carrying `Basic ` **and** `client_id=B` in the body authenticates as **A**, while an access log or audit hook that reads `client_id` off the body records **B**. Log the authenticated client the grant hands you, never the request body's `client_id`. ## Spec-level gaps - **Introspection (RFC 7662) emits a subset of the optional fields:** `active`, `client_id`, `token_type`, `scope`, `exp`, `iss`, `sub`, `username`. `sub` and `username` appear only when the token has a user; a machine token (client credentials) has neither, and its `client_id` identifies the caller — so the presence of `sub` is how a resource server tells a user token from a machine one. The typed response also declares `iat` / `nbf` / `aud` / `jti`, but the server never populates them today. Practical consequence: a resource server that enforces audience restriction via introspection `aud` cannot do so — issue JWT access tokens (`createJwtAccessTokenGenerator`) and validate them with `JwksTokenReader`, which enforces `aud`, if you need in-token audience/issued-at claims. - **Introspection authorization is application policy.** By default, `/introspect` allows any admitted client to inspect any live token, preserving the separate resource-server use of RFC 7662. Configure `AuthorizationServerOptions.canIntrospectToken` to decide which resolved tokens each authenticated client may inspect. The callback receives the client, token and actual token kind, independent of `token_type_hint`. Returning false yields only `{ active: false }`, without claims enrichment; throwing fails the request closed. Revocation always checks ownership. The disclosure surface of one successful call is `active`, `client_id`, `scope`, `exp`, `iss`, and — when the token has a resource owner — `sub` and `username`. That is enough to enumerate which user a captured token belongs to and what it may do. If your deployment has clients that should not learn that about each other, configure this policy or mount the endpoint behind a separate authorization boundary. A public client ID does not authenticate its holder, so matching a token's client ID alone does not protect public-client metadata. For confidential clients inspecting only their own tokens, require verified confidentiality as well as matching client IDs. A separately registered resource server needs an explicit policy for the tokens it may read; do not grant it every client's refresh-token metadata by accident. - **A live refresh token introspects as `active: true`.** `/introspect` resolves both token kinds, so a refresh token reports `active: true` with `exp` taken from its own expiry, and carries **no** `token_type` (an access token carries `token_type: "Bearer"`). A caller that treats any `active: true` response as "this is a valid access token" will accept a refresh token presented as a bearer — check `token_type` when the distinction matters. The shipped `IntrospectionTokenReader` does exactly that: it accepts an active response only when `token_type` names a bearer token, so an authorization server that omits `token_type` from active responses cannot be read by it. ## Resource servers (`@udibo/oauth2/server/resource`) - Offline JWT validation does not consult token revocation state. A revoked token can remain valid until its signed expiry. Use an appropriate short lifetime or online validation when prompt revocation is required. - **Clock skew is two options, and only one of them is on by default.** `JwksTokenReaderOptions.clockSkewSeconds` (default 30) governs the reader's own `exp` and `nbf` claim checks; the reader then hands `ResourceServer` a token whose `accessTokenExpiresAt` is the issuer's raw `exp`, and `ResourceServer.getToken` re-checks it with `ResourceServerOptions.clockSkewSeconds`, which defaults to **0**. Set both to tolerate drift end to end. The resource-server option is the only one that reaches an `IntrospectionTokenReader`, which has no notion of skew at all. The `nbf` half is still reader-only — nothing re-checks `nbf` — so a slow-clock issuer is tolerated only by the reader. - **`IntrospectionTokenReader` calls the authorization server on every request.** It takes `fetchTimeoutMs` (default 5000, matching `JwksTokenReader`), so an endpoint that accepts connections and then stops answering fails at the deadline rather than hanging — but there is still **no response caching**, so every authenticated request costs a round trip. The `fetch` injection seam remains the escape hatch for mTLS, retries, or a different bound; note that an injected `fetch` which ignores `AbortSignal` also ignores `fetchTimeoutMs`. Transport failures and timeouts both map to `temporarily_unavailable` rather than `invalid_token`, so they are correctly reported as "issuer is down", not "your token is bad". ## Not implemented (adjacent specs) Commonly requested capabilities that are simply not in scope yet — none are partially implemented or emulated: - Dynamic client registration (RFC 7591/7592) - Pushed Authorization Requests (PAR, RFC 9126) - JWT-secured authorization requests (JAR, RFC 9101) and JARM - Sender-constrained tokens (DPoP, RFC 9449; mTLS, RFC 8705) - Token exchange (RFC 8693) - OIDC **logout-token emission** (back-channel logout on the provider side), OIDC Session Management, and front-channel logout. RP-Initiated Logout **is** implemented on the provider side — configure the `endSession` option and the server serves `/end_session` and advertises `end_session_endpoint`; without it the endpoint 404s and stays unadvertised. The BFF also _consumes_ both RP-initiated and back-channel logout as a relying party (`rpInitiatedLogout`, `backchannelLogout`) - OIDC dynamic discovery of anything beyond the served metadata documents ## OIDC connector validation Generic `oidcProvider` and Google validate ID-token claims while trusting the direct TLS token exchange for integrity. They do not verify ID-token signatures. Apple verifies its signature separately. Do not pass out-of-band ID tokens to connectors designed for a direct authorization-code exchange. Generic OIDC requires HTTPS endpoints except loopback development URLs and refuses provider fetch redirects. A trusted provider configuration is part of that boundary. ## OIDC issuance scope - **Signing is ES256 only** (Web Crypto, zero dependencies). There is no RS256 option; verify your relying parties accept ES256 . - `id_token` claims are released per scope through the app-provided `userClaims` hook; there is no `claims` request-parameter support. - `acr` / `amr` are not asserted. ## Identity flows (`@udibo/oauth2/identity`) - Automatic password upgrades require atomic `IdentityUserStore.replaceCredential`. Stores without it skip upgrades. A failed comparison re-verifies the password against the credential now stored: a login that raced with a reset to a different password is rejected, one that raced with another correct login is not. - OTP `consume` must atomically return whether this caller consumed the record. Concurrent issuance is separate: invalidate/create are not one transaction, so serialize requests when only one code may remain outstanding. - Uniform response bodies do not by themselves make request timing uniform. Account lookup, persistence, and email delivery can still expose differences. Throttle requests and avoid synchronously waiting for network mail delivery. - The built-in fixed-window rate limiter is deliberately feature-frozen; it is a floor, not a product. Two seams sit under it and they do different jobs: **`RateLimiterLike` replaces the limiter** — implement `check` / `reset` for a sliding window, a token bucket, IP reputation, or a limiter you already run — while **`RateLimitStore` only relocates the counters** the built-in fixed window keeps. A store cannot deliver a sliding window: the window arithmetic lives in `RateLimiter.check` and the store is told nothing but `windowMs`. `AccountLockoutLike` is the matching seam for lockout policy. Both are structural, so a plain object with the right methods is assignable. - `MemoryRateLimitStore` sweeps lapsed buckets and is hard-capped at 10,000, so attacker-chosen keys can no longer grow it without bound — but a bounded store must discard something, and at the cap it evicts. It evicts the **coldest** live buckets first (fewest hits, ties broken by soonest reset), never the oldest, so a unique-key flood discards the attacker's own single-hit junk rather than the counter that is throttling them. The residual is that an attacker willing to raise ~10,000 keys above a victim's hit count can still displace that victim's counter — roughly a 10x cost increase over a naive flood, not an impossibility. Bounding memory and resisting eviction are in genuine tension in a single process; this is a dev / single-process store, and production backs the limiter with Redis or a database where neither compromise is forced. - `rateLimiter` covers every flow, keyed only by prefix, so a single threshold applies until you override the email-sending flows through `rateLimiters` — per-flow limiters are configuration, not a default. - IP-based throttling is a route-layer concern by design — the service has no request context. - The flows are single-app primitives: multi-tenancy, organizations, and SSO orchestration are intentionally out of scope for this library. - **`resetPassword` voids outstanding passwordless credentials only as far as your stores let it.** It now drops the subject's pending sign-in links (`TokenFlowStore.deleteBySubject(TokenPurpose.SignIn, userId)`) and the pending sign-in code (`OtpStore.invalidate(email, purpose)`) after the password changes. Both calls are best-effort: `deleteBySubject` is **optional** on `TokenFlowStore`, so a store that does not implement it leaves outstanding links redeemable until they expire, and a store that throws is logged rather than failing the reset — the password has already changed by that point. The OTP half also depends on the reset token carrying `data.email`, which `requestPasswordReset` sets; an app that mints reset tokens by calling `TokenFlowService.create` itself gets the link half only. Account-unlock and email-verification links are deliberately untouched — neither authenticates anyone. - **`honoIdentityRoutes`' CSRF guard trusts the browser, and only the browser.** The factory mounts a same-origin guard on unsafe methods by default: a request whose `Sec-Fetch-Site` is anything but `same-origin`/`none` is refused with `403 { "error": "forbidden_origin" }`, and for browsers that omit that header it falls back to comparing the `Origin` header's **host** (not scheme — a TLS-terminating proxy leaves the request URL on `http:`). That is sound against a cross-site page, because page JavaScript cannot forge either header. It is not a synchronizer token: a caller that speaks HTTP directly sends neither header and is allowed through by design (that is not CSRF), and an intermediary that rewrites those headers defeats it. Safe methods are left to the router so preflights still work. Use `csrf: { allowedOrigins: [...] }` for a legitimate cross-origin form, or `csrf: false` when an outer middleware already terminates CSRF. ## Sessions (`@udibo/oauth2/hono/bff`) - **`EncryptedCookieSessionStore` cannot genuinely revoke a session.** `destroy()` is a no-op — statelessness is the whole point — so sign-out clears the browser's cookie and nothing else, and a captured copy stays valid until it either expires or ages out. Revoke the refresh token at the authorization server, or use a database-backed `SessionStore` where revocation is real and immediate. Two options shrink the window without making `destroy` real: - **Secret rotation with a grace window.** `secret` accepts an ordered list: the first entry seals every new cookie and `read` tries each in turn, so rotating `SESSION_SECRET` is a `[new, old]` deploy that keeps existing sessions readable rather than a fleet-wide forced sign-out. Drop the old secret once the grace window elapses. This mirrors the multi-key JWKS grace window for signing keys. A single secret still behaves as before. - **Bounded lifetime, on by default.** `maxAgeMs` stamps each cookie with a seal time and rejects any cookie older than the cap on `read`, bounding how long a captured cookie stays useful without any server-side state. It defaults to 14 days rather than being opt-in, and there is no unbounded setting — a bearer cookie `destroy()` cannot revoke has to expire on its own. Because `update` re-seals with a fresh stamp, the cap is an inactivity window; `HonoBff`'s `sessionMaxAgeMs` (also 14 days) is the absolute one, and the constructor throws if the two are configured to disagree. Genuine revocation still requires a DB-backed store. - Back-channel logout is unavailable on any store that cannot enumerate sessions, and `HonoBff` throws at construction rather than degrading silently. - **`attachToken()` overwrites an inbound bearer; `protect()` lets it win.** The two guards are presented as alternatives, and they differ here: `attachToken()` resolves the session and `set`s `Authorization: Bearer ` unconditionally, replacing whatever the caller sent, while `protect()` checks for an inbound bearer first and validates that one directly (so one guard serves both the BFF's own frontend and machine-to-machine clients). Both behaviors are deliberate and pinned by tests. Neither is attacker-forceable from a browser: the package writes no `Access-Control-*` header anywhere in `src/`, so a cross-origin page cannot set an `Authorization` header on a credentialed request, and the CSRF header check runs before either path. The practical rule is the one the difference implies — if a mount must serve machine-to-machine callers, use `protect()`; if a mount must **only** ever act as the signed-in browser user, `attachToken()` is the one that guarantees it. - **`SessionData` and `ListableSessionService` are two layers, not a missing bridge**. `SessionData` (in `@udibo/oauth2/hono/bff`) is the BFF's _token custody_ record — the access token, refresh token, cached claims, `sid`. `ListableSessionService` (in `@udibo/oauth2/identity`) is the _app login session_ seam a "where you're signed in" screen renders and revokes against. They describe different things, so there is deliberately no automatic mapping between them. The reason this reads as a gap is that `SessionData` carries no id field — but it does not need one: every `SessionStore` method is keyed by `cookieValue` (`read`, `update`, `destroy` all take it, and `create` returns it), so an app-owned store already holds the id for each record and can project its own rows into `SessionSummary` directly. The correlating field is that internal record ID, which is safe to return as `SessionSummary.id`. Never expose `cookieValue` or its hash in a session summary: the cookie value authenticates requests. Keep a separate non-secret row identifier. When the app and the BFF should share one session record rather than keeping two, the documented bridge is `sessionMode: "shared"`, which attaches the tokens to the session your login already created. ## Runtime - **`./cli` is the only Deno-locked entrypoint.** It uses `Deno.serve`, `Deno.env`, `Deno.readTextFile` and `Deno.args`, so it runs on Deno and nowhere else. Every other subpath is Web-standard — no `node:` import and no runtime-specific global anywhere on the library path (the `Deno.env.get` you see in connector JSDoc is example prose, not code the package runs). The CLI is a development tool (`oidc keygen`, `idp dev`); nothing on the library path imports it, so its floor never constrains the rest of the package. The full matrix is in [the stability policy](https://github.com/udibo/oauth2/blob/main/docs/stability.md#runtime-support). --- # Stability & Breaking-Change Policy How `@udibo/oauth2` versions, what counts as breaking, and what users can rely on. Use this policy to decide when to upgrade and which migration notes to review. ## Versioning Releases follow [semantic versioning](https://semver.org/), cut automatically by semantic-release from Conventional Commits (see [CONTRIBUTING.md](https://github.com/udibo/oauth2/blob/main/CONTRIBUTING.md)). Nobody hand-picks version numbers. ### While 0.x - A **minor** (`0.X.0`) may contain breaking changes. Every breaking change is marked `feat!:`/`fix!:` in the commit, called out in the changelog with a migration note, and never lands silently. - A **patch** (`0.x.Y`) preserves the public API, subject to the security-fix policy below. - This is enforced, not just described: `.releaserc.json` maps `{ "breaking": true, "release": "minor" }`, so a `feat!:` commit cuts a minor rather than a major, and a packaging test pins that mapping. Do not "correct" it to `major` — moving to 1.0 is a deliberate decision, not a side effect of a breaking commit. ### From 1.0 - Breaking changes ship only in **majors**, batched rather than dribbled. - Anything removed in a major must have been deprecated (documented + JSDoc `@deprecated` with the replacement named) for at least one minor beforehand. - Minors and patches are additive or corrective only. ## Runtime support Deno runs the complete test suite. CI also builds an npm-format artifact and smoke-tests 20 entrypoints with TypeScript and Node. The generic testing exports (`/testing`, `/testing/contract`) rely on Deno's test runner and are not verified on Node. `/cli` uses Deno runtime APIs and is Deno-only. Client and React exports are intended for browser use. Server exports belong on the backend, where client secrets and token storage can remain private. Bun has not been verified. See the [runtime table](https://github.com/udibo/oauth2/blob/main/README.md#runtime-support) for the entrypoint groups and the difference between full tests and import/type checks. The legacy-password symbols live in `/identity/migration`; `/identity` does not re-export them. Narrowing a documented runtime target or removing an exported subpath is a compatibility change. ## What is public API - Every subpath export listed in `src/deno.json` `exports`, and every symbol those modules export, including their documented types. - The **wire behavior** of the servers and adapters: endpoint request/response shapes, error codes, and challenge headers are API — a change that breaks a conforming OAuth2/OIDC client is a breaking change even if no TypeScript signature moved. - The documented storage/service seams (`ClientServiceInterface`, `TokenServiceInterface`, `IdentityUserStore`, `RateLimitStore`, …): **adding a required member is breaking**; adding an optional member is not. Not public API: anything under `src/` not reachable from an export, test helpers' internals, and the example apps. ## Security exceptions A security fix may tighten behavior in a patch (e.g. stricter validation of a malformed input) when the previous behavior was a vulnerability. If a security fix must break a documented API, it ships as the smallest honest semver bump with a prominent changelog notice — we do not sit on a vulnerability to wait for a major. ## What we promise not to do - Remove or paywall a shipped capability (MFA and core auth stay free — standing product commitment). - Rename exports without a deprecation window (post-1.0). - Change defaults to something less secure. Defaults only ever tighten, and a tightening default is documented as breaking. --- # Issue Triage Process How incoming issues are handled once the package has a public tracker. Until then the same process runs on the internal tracker, so opening up is a switch flip, not a scramble. ## Intake Every new issue gets, within **3 business days**: 1. A **type** label: `bug`, `security` (see below), `feature`, `docs`, `question`. 2. A **module** label when clear: `server`, `client`, `identity`, `bff`, `react`, `examples`. 3. A first response — even if only "reproduced, looking into it" or a request for a minimal reproduction. **Security reports opened publicly** are acknowledged, minimally scrubbed (details edited out if actively dangerous), and redirected to the [SECURITY.md](https://github.com/udibo/oauth2/blob/main/SECURITY.md) channel. The reporter is thanked, not scolded. ## Priority | Label | Meaning | Target | | ----- | ------------------------------------------------------------------ | ------------------------ | | `p0` | Security vulnerability or data-loss/auth-bypass bug | fix ASAP, patch release | | `p1` | Broken documented behavior with no workaround | next release | | `p2` | Broken documented behavior with a workaround; significant papercut | scheduled | | `p3` | Nice-to-have, cosmetic, or speculative | backlog, may be declined | ## Bug bar - A deviation from a documented behavior or an RFC MUST is a bug. - A deviation listed in [known-limitations.md](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md) is not a bug report — it converts to a feature request against that entry. - "The safe path was unclear and I misconfigured it" is a **docs bug** and is taken seriously; unclear docs around security controls get `p1`. ## Feature requests Measured against the library's positioning: it serves apps that consume an IdP or own their single-app auth. Multi-tenancy, org SSO orchestration, plugin frameworks, and hosted-service concerns are out of scope by design and are declined with a pointer to that rationale — kindly, and only after understanding the underlying need (the need may have an in-scope answer). ## Staleness - `needs-repro` issues with no response for 30 days close with an invitation to reopen. - Declined features close with the reasoning stated; "no" is said explicitly rather than by silence. --- # Security Policy `@udibo/oauth2` is security-critical software: it implements the OAuth 2.0 / OpenID Connect protocol surface and password-based identity flows. We take reports seriously and value the time of security researchers. > **Status note:** the package is not yet published to a public registry. Until > it is, the disclosure channel below is drafted and monitored, but the package > has no external users; the process activates fully at publish. ## Supported versions | Version | Supported | | -------------- | -------------------------------- | | latest `0.x` | ✅ security fixes | | older releases | ❌ upgrade to the latest release | Pre-1.0, security fixes land on the latest release only. From 1.0 on, the latest minor of the current major receives fixes, and the final minor of the previous major receives critical fixes for 6 months after a new major ships. ## Reporting a vulnerability **Do not open a public issue for a security vulnerability.** Email **security@udibo.com** with: - A description of the vulnerability and the affected module/subpath (e.g. `@udibo/oauth2/server`, `@udibo/oauth2/identity`). - Reproduction steps or a proof of concept. - The impact you believe it has (what an attacker gains). - Any suggested remediation, if you have one. You will receive an acknowledgment within **72 hours** and a substantive assessment within **7 days**. We ask that you give us **90 days** to remediate before public disclosure; we will credit you in the release notes unless you prefer otherwise. We do not operate a paid bounty program. ## Scope In scope: - Protocol vulnerabilities (token leakage, redirect validation, PKCE bypass, replay, open redirects, header trust). - Identity-flow vulnerabilities (enumeration oracles, timing oracles, throttle or lockout bypass, token-flow weaknesses). - Cryptographic mistakes (signing, comparison, randomness). Out of scope: - Vulnerabilities in example apps' third-party dependencies (report upstream). - Misconfiguration of an app that consumes the library contrary to documented guidance (though we welcome reports that the safe path is unclear — unclear docs around a security control are treated as a docs bug with priority). ## Design commitments - **The protocol defaults are the strict ones.** PKCE is required at both the authorize and token endpoints (`requirePKCE: true`), `state` is required, identity flows are enumeration-safe and timing-equalized, and the built-in password policy runs whether or not you configure one. Relaxing any of these is an explicit option you pass. - **The identity protections are opt-in objects, not defaults.** Rate limiting and account lockout do **nothing** unless you construct and pass them: `IdentityService` takes `rateLimiter` (and optional per-flow `rateLimiters`), `lockout`, and — at the route layer — a `CaptchaProvider` you verify through `verifyCaptcha`. An `IdentityService` built without them has no throttling and no lockout. This is the single most consequential default in the identity layer; treat wiring `rateLimiter` and `lockout` as part of deploying, not as hardening you get to later. See [Rate limiting, lockout, and password policy](https://github.com/udibo/oauth2/blob/main/docs/guides/production-deployment.md#rate-limiting-lockout-and-password-policy) and the [hardening checklist](https://github.com/udibo/oauth2/blob/main/docs/guides/hardening-checklist.md). - **Honest gaps:** deviations from the specs, and defaults that trade safety for compatibility, are documented in [docs/known-limitations.md](https://github.com/udibo/oauth2/blob/main/docs/known-limitations.md), not hidden. - **No silent fixes:** security-relevant fixes are called out in the changelog and release notes.