/** * Configures a provider that supports pin code authentication. This is usually paired with the * `CodeUI`. * * ```ts * import { CodeUI } from "@aryalabs/openauth/ui/code" * import { CodeProvider } from "@aryalabs/openauth/provider/code" * * export default issuer({ * providers: { * code: CodeProvider( * CodeUI({ * copy: { * code_info: "We'll send a pin code to your email" * }, * sendCode: (claims, code) => console.log(claims.email, code) * }) * ) * }, * // ... * }) * ``` * * You can customize the provider using. * * ```ts {7-9} * const ui = CodeUI({ * // ... * }) * * export default issuer({ * providers: { * code: CodeProvider( * { ...ui, length: 4 } * ) * }, * // ... * }) * ``` * * Behind the scenes, the `CodeProvider` expects callbacks that implements request handlers * that generate the UI for the following. * * ```ts * CodeProvider({ * // ... * request: (req, state, form, error) => Promise * }) * ``` * * This allows you to create your own UI. * * @packageDocumentation */ import type { Context } from "hono"; import { generateUnbiasedDigits, timingSafeCompare } from "../random.js"; import type { Provider } from "./provider.js"; export interface CodeProviderConfig = Record> { /** * The length of the pin code. * * @default 6 */ length?: number; /** * The request handler to generate the UI for the code flow. * * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) * ojects. * * Also passes in the current `state` of the flow and any `error` that occurred. * * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object * in return. */ request: (req: Request, state: CodeProviderState, form?: FormData, error?: CodeProviderError) => Promise; /** * Callback to send the pin code to the user. * * @example * ```ts * { * sendCode: async (claims, code) => { * // Send the code through the email or phone number based on the claims * } * } * ``` */ sendCode: (claims: Claims, code: string) => Promise; } /** * The state of the code flow. * * | State | Description | * | ----- | ----------- | * | `start` | The user is asked to enter their email address or phone number to start the flow. | * | `code` | The user needs to enter the pin code to verify their _claim_. | */ export type CodeProviderState = | { type: "start"; } | { type: "code"; resend?: boolean; code: string; claims: Record; }; /** * The errors that can happen on the code flow. * * | Error | Description | * | ----- | ----------- | * | `invalid_code` | The code is invalid. | * | `invalid_claim` | The _claim_, email or phone number, is invalid. | */ export type CodeProviderError = | { type: "invalid_code"; } | { type: "invalid_claim"; key: string; value: string; }; export function CodeProvider = Record>( config: CodeProviderConfig, ): Provider<{ claims: Claims }> { const length = config.length || 6; function generate() { return generateUnbiasedDigits(length); } return { type: "code", init(routes, ctx) { async function transition(c: Context, next: CodeProviderState, fd?: FormData, err?: CodeProviderError) { await ctx.set(c, "provider", 60 * 60 * 24, next); const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err)); return resp; } routes.get("/authorize", async (c) => { const resp = await transition(c, { type: "start", }); return resp; }); routes.post("/authorize", async (c) => { const code = generate(); const fd = await c.req.formData(); const state = await ctx.get(c, "provider"); const action = fd.get("action")?.toString(); if (action === "request" || action === "resend") { const claims = Object.fromEntries(fd) as Claims; // @ts-expect-error TODO: remove or fix type claims.action = undefined; const err = await config.sendCode(claims, code); if (err) return transition(c, { type: "start" }, fd, err); return transition( c, { type: "code", resend: action === "resend", claims, code, }, fd, ); } if (fd.get("action")?.toString() === "verify" && state.type === "code") { const fd = await c.req.formData(); const compare = fd.get("code")?.toString(); if (!state.code || !compare || !timingSafeCompare(state.code, compare)) { return transition( c, { ...state, resend: false, }, fd, { type: "invalid_code" }, ); } await ctx.unset(c, "provider"); return ctx.forward(c, await ctx.success(c, { claims: state.claims as Claims })); } }); }, }; } /** * @internal */ export type CodeProviderOptions = Parameters[0];