diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index f4c1f277..da8944eb 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -349,6 +349,24 @@ export interface IssuerInput< */ retention?: number } + /** + * Control when provider-side persistence should be committed. + * + * - `immediate`: commit during provider success callback. + * - `lazy`: defer commit until authorization code is exchanged. + * + * In `lazy` mode, providers should pass commit payload using + * `ctx.success(..., { commit })` and implement `provider.finalize()`. + * + * This only affects provider-side persistence timing, not token lifetime. + * + * Docs: /docs/concepts/lazy-registration + * + * @default "immediate" + */ + persistence?: { + registration?: "immediate" | "lazy" + } /** * Optionally, configure the UI that's displayed when the user visits the root URL of the * of the OpenAuth server. @@ -466,6 +484,8 @@ export function issuer< const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365 const ttlRefreshReuse = input.ttl?.reuse ?? 60 const ttlRefreshRetention = input.ttl?.retention ?? 0 + const registrationPersistence = + input.persistence?.registration ?? "immediate" if (input.theme) { setTheme(input.theme) } @@ -517,13 +537,32 @@ export function issuer< { async subject(type, properties, subjectOpts) { const authorization = await getAuthorization(ctx) + const providerName = ctx.get("provider") as string + const provider = input.providers[providerName] const subject = subjectOpts?.subject ? subjectOpts.subject : await resolveSubject(type, properties) await successOpts?.invalidate?.( await resolveSubject(type, properties), ) + const commit = successOpts?.commit + async function commitProvider() { + if (!commit) return + if (!provider?.finalize) { + throw new Error( + `Provider ${providerName} does not support lazy registration`, + ) + } + await provider.finalize({ + storage: storage!, + subject, + type: type as string, + properties, + data: commit, + }) + } if (authorization.response_type === "token") { + await commitProvider() const location = new URL(authorization.redirect_uri) const tokens = await generateTokens(ctx, { subject, @@ -545,10 +584,14 @@ export function issuer< } if (authorization.response_type === "code") { const code = crypto.randomUUID() + if (registrationPersistence === "immediate") { + await commitProvider() + } await Storage.set( storage, ["oauth:code", code], { + provider: providerName, type, properties, subject, @@ -559,6 +602,10 @@ export function issuer< access: subjectOpts?.ttl?.access ?? ttlAccess, refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh, }, + commit: + registrationPersistence === "lazy" + ? commit + : undefined, }, 60, ) @@ -810,6 +857,7 @@ export function issuer< ) const key = ["oauth:code", code.toString()] const payload = await Storage.get<{ + provider?: string type: string properties: any clientID: string @@ -820,6 +868,7 @@ export function issuer< refresh: number } pkce?: AuthorizationState["pkce"] + commit?: any }>(storage, key) if (!payload) { return c.json( @@ -877,6 +926,38 @@ export function issuer< ) } } + if (payload.commit !== undefined) { + const provider = payload.provider + ? input.providers[payload.provider] + : undefined + if (!provider?.finalize) { + return c.json( + { + error: "server_error", + error_description: + "Provider does not support lazy registration", + }, + 500, + ) + } + try { + await provider.finalize({ + storage, + subject: payload.subject, + type: payload.type, + properties: payload.properties, + data: payload.commit, + }) + } catch { + return c.json( + { + error: "server_error", + error_description: "Failed to commit registration", + }, + 500, + ) + } + } const tokens = await generateTokens(c, payload) await Storage.remove(storage, key) return c.json({ diff --git a/packages/openauth/src/provider/password.ts b/packages/openauth/src/provider/password.ts index 850af8a3..38dedbbf 100644 --- a/packages/openauth/src/provider/password.ts +++ b/packages/openauth/src/provider/password.ts @@ -273,6 +273,15 @@ export function PasswordProvider( } return { type: "password", + async finalize(input) { + if (input.data?.kind !== "password-register") return + const email = input.data.email?.toString()?.toLowerCase() + const password = input.data.password + if (!email || !password) return + const existing = await Storage.get(input.storage, ["email", email, "password"]) + if (existing) return + await Storage.set(input.storage, ["email", email, "password"], password) + }, init(routes, ctx) { routes.get("/authorize", async (c) => ctx.forward(c, await config.login(c.req.raw)), @@ -408,14 +417,19 @@ export function PasswordProvider( ]) if (existing) return transition({ type: "start" }, { type: "email_taken" }) - await Storage.set( - ctx.storage, - ["email", provider.email, "password"], - provider.password, + return ctx.success( + c, + { + email: provider.email, + }, + { + commit: { + kind: "password-register", + email: provider.email, + password: provider.password, + }, + }, ) - return ctx.success(c, { - email: provider.email, - }) } return transition({ type: "start" }) diff --git a/packages/openauth/src/provider/provider.ts b/packages/openauth/src/provider/provider.ts index edbf6933..a88d5dbe 100644 --- a/packages/openauth/src/provider/provider.ts +++ b/packages/openauth/src/provider/provider.ts @@ -3,9 +3,27 @@ import { StorageAdapter } from "../storage/storage.js" export type ProviderRoute = Hono +/** + * Payload passed to `provider.finalize()` when lazy registration is enabled. + */ +export interface ProviderFinalizeInput { + storage: StorageAdapter + subject: string + type: string + properties: any + data: any +} + export interface Provider { type: string init: (route: ProviderRoute, options: ProviderOptions) => void + /** + * Finalize provider-side persistence at token exchange time. + * + * Called when `IssuerInput.persistence.registration = "lazy"` and the + * provider supplied commit payload during `success()`. + */ + finalize?: (input: ProviderFinalizeInput) => Promise client?: (input: { clientID: string clientSecret: string @@ -20,6 +38,12 @@ export interface ProviderOptions { properties: Properties, opts?: { invalidate?: (subject: string) => Promise + /** + * Arbitrary provider payload for lazy persistence. + * + * Saved into authorization-code state and passed to `finalize()`. + */ + commit?: any }, ) => Promise forward: (ctx: Context, response: Response) => Response diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index be303d77..65528dbf 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -50,7 +50,7 @@ const issuerConfig = { }, } satisfies Provider<{ email: string }>, }, - success: async (ctx, value) => { + success: async (ctx: any, value: any) => { if (value.provider === "dummy") { return ctx.subject("user", { userID: "123", @@ -119,6 +119,281 @@ describe("code flow", () => { }) }) +describe("lazy registration", () => { + test("commits only on token exchange", async () => { + const lazyStorage = MemoryStorage() + const lazyIssuer = issuer({ + ...issuerConfig, + storage: lazyStorage, + persistence: { + registration: "lazy", + }, + providers: { + lazy: { + type: "lazy", + async finalize(input) { + if (input.data?.kind !== "user-create") return + const existing = await input.storage.get(["user", input.data.id]) + if (existing) return + await input.storage.set(["user", input.data.id], { + created: true, + }) + }, + init(route, ctx) { + route.get("/authorize", async (c) => { + return ctx.success( + c, + { + email: "foo@bar.com", + }, + { + commit: { + kind: "user-create", + id: "abc", + }, + }, + ) + }) + }, + } satisfies Provider<{ email: string }>, + }, + success: async (ctx, value) => { + if (value.provider === "lazy") { + return ctx.subject("user", { + userID: "abc", + }) + } + throw new Error("Invalid provider: " + value.provider) + }, + }) + + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(lazyIssuer.request(a, b)), + }) + + const { challenge, url } = await client.authorize( + "https://client.example.com/callback", + "code", + { + provider: "lazy", + pkce: true, + }, + ) + + let response = await lazyIssuer.request(url) + response = await lazyIssuer.request(response.headers.get("location")!, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code") + expect(code).not.toBeNull() + + expect(await lazyStorage.get(["user", "abc"])).toBeUndefined() + + const exchanged = await client.exchange( + code!, + "https://client.example.com/callback", + challenge.verifier, + ) + if (exchanged.err) throw exchanged.err + + expect(await lazyStorage.get(["user", "abc"])).toEqual({ + created: true, + }) + }) + + test("returns server_error when finalize fails", async () => { + const lazyIssuer = issuer({ + ...issuerConfig, + storage: MemoryStorage(), + persistence: { + registration: "lazy", + }, + providers: { + lazy: { + type: "lazy", + async finalize() { + throw new Error("db unavailable") + }, + init(route, ctx) { + route.get("/authorize", async (c) => { + return ctx.success( + c, + { + email: "foo@bar.com", + }, + { + commit: { + kind: "user-create", + id: "abc", + }, + }, + ) + }) + }, + } satisfies Provider<{ email: string }>, + }, + success: async (ctx, value) => { + if (value.provider === "lazy") { + return ctx.subject("user", { + userID: "abc", + }) + } + throw new Error("Invalid provider: " + value.provider) + }, + }) + + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(lazyIssuer.request(a, b)), + }) + + const { url } = await client.authorize( + "https://client.example.com/callback", + "code", + { + provider: "lazy", + }, + ) + + let response = await lazyIssuer.request(url) + response = await lazyIssuer.request(response.headers.get("location")!, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + + response = await lazyIssuer.request("https://auth.example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: "123", + redirect_uri: "https://client.example.com/callback", + }).toString(), + }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: "server_error", + error_description: "Failed to commit registration", + }) + }) + + test("retry succeeds after transient finalize failure", async () => { + const lazyStorage = MemoryStorage() + let attempts = 0 + const lazyIssuer = issuer({ + ...issuerConfig, + storage: lazyStorage, + persistence: { + registration: "lazy", + }, + providers: { + lazy: { + type: "lazy", + async finalize(input) { + attempts += 1 + if (attempts === 1) { + throw new Error("temporary outage") + } + const existing = await input.storage.get(["user", "abc"]) + if (existing) return + await input.storage.set(["user", "abc"], { created: true }) + }, + init(route, ctx) { + route.get("/authorize", async (c) => { + return ctx.success( + c, + { + email: "foo@bar.com", + }, + { + commit: { + kind: "user-create", + id: "abc", + }, + }, + ) + }) + }, + } satisfies Provider<{ email: string }>, + }, + success: async (ctx, value) => { + if (value.provider === "lazy") { + return ctx.subject("user", { + userID: "abc", + }) + } + throw new Error("Invalid provider: " + value.provider) + }, + }) + + const client = createClient({ + issuer: "https://auth.example.com", + clientID: "123", + fetch: (a, b) => Promise.resolve(lazyIssuer.request(a, b)), + }) + + const { url } = await client.authorize( + "https://client.example.com/callback", + "code", + { + provider: "lazy", + }, + ) + + let response = await lazyIssuer.request(url) + response = await lazyIssuer.request(response.headers.get("location")!, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code")! + + const tokenRequest = () => + lazyIssuer.request("https://auth.example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: "123", + redirect_uri: "https://client.example.com/callback", + }).toString(), + }) + + response = await tokenRequest() + expect(response.status).toBe(500) + + response = await tokenRequest() + expect(response.status).toBe(200) + const json = await response.json() + expect(json.access_token).toEqual(expectNonEmptyString) + expect(json.refresh_token).toEqual(expectNonEmptyString) + expect(await lazyStorage.get(["user", "abc"])).toEqual({ + created: true, + }) + expect(attempts).toBe(2) + }) +}) + describe("client credentials flow", () => { test("success", async () => { const client = createClient({ diff --git a/www/astro.config.mjs b/www/astro.config.mjs index d5155da6..f386bdea 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -3,6 +3,7 @@ import starlight from "@astrojs/starlight" import { defineConfig } from "astro/config" import { rehypeHeadingIds } from "@astrojs/markdown-remark" import rehypeAutolinkHeadings from "rehype-autolink-headings" +import { mermaid } from "./src/plugins/mermaid" import config from "./config" const url = "https://openauth.js.org" @@ -59,6 +60,16 @@ export default defineConfig({ content: `${url}/social-share.png`, }, }, + { + tag: "script", + attrs: { + type: "module", + }, + content: ` + import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'; + mermaid.initialize({ startOnLoad: true, securityLevel: 'loose' }); + `, + }, ], logo: { light: "./src/assets/logo-light.svg", @@ -93,6 +104,10 @@ export default defineConfig({ label: "Core", items: ["docs/client", "docs/issuer", "docs/subject"], }, + { + label: "Concepts", + items: ["docs/concepts/lazy-registration"], + }, { label: "Providers", items: [ @@ -128,6 +143,7 @@ export default defineConfig({ }), ], markdown: { + remarkPlugins: [mermaid], rehypePlugins: [ rehypeHeadingIds, [rehypeAutolinkHeadings, { behavior: "wrap" }], diff --git a/www/src/content/docs/docs/concepts/lazy-registration.mdx b/www/src/content/docs/docs/concepts/lazy-registration.mdx new file mode 100644 index 00000000..6cbfcdea --- /dev/null +++ b/www/src/content/docs/docs/concepts/lazy-registration.mdx @@ -0,0 +1,136 @@ +--- +title: Lazy Registration +description: Technical guide for immediate vs lazy registration persistence in PasswordProvider and OAuth code flow. +--- + +# Lazy registration in OpenAuth + +OpenAuth supports two persistence modes for provider-side registration writes: + +- `immediate` (default): provider writes happen as soon as the provider flow succeeds. +- `lazy`: provider writes are deferred until the authorization code is exchanged at `/token`. + +Use lazy mode when you want to avoid creating partial accounts for users who never finish the OAuth code exchange. + +## Why this exists + +In code flow, provider success happens before the client exchanges the authorization code. If registration data is persisted too early, you can end up with records for users who abandoned the flow before token exchange. + +Lazy registration moves that persistence to the token exchange boundary. + +## Flow comparison + +| Step | `immediate` | `lazy` | +| --- | --- | --- | +| Provider success callback | Commits registration data now | Stores commit payload in authorization code state | +| Redirect with authorization code | Happens after commit | Happens before commit | +| `/token` code exchange | Issues tokens | First runs provider finalize, then issues tokens | +| User abandons before `/token` | Registration may already exist | No registration is committed | + +## Sequence diagrams + +Authorize phase: + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant C as Client App + participant I as OpenAuth Issuer + participant P as PasswordProvider + participant S as Storage + + U->>C: Start sign-in + C->>I: /authorize + I->>P: Run provider flow + P-->>I: success + commit + I->>S: Store code state + commit + I-->>C: Redirect with code +``` + +Token exchange phase: + +```mermaid +sequenceDiagram + autonumber + participant C as Client App + participant I as OpenAuth Issuer + participant P as PasswordProvider + participant S as Storage + + C->>I: POST /token + code + I->>P: finalize(commit) + P->>S: Idempotent write + I->>S: Consume code + I-->>C: access + refresh tokens +``` + +## Configure lazy mode + +```ts +import { issuer } from "@openauthjs/openauth" + +const app = issuer({ + // ... + persistence: { + registration: "lazy", + }, +}) +``` + +`immediate` remains the default when `persistence.registration` is not set. + +## Provider integration contract + +When a provider wants lazy persistence, it should: + +1. Return `commit` data from `ctx.success(..., { commit })` +2. Implement `finalize(input)` to commit that data later +3. Make `finalize` idempotent (safe on retries) + +Example pattern: + +```ts +ctx.success(c, { email }, { + commit: { + kind: "password-register", + email, + password, + }, +}) + +finalize: async (input) => { + if (input.data?.kind !== "password-register") return + const existing = await input.storage.get(["email", input.data.email, "password"]) + if (existing) return + await input.storage.set(["email", input.data.email, "password"], input.data.password) +} +``` + +## Storage behavior in code flow + +During `/authorize` success in lazy mode, OpenAuth stores the authorization code payload with: + +- provider name +- provider properties +- resolved subject +- optional PKCE data +- lazy `commit` payload + +At `/token`: + +1. OpenAuth validates the code and client binding. +2. If commit payload exists, OpenAuth calls `provider.finalize(...)`. +3. If finalize succeeds, tokens are generated and the code is removed. +4. If finalize fails, OpenAuth returns `server_error` and keeps the code for retry. + +## Error and retry semantics + +- Missing `finalize` for a lazy commit results in `server_error`. +- Errors inside `finalize` return `server_error`. +- Since code is not consumed on finalize failure, the client can retry `/token`. +- `finalize` must be idempotent because retries may run the same commit more than once. + +## PasswordProvider specifics + +`PasswordProvider` uses this pattern for registration so user credentials are only persisted after successful token exchange in lazy mode. diff --git a/www/src/content/docs/docs/provider/password.mdx b/www/src/content/docs/docs/provider/password.mdx index 16fcd346..fc98352f 100644 --- a/www/src/content/docs/docs/provider/password.mdx +++ b/www/src/content/docs/docs/provider/password.mdx @@ -44,6 +44,9 @@ PasswordProvider({ ``` This allows you to create your own UI for each of these screens. + +For a technical deep dive on lazy persistence during code flow, see +[/docs/concepts/lazy-registration](/docs/concepts/lazy-registration). --- ## Methods diff --git a/www/src/custom.css b/www/src/custom.css index e69de29b..98cbe967 100644 --- a/www/src/custom.css +++ b/www/src/custom.css @@ -0,0 +1,44 @@ +.sl-markdown-content .mermaid { + margin: 1.25rem 0; + overflow-x: auto; +} + +.sl-markdown-content .mermaid svg { + min-width: 820px; + height: auto; +} + +.sl-markdown-content .mermaid text { + font-size: 15px; +} + +/* Improve Mermaid readability in dark theme */ +:root[data-theme="dark"] .sl-markdown-content .mermaid text, +:root[data-theme="dark"] .sl-markdown-content .mermaid tspan, +:root[data-theme="dark"] .sl-markdown-content .mermaid .messageText, +:root[data-theme="dark"] .sl-markdown-content .mermaid .labelText, +:root[data-theme="dark"] .sl-markdown-content .mermaid .loopText, +:root[data-theme="dark"] .sl-markdown-content .mermaid .actor, +:root[data-theme="dark"] .sl-markdown-content .mermaid .noteText { + fill: #f8fafc !important; + color: #f8fafc !important; + stroke: none !important; +} + +:root[data-theme="dark"] .sl-markdown-content .mermaid .messageLine0, +:root[data-theme="dark"] .sl-markdown-content .mermaid .messageLine1, +:root[data-theme="dark"] .sl-markdown-content .mermaid .actor-line, +:root[data-theme="dark"] .sl-markdown-content .mermaid .loopLine, +:root[data-theme="dark"] .sl-markdown-content .mermaid .innerArc, +:root[data-theme="dark"] .sl-markdown-content .mermaid .marker, +:root[data-theme="dark"] .sl-markdown-content .mermaid #arrowhead path { + stroke: #cbd5e1 !important; + fill: #cbd5e1 !important; +} + +:root[data-theme="dark"] .sl-markdown-content .mermaid .actor, +:root[data-theme="dark"] .sl-markdown-content .mermaid .labelBox, +:root[data-theme="dark"] .sl-markdown-content .mermaid g rect.rect { + fill: #111827 !important; + stroke: #94a3b8 !important; +} diff --git a/www/src/plugins/mermaid.ts b/www/src/plugins/mermaid.ts new file mode 100644 index 00000000..87bd8aee --- /dev/null +++ b/www/src/plugins/mermaid.ts @@ -0,0 +1,26 @@ +function escapeHtml(input: string) { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") +} + +export function mermaid() { + return (tree: any) => { + const walk = (node: any) => { + if (!node?.children || !Array.isArray(node.children)) return + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + if (child?.type === "code" && child?.lang === "mermaid") { + node.children[i] = { + type: "html", + value: `
${escapeHtml(child.value ?? "")}
`, + } + continue + } + walk(child) + } + } + walk(tree) + } +}