accounts
Version:
Tempo Accounts SDK
248 lines (224 loc) • 9.5 kB
text/typescript
import { sign } from 'hono/jwt'
import * as z from 'zod/mini'
import { type Handler, from } from '../../Handler.js'
import * as Hono from '../hono.js'
const defaults = {
claimsSupported: ['iss', 'aud', 'sub', 'iat', 'exp', 'nonce'],
kid: 'oidc-1',
path: '/',
ttl: 5 * 60, // 5 minutes
} as const
/** Zod schemas for the OIDC provider's request and response payloads. */
export namespace schema {
/** Schemas for `POST {path}/token`. */
export namespace token {
/** Request body schema. */
export const parameters = z.object({
/** Audience the token is minted for — the requesting app's origin. */
audience: z.string(),
/** One-time value bound into the token (OIDC `nonce`). */
nonce: z.optional(z.string()),
/**
* Subject the token is minted for. Ignored when an `authenticate`
* callback is configured (the resolved subject wins); required
* otherwise.
*/
subject: z.optional(z.string()),
})
/** Response body schema. */
export const returns = z.object({
idToken: z.string(),
})
}
}
/**
* OpenID Connect provider (issuer) request handler. Mounts three routes
* under `path`:
*
* - `POST {path}/token` → mints + signs an EdDSA id_token, returns `{ idToken }`
* - `GET {path}/.well-known/openid-configuration` → OIDC discovery document
* - `GET {path}/.well-known/jwks.json` → public signing keys (JWKS)
*
* The handler owns the OIDC protocol mechanics (claim set, EdDSA signing,
* discovery + JWKS shape); a deployment supplies its key material and the
* claim source via callbacks, so nothing here is deployment-specific:
*
* - `authenticate(request)` resolves the authenticated subject (e.g. mapping
* a session cookie to an account address). When omitted, the request body's
* `subject` is trusted instead — only safe behind an authenticating gateway.
* - `getClaims({ subject, audience, nonce, request })` returns the claim set
* embedded into the token (e.g. `{ email, email_verified: true }`). Throwing
* rejects issuance (e.g. no verified email) with `400`.
*
* Tokens carry `iss`, `aud`, `sub`, `iat`, `exp`, an optional `nonce`, and any
* claims returned by `getClaims`. They are signed EdDSA (Ed25519) with the
* provided `signingKey`; the public counterpart is served at the JWKS route so
* relying parties can verify them.
*/
export function oidcProvider(options: oidcProvider.Options): oidcProvider.ReturnType {
const {
authenticate,
claimsSupported = defaults.claimsSupported,
getClaims,
issuer,
jwksUri,
kid = defaults.kid,
path = defaults.path,
publicKey,
signingKey,
ttl = defaults.ttl,
...rest
} = options
const key = parseJwk(signingKey)
const jwk = { ...parseJwk(publicKey), kid, use: 'sig', alg: 'EdDSA' }
const tokenPath = path === '/' ? '/token' : `${path}/token`
const discoveryPath =
path === '/' ? '/.well-known/openid-configuration' : `${path}/.well-known/openid-configuration`
const jwksPath = path === '/' ? '/.well-known/jwks.json' : `${path}/.well-known/jwks.json`
// The advertised `jwks_uri` is issuer-relative (OIDC serves discovery at
// `{issuer}/.well-known/openid-configuration`), not origin + routing path —
// those differ when `issuer` is path-based (e.g. `{origin}/api/oidc`).
const resolvedJwksUri = jwksUri ?? `${trimTrailingSlash(issuer)}/.well-known/jwks.json`
const router = from(rest)
router.post(tokenPath, Hono.validate('json', schema.token.parameters), async (c) => {
const { audience, nonce, subject: subject_body } = c.req.valid('json')
// Resolve the subject the token is minted for. An `authenticate` callback
// (e.g. session cookie → address) always wins so the server never trusts a
// client-supplied subject when it can prove one itself.
const subject = await (async () => {
try {
const resolved = authenticate ? await authenticate(c.req.raw) : (subject_body ?? '')
if (!resolved) return c.json({ error: 'missing subject' }, 400)
return resolved
} catch (error) {
return c.json({ error: error instanceof Error ? error.message : 'unauthenticated' }, 401)
}
})()
if (subject instanceof Response) return subject
// Resolve the claim set (e.g. verified email). Throwing here rejects
// issuance — the deployment owns the policy (e.g. `email_verified`).
const claims = await (async () => {
try {
return await getClaims({ audience, nonce, request: c.req.raw, subject })
} catch (error) {
return c.json({ error: error instanceof Error ? error.message : 'claims unavailable' }, 400)
}
})()
if (claims instanceof Response) return claims
const now = Math.floor(Date.now() / 1000)
const payload = {
iss: issuer,
aud: audience,
sub: subject,
iat: now,
exp: now + ttl,
...(nonce ? { nonce } : {}),
...claims,
}
const idToken = await sign(payload, key, 'EdDSA')
return c.json(z.encode(schema.token.returns, { idToken }))
})
// Verify-only discovery: this is a verifiable ID-token issuer (JWKS + JWT),
// not a full interactive OIDC OP. Apps integrate by verifying the token (the
// `jwks_uri` is the field that matters), not by running a redirect login — so
// `authorization_endpoint`/`token_endpoint` are intentionally omitted. The
// shape otherwise mirrors workload-identity issuers (GitHub Actions, GitLab
// CI, Kubernetes), which advertise `response_types_supported: ["id_token"]`
// + JWKS only.
router.get(discoveryPath, (c) =>
c.json({
issuer,
jwks_uri: resolvedJwksUri,
response_types_supported: ['id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['EdDSA'],
claims_supported: claimsSupported,
}),
)
router.get(jwksPath, (c) =>
c.json(
{ keys: [jwk] },
{ headers: { 'Cache-Control': 'public, max-age=31536000, immutable' } },
),
)
return router
}
export declare namespace oidcProvider {
/** Return type of `oidcProvider()` — a `Handler`. */
type ReturnType = Handler
/**
* Resolves the authenticated subject for a token request — e.g. mapping a
* session cookie to an account address. Throwing rejects issuance with
* `401`. When omitted, the request body's `subject` is used instead.
*/
type authenticate = (request: Request) => string | Promise<string>
/**
* Returns the claim set embedded into the minted token. The deployment owns
* the verification policy (e.g. only return `email` when verified, and set
* `email_verified: true`). Throwing rejects issuance with `400`.
*/
type getClaims = (params: {
/** Audience the token is minted for. */
audience: string
/** OIDC `nonce`, when supplied by the caller. */
nonce: string | undefined
/** Underlying request — useful for headers, IP, etc. */
request: Request
/** Resolved subject the token is minted for. */
subject: string
}) => Record<string, unknown> | Promise<Record<string, unknown>>
type Options = from.Options & {
/**
* Resolves the authenticated subject from the request (e.g. session
* cookie → account address). When omitted, the request body's `subject`
* is trusted — only safe behind an authenticating gateway.
*/
authenticate?: authenticate | undefined
/**
* Claim names advertised in the discovery document's `claims_supported`.
* Append the deployment's own claims (e.g. `email`, `email_verified`) to
* the protocol defaults.
* @default ["iss", "aud", "sub", "iat", "exp", "nonce"]
*/
claimsSupported?: readonly string[] | undefined
/** Returns the claim set embedded into the minted token. */
getClaims: getClaims
/**
* Issuer identifier — set as the token `iss` and the discovery `issuer`.
* Must be an absolute URL (e.g. `'https://wallet.tempo.xyz'`). OIDC serves
* discovery at `{issuer}/.well-known/openid-configuration`, so `issuer` must
* equal the mount's public URL — i.e. `{origin}{path}` when mounted under a
* `path` (e.g. `'https://wallet.tempo.xyz/api/oidc'` for `path: '/api/oidc'`).
*/
issuer: string
/**
* Absolute URL advertised as the discovery `jwks_uri`. Defaults to the
* mounted JWKS route under `issuer`. Set this when JWKS is served
* elsewhere (e.g. an existing `/.well-known/jwks.json`).
*/
jwksUri?: string | undefined
/** Key id set on the JWKS entry. @default "oidc-1" */
kid?: string | undefined
/** Path prefix for the provider endpoints. @default "/" */
path?: string | undefined
/** Public signing key (JWK string), served at the JWKS route. */
publicKey: string
/** Private signing key (JWK string), used to sign tokens (EdDSA). */
signingKey: string
/** Token lifetime in seconds. @default 300 */
ttl?: number | undefined
}
}
/**
* Parse a JWK string, normalizing the `Ed25519` algorithm name to the JWT
* `EdDSA` value expected by `hono/jwt`.
*/
function parseJwk(jwk: string): JsonWebKey {
const parsed = JSON.parse(jwk) as JsonWebKey
if (parsed.alg === 'Ed25519') parsed.alg = 'EdDSA'
return parsed
}
/** Strip a single trailing slash so URL joins don't double up. */
function trimTrailingSlash(value: string): string {
return value.endsWith('/') ? value.slice(0, -1) : value
}