# @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.