UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

800 lines (799 loc) 30.5 kB
import { $atom, $inject, $module, $state, Alepha, AlephaError, z } from "alepha"; import { $logger } from "alepha/logger"; import { JwtProvider } from "alepha/security"; import { $route } from "alepha/server"; import { createHash, randomUUID } from "node:crypto"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { $entity, $repository, db } from "alepha/orm"; //#region ../../src/api/oauth/helpers/consentPage.ts const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" })[c]); const renderConsentPage = (options) => { const hidden = Object.entries(options.hidden).map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}" />`).join(""); const scopes = options.scopes.length ? options.scopes.map((s) => `<li>${escapeHtml(s)}</li>`).join("") : "<li>Basic access</li>"; return `<!doctype html> <html lang="en"><head><meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>Authorize ${escapeHtml(options.clientName)}</title> <style> body{font-family:system-ui,sans-serif;background:#0b0b0f;color:#e5e5e5; display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0} .card{background:#16161d;border:1px solid #2a2a35;border-radius:12px; padding:32px;max-width:380px;width:100%} h1{font-size:18px;margin:0 0 4px}p{color:#9a9aa5;font-size:14px;margin:4px 0 16px} ul{font-size:14px;padding-left:18px} .row{display:flex;gap:8px;margin-top:20px} button{flex:1;padding:10px;border-radius:8px;border:0;font-size:14px;cursor:pointer} .allow{background:#6d5cf0;color:#fff}.deny{background:#2a2a35;color:#e5e5e5} </style></head><body> <div class="card"> <h1>${escapeHtml(options.clientName)} wants to connect</h1> <p>Signed in as ${escapeHtml(options.userName)}</p> <p>It will be able to:</p> <ul>${scopes}</ul> <form method="POST" action="/oauth/authorize">${hidden} <div class="row"> <button class="deny" type="submit" name="decision" value="deny">Deny</button> <button class="allow" type="submit" name="decision" value="allow">Allow</button> </div></form></div></body></html>`; }; //#endregion //#region ../../src/api/oauth/helpers/oauthMetadata.ts /** * RFC 8414 — OAuth 2.0 Authorization Server Metadata. * `baseUrl` is the absolute origin of the deployment (e.g. https://app.com). */ const buildAuthorizationServerMetadata = (baseUrl) => ({ issuer: baseUrl, authorization_endpoint: `${baseUrl}/oauth/authorize`, token_endpoint: `${baseUrl}/oauth/token`, registration_endpoint: `${baseUrl}/oauth/register`, jwks_uri: `${baseUrl}/oauth/jwks`, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["mcp"] }); /** * RFC 9728 — OAuth 2.0 Protected Resource Metadata. * `resource` is the absolute URL of the MCP endpoint being protected. */ const buildProtectedResourceMetadata = (baseUrl, resource) => ({ resource, authorization_servers: [baseUrl], scopes_supported: ["mcp"], bearer_methods_supported: ["header"] }); //#endregion //#region ../../src/api/oauth/helpers/oidcMetadata.ts /** * OpenID Connect Discovery 1.0 — provider metadata document * (`/.well-known/openid-configuration`). `baseUrl` is the absolute origin of * the OIDC provider (e.g. https://alepha.club). */ const buildOpenIdConfiguration = (baseUrl) => ({ issuer: baseUrl, authorization_endpoint: `${baseUrl}/oauth/authorize`, token_endpoint: `${baseUrl}/oauth/token`, jwks_uri: `${baseUrl}/oauth/jwks`, registration_endpoint: `${baseUrl}/oauth/register`, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["EdDSA", "RS256"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none", "client_secret_post"], scopes_supported: [ "openid", "email", "profile" ] }); //#endregion //#region ../../src/api/oauth/schemas/authorizeDecisionBodySchema.ts /** * Body posted by the consent screen. All authorization-request parameters * are round-tripped through hidden form fields so the POST handler can * re-validate them without server-side session state. */ const authorizeDecisionBodySchema = z.object({ decision: z.text(), response_type: z.text(), client_id: z.text(), redirect_uri: z.text({ maxLength: 2048 }), code_challenge: z.text(), code_challenge_method: z.text(), scope: z.text({ maxLength: 1024 }).optional(), state: z.text({ maxLength: 512 }).optional(), resource: z.text({ maxLength: 2048 }).optional(), nonce: z.text({ maxLength: 512 }).optional() }); //#endregion //#region ../../src/api/oauth/schemas/authorizeQuerySchema.ts /** * OAuth 2.1 authorization request query parameters (GET /oauth/authorize). */ const authorizeQuerySchema = z.object({ response_type: z.text(), client_id: z.text(), redirect_uri: z.text({ maxLength: 2048 }), code_challenge: z.text(), code_challenge_method: z.text(), scope: z.text({ maxLength: 1024 }).optional(), state: z.text({ maxLength: 512 }).optional(), resource: z.text({ maxLength: 2048 }).optional(), prompt: z.text({ maxLength: 64 }).optional(), nonce: z.text({ maxLength: 512 }).optional() }); //#endregion //#region ../../src/api/oauth/schemas/registerClientBodySchema.ts /** * RFC 7591 Dynamic Client Registration request body. * Only the fields Alepha consumes are typed; unknown fields are ignored. */ const registerClientBodySchema = z.object({ client_name: z.text({ maxLength: 200 }).optional(), redirect_uris: z.array(z.text({ maxLength: 2048 })).min(1), scope: z.text({ maxLength: 1024 }).optional(), grant_types: z.array(z.text()).optional(), token_endpoint_auth_method: z.text().optional() }).meta({ additionalProperties: true }); //#endregion //#region ../../src/api/oauth/schemas/tokenRequestBodySchema.ts /** * Body of a POST /oauth/token request. OAuth 2.1 mandates * application/x-www-form-urlencoded encoding (section 3.2.2). All fields are * optional at the schema level; the handler enforces the grant-specific * requirements and returns the appropriate OAuth error responses. */ const tokenRequestBodySchema = z.object({ grant_type: z.text().optional(), code: z.text({ maxLength: 4096 }).optional(), client_id: z.text().optional(), redirect_uri: z.text({ maxLength: 2048 }).optional(), code_verifier: z.text({ maxLength: 256 }).optional(), refresh_token: z.text({ maxLength: 4096 }).optional(), client_secret: z.text({ maxLength: 512 }).optional() }); //#endregion //#region ../../src/api/oauth/entities/oauthClientEntity.ts /** * A registered OAuth 2.1 client application. * * Rows are created by Dynamic Client Registration (RFC 7591) when an MCP * client (e.g. Claude) first connects. `source` records who created the * client; for DCR it is always `"dcr"` and `createdByUserId` is null until * a user completes an authorization. */ const oauthClientEntity = $entity({ name: "oauth_clients", schema: z.object({ id: db.primaryKey(z.uuid()), createdAt: db.createdAt(), updatedAt: db.updatedAt(), clientId: z.text({ maxLength: 64 }), clientName: z.text({ maxLength: 200 }), redirectUris: db.default(z.array(z.text({ maxLength: 2048 })), []), scopes: db.default(z.array(z.text({ maxLength: 64 })), []), realm: z.text({ maxLength: 100 }), /** * OAuth client type. `confidential` clients authenticate at the token * endpoint with a secret; `public` clients rely on PKCE only. */ type: db.default(z.enum(["public", "confidential"]), "public"), /** * First-party client: skip the consent screen. Consent exists to protect a * user from THIRD-party apps requesting their data; a trusted client is the * authorization server's own product (same vendor), so an authenticated * user is sent straight back with a code — no "App X wants to connect" page. */ trusted: db.default(z.boolean(), false), /** * Scrypt hash of the client secret (confidential clients only). Null for * public clients. Verified via `OAuthClientService.verifySecret`. */ clientSecretHash: z.text({ maxLength: 256 }).optional(), source: db.default(z.text({ maxLength: 16 }), "dcr"), createdByUserId: z.uuid().optional(), lastUsedAt: z.datetime().optional(), revokedAt: z.datetime().optional() }), indexes: [{ columns: ["clientId"], unique: true }] }); //#endregion //#region ../../src/api/oauth/services/OAuthClientService.ts /** * Core OAuth 2.1 service backing the authorization server. * * Responsibilities: * - Client registration (RFC 7591 Dynamic Client Registration) and lookup, * with exact-match redirect_uri validation. * - Stateless PKCE authorization codes: minting short-lived signed JWTs that * carry the grant, and verifying/consuming them (replay, expiry, client and * redirect_uri checks, S256 PKCE). * - Realm issuer registry: realms register an issuer + user loader so the * token endpoint can mint access tokens without depending on realm wiring. */ var OAuthClientService = class { alepha = $inject(Alepha); dateTime = $inject(DateTimeProvider); log = $logger(); repo = $repository(oauthClientEntity); jwt = $inject(JwtProvider); crypto = $inject(CryptoProvider); /** * Codes already redeemed in this process. Single-use enforcement only * needs to cover the ~60s code lifetime, so a bounded in-memory set is * sufficient even on serverless — an expired code fails JWT verification * regardless. */ usedCodes = /* @__PURE__ */ new Set(); /** * Registry of realm issuers used to mint access tokens. Populated by * `$realm` (via `registerIssuer`) so the OAuth module does not depend on the * realm wiring directly. */ issuers = /* @__PURE__ */ new Map(); /** * Register a realm issuer and a user loader. Called by `$realm` so the * OAuth token endpoint can mint access tokens for that realm. */ registerIssuer(realm, issuer, loadUser) { this.issuers.set(realm, { issuer, loadUser }); } /** * Mint an access token for a consumed authorization-code grant, using the * issuer registered for `realm`. Throws if the realm has no issuer. */ async issueAccessToken(realm, grant) { const entry = this.issuers.get(realm); if (!entry) throw new AlephaError(`No issuer registered for realm '${realm}'`); const user = await entry.loadUser(grant.userId); const tokens = await entry.issuer.createToken(user, void 0, { clientId: grant.clientId }); return { access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token }; } /** * Mint an OIDC `id_token` for a consumed grant, signed by the realm's issuer * key (asymmetric when configured). Claims: iss, sub, aud (client_id), exp, * iat, nonce (when present), plus the standard profile claims the loader * provides: email, email_verified, name, given_name, family_name, * preferred_username, picture. */ async issueIdToken(realm, params) { const entry = this.issuers.get(realm); if (!entry) throw new AlephaError(`No issuer registered for realm '${realm}'`); const user = await entry.loadUser(params.userId); const iat = this.dateTime.now().unix(); const exp = iat + entry.issuer.accessTokenExpiration.asSeconds(); return this.jwt.create({ iss: params.issuer, sub: user.id, aud: params.clientId, exp, iat, ...params.nonce ? { nonce: params.nonce } : {}, ...user.email ? { email: user.email } : {}, ...user.emailVerified !== void 0 ? { email_verified: user.emailVerified } : {}, ...user.name ? { name: user.name } : {}, ...user.firstName ? { given_name: user.firstName } : {}, ...user.lastName ? { family_name: user.lastName } : {}, ...user.username ? { preferred_username: user.username } : {}, ...user.picture ? { picture: user.picture } : {} }, realm, { header: { typ: "JWT" } }); } /** * Exchange a refresh token for a fresh access token (OAuth 2.1 * `refresh_token` grant), using the issuer registered for `realm`. Lets an * MCP client stay connected for the refresh token's full lifetime without * re-running the authorization flow. Throws if the realm has no issuer or * the refresh token is invalid/expired. */ async refreshAccessToken(realm, refreshToken) { const entry = this.issuers.get(realm); if (!entry) throw new AlephaError(`No issuer registered for realm '${realm}'`); const { tokens, user } = await entry.issuer.refreshToken(refreshToken); return { access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token, userId: user.id }; } /** * Register a new OAuth client. Used by the RFC 7591 DCR endpoint and, * later, by user/admin UIs (via the `source` field). */ async register(options) { if (options.redirectUris.length === 0) throw new AlephaError("At least one redirect_uri is required"); for (const uri of options.redirectUris) this.assertValidRedirectUri(uri); const type = options.type ?? "public"; if (type === "confidential" && !options.secret) throw new AlephaError("A confidential client requires a secret"); const clientId = options.clientId ?? `mcp_${randomUUID().replace(/-/g, "")}`; const clientSecretHash = options.secret ? await this.crypto.hashPassword(options.secret) : void 0; const client = await this.repo.create({ clientId, clientName: options.clientName || "OAuth Client", redirectUris: options.redirectUris, scopes: options.scopes ?? ["openid"], realm: options.realm, type, trusted: options.trusted ?? false, clientSecretHash, source: options.source ?? "dcr", createdByUserId: options.createdByUserId }); this.log.info("OAuth client registered", { clientId, type, source: client.source }); return client; } /** * Idempotently align a registered client's redirect_uri allowlist with the * given list. No-op when the client is unknown or the list already matches — * safe to call from post-deploy seeders (allowlist changes ship as code, and * `register` alone would leave existing rows stale). */ async updateRedirectUris(clientId, redirectUris) { if (redirectUris.length === 0) throw new AlephaError("At least one redirect_uri is required"); for (const uri of redirectUris) this.assertValidRedirectUri(uri); const client = await this.findByClientId(clientId); if (!client) return; if (client.redirectUris.length === redirectUris.length && client.redirectUris.every((uri, i) => uri === redirectUris[i])) return; await this.repo.updateById(client.id, { redirectUris }); this.log.info("OAuth client redirect_uris updated", { clientId, redirectUris }); } /** * Verify a confidential client's secret against its stored scrypt hash. * Returns false for unknown/revoked/public (no-hash) clients. */ async verifySecret(clientId, secret) { const client = await this.findByClientId(clientId); if (!client || client.revokedAt || !client.clientSecretHash) return false; return this.crypto.verifyPassword(secret, client.clientSecretHash); } /** * Validate a registered redirect_uri. https (or http://localhost) only, and * at most a single `*` which must live inside the host (see * `redirectUriMatches` for the matching rule). */ assertValidRedirectUri(uri) { const stars = (uri.match(/\*/g) ?? []).length; if (stars > 1) throw new AlephaError(`At most one '*' wildcard is allowed in redirect_uri: ${uri}`); const probe = uri.replace("*", "wildcard"); if (!probe.startsWith("https://") && !probe.startsWith("http://localhost")) throw new AlephaError(`Invalid redirect_uri: ${uri}`); if (stars === 1) { if (!(uri.slice(uri.indexOf("://") + 3).split("/")[0] ?? "").includes("*")) throw new AlephaError(`Wildcard '*' is only allowed in the host: ${uri}`); } } /** * Look up a client by its public `clientId`. Returns null if unknown. */ async findByClientId(clientId) { return await this.repo.findOne({ where: { clientId: { eq: clientId } } }) ?? null; } /** * Narrow the scopes a client asks for to those it is actually registered * for. The requested scope string is attacker-controlled, so it must never * be trusted verbatim — a client registered for `["mcp"]` that asks for * `mcp admin` must only be granted `mcp`. When nothing (usable) is * requested, the client's full registered set is the default grant. * Requested order is preserved and duplicates are dropped. */ intersectScopes(requested, allowed) { if (!requested || requested.length === 0) return allowed; const allowedSet = new Set(allowed); const granted = []; for (const scope of requested) if (allowedSet.has(scope) && !granted.includes(scope)) granted.push(scope); return granted; } /** * Redirect_uri check. A registered pattern is matched byte-exact unless it * contains a single `*`, which matches exactly ONE host label (no dots). * E.g. `https://*.alepha.club/auth/callback` matches * `https://b14.alepha.club/auth/callback` but NOT `https://alepha.club/...` * nor `https://a.b.alepha.club/...`. */ isRedirectUriAllowed(client, redirectUri) { return client.redirectUris.some((pattern) => this.redirectUriMatches(pattern, redirectUri)); } redirectUriMatches(pattern, candidate) { if (!pattern.includes("*")) return pattern === candidate; const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`^${escaped.replace("\\*", "[^.]+")}$`).test(candidate); } /** * Mint a stateless authorization code: a short-lived signed JWT * (`typ: "oauth_code"`) carrying the grant. No server-side code storage. */ async createAuthorizationCode(realm, grant) { const iat = this.dateTime.now().unix(); return this.jwt.create({ sub: grant.userId, client_id: grant.clientId, redirect_uri: grant.redirectUri, code_challenge: grant.codeChallenge, scopes: grant.scopes, resource: grant.resource, nonce: grant.nonce, iat, exp: iat + 60, jti: randomUUID() }, realm, { header: { typ: "oauth_code" } }); } /** * Verify and atomically consume an authorization code. Throws on expiry, * replay, client/redirect mismatch, or PKCE failure. */ async consumeAuthorizationCode(realm, code, check) { const { result } = await this.jwt.parse(code, realm, { typ: "oauth_code" }); const payload = result.payload; const jti = payload.jti; if (this.usedCodes.has(jti)) throw new AlephaError("Authorization code already used"); if (payload.client_id !== check.clientId) throw new AlephaError("client_id mismatch"); if (payload.redirect_uri !== check.redirectUri) throw new AlephaError("redirect_uri mismatch"); if (createHash("sha256").update(check.codeVerifier).digest("base64url") !== payload.code_challenge) throw new AlephaError("PKCE verification failed"); this.usedCodes.add(jti); return { userId: payload.sub, scopes: payload.scopes ?? [], resource: payload.resource, nonce: payload.nonce }; } }; //#endregion //#region ../../src/api/oauth/controllers/OAuthController.ts /** * Configuration for the OAuth authorization server. * `realm` is the issuer realm whose JWTs are minted as access tokens; * `resource` is the path of the protected MCP endpoint; * `loginPath` is the app-level login page unauthenticated users are * redirected to from the authorize endpoint. */ const oauthOptions = $atom({ name: "alepha.api.oauth.options", description: "Configuration for the OAuth authorization server.", schema: z.object({ realm: z.text({ default: "users" }), resource: z.text({ default: "/mcp" }), loginPath: z.text({ default: "/login" }) }), default: { realm: "users", resource: "/mcp", loginPath: "/login" } }); /** * OAuth 2.1 authorization server endpoints: discovery metadata and * RFC 7591 dynamic client registration. Authorize/token routes are added * separately. */ var OAuthController = class { log = $logger(); options = $state(oauthOptions); clients = $inject(OAuthClientService); jwt = $inject(JwtProvider); /** * Absolute origin of the current request, e.g. https://app.com. */ baseUrl(url) { return `${url.protocol}//${url.host}`; } metadata = $route({ method: "GET", path: "/.well-known/oauth-authorization-server", handler: ({ url, reply }) => { reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify(buildAuthorizationServerMetadata(this.baseUrl(url))); } }); protectedResource = $route({ method: "GET", path: "/.well-known/oauth-protected-resource", handler: ({ url, reply }) => { const base = this.baseUrl(url); reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify(buildProtectedResourceMetadata(base, `${base}${this.options.resource}`)); } }); openidConfiguration = $route({ method: "GET", path: "/.well-known/openid-configuration", handler: ({ url, reply }) => { reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify(buildOpenIdConfiguration(this.baseUrl(url))); } }); jwks = $route({ method: "GET", path: "/oauth/jwks", handler: async ({ reply }) => { reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify(await this.jwt.getJwks(this.options.realm)); } }); register = $route({ method: "POST", path: "/oauth/register", schema: { body: registerClientBodySchema }, handler: async ({ body, reply }) => { const client = await this.clients.register({ realm: this.options.realm, clientName: body.client_name ?? "MCP Client", redirectUris: body.redirect_uris, scopes: body.scope ? body.scope.split(" ") : ["mcp"], source: "dcr" }); reply.status = 201; reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify({ client_id: client.clientId, client_id_issued_at: Math.floor(new Date(client.createdAt).getTime() / 1e3), client_name: client.clientName, redirect_uris: client.redirectUris, grant_types: ["authorization_code"], token_endpoint_auth_method: "none" }); } }); /** * GET /oauth/authorize — OAuth 2.1 authorization request. If the user * has no session, redirect to the realm login page with a return URL. * If authenticated, render the consent screen. */ authorize = $route({ method: "GET", path: "/oauth/authorize", schema: { query: authorizeQuerySchema }, use: [], handler: async ({ query, user, url, reply }) => { if (query.response_type !== "code") { reply.status = 400; reply.body = "unsupported response_type"; return; } if (query.code_challenge_method !== "S256") { reply.status = 400; reply.body = "code_challenge_method must be S256"; return; } const client = await this.clients.findByClientId(query.client_id); if (!client || client.revokedAt) { reply.status = 400; reply.body = "unknown client_id"; return; } if (!this.clients.isRedirectUriAllowed(client, query.redirect_uri)) { reply.status = 400; reply.body = "redirect_uri not registered"; return; } const silent = query.prompt === "none"; const skipConsent = silent || client.trusted === true; if (!user) { if (silent) { const redirect = new URL(query.redirect_uri); redirect.searchParams.set("error", "login_required"); if (query.state) redirect.searchParams.set("state", query.state); reply.redirect(redirect.toString(), 302); return; } const returnTo = encodeURIComponent(url.pathname + url.search); reply.redirect(`${this.options.loginPath}?redirect_uri=${returnTo}`, 302); return; } if (skipConsent) { const code = await this.clients.createAuthorizationCode(this.options.realm, { userId: user.id, clientId: query.client_id, redirectUri: query.redirect_uri, codeChallenge: query.code_challenge, scopes: this.clients.intersectScopes(query.scope?.split(" "), client.scopes), resource: query.resource || void 0, nonce: query.nonce }); const redirect = new URL(query.redirect_uri); redirect.searchParams.set("code", code); if (query.state) redirect.searchParams.set("state", query.state); reply.redirect(redirect.toString(), 302); return; } reply.headers["content-type"] = "text/html; charset=utf-8"; reply.body = renderConsentPage({ clientName: client.clientName, userName: user.name ?? user.email ?? "your account", scopes: this.clients.intersectScopes(query.scope?.split(" "), client.scopes), hidden: { response_type: query.response_type, client_id: query.client_id, redirect_uri: query.redirect_uri, code_challenge: query.code_challenge, code_challenge_method: query.code_challenge_method, scope: query.scope ?? "", state: query.state ?? "", resource: query.resource ?? "", nonce: query.nonce ?? "" } }); } }); /** * POST /oauth/authorize — consent decision. On "allow", mint an * authorization code and redirect back to the client's redirect_uri. * * CSRF: this route carries no CSRF token and relies solely on the session * cookie to identify the user. This is a deliberate MVP/MCP tradeoff — a * forged consent submit can still only issue an authorization code to an * already-registered client, and that code is bound by PKCE, so the * attacker cannot redeem it without the matching code_verifier. */ authorizeDecision = $route({ method: "POST", path: "/oauth/authorize", schema: { body: authorizeDecisionBodySchema }, use: [], handler: async ({ body, user, reply }) => { if (!user) { reply.status = 401; reply.body = "authentication required"; return; } const client = await this.clients.findByClientId(body.client_id); if (!client || client.revokedAt || !this.clients.isRedirectUriAllowed(client, body.redirect_uri)) { reply.status = 400; reply.body = "invalid client"; return; } const redirect = new URL(body.redirect_uri); if (body.decision !== "allow") { redirect.searchParams.set("error", "access_denied"); if (body.state) redirect.searchParams.set("state", body.state); reply.redirect(redirect.toString(), 302); return; } const code = await this.clients.createAuthorizationCode(this.options.realm, { userId: user.id, clientId: body.client_id, redirectUri: body.redirect_uri, codeChallenge: body.code_challenge, scopes: this.clients.intersectScopes(body.scope?.split(" "), client.scopes), resource: body.resource || void 0, nonce: body.nonce }); redirect.searchParams.set("code", code); if (body.state) redirect.searchParams.set("state", body.state); reply.redirect(redirect.toString(), 302); } }); /** * POST /oauth/token — supports the `authorization_code` grant (verifies * PKCE, mints an access token via the realm issuer) and the * `refresh_token` grant (exchanges a refresh token for a fresh access * token, so a client stays connected without re-running the flow). */ token = $route({ method: "POST", path: "/oauth/token", schema: { body: tokenRequestBodySchema }, use: [], handler: async ({ body, url, reply }) => { reply.headers["content-type"] = "application/json"; try { if (body.grant_type === "authorization_code") { const client = await this.clients.findByClientId(body.client_id ?? ""); if (!client || client.revokedAt) { reply.status = 400; reply.body = JSON.stringify({ error: "invalid_client" }); return; } if (client.type === "confidential") { if (!await this.clients.verifySecret(client.clientId, body.client_secret ?? "")) { reply.status = 401; reply.body = JSON.stringify({ error: "invalid_client" }); return; } } const grant = await this.clients.consumeAuthorizationCode(this.options.realm, body.code ?? "", { clientId: body.client_id ?? "", redirectUri: body.redirect_uri ?? "", codeVerifier: body.code_verifier ?? "" }); const tokens = await this.clients.issueAccessToken(this.options.realm, { ...grant, clientId: body.client_id ?? "" }); const response = { access_token: tokens.access_token, token_type: "Bearer", expires_in: tokens.expires_in, refresh_token: tokens.refresh_token, scope: grant.scopes.join(" ") }; if (grant.scopes.includes("openid")) response.id_token = await this.clients.issueIdToken(this.options.realm, { userId: grant.userId, clientId: body.client_id ?? "", issuer: this.baseUrl(url), nonce: grant.nonce }); reply.body = JSON.stringify(response); return; } if (body.grant_type === "refresh_token") { const tokens = await this.clients.refreshAccessToken(this.options.realm, body.refresh_token ?? ""); const response = { access_token: tokens.access_token, token_type: "Bearer", expires_in: tokens.expires_in, refresh_token: tokens.refresh_token }; if (body.client_id) response.id_token = await this.clients.issueIdToken(this.options.realm, { userId: tokens.userId, clientId: body.client_id, issuer: this.baseUrl(url) }); reply.body = JSON.stringify(response); return; } reply.status = 400; reply.body = JSON.stringify({ error: "unsupported_grant_type" }); } catch (e) { this.log.warn("OAuth token exchange failed", e); reply.status = 400; reply.body = JSON.stringify({ error: "invalid_grant" }); } } }); }; //#endregion //#region ../../src/api/oauth/index.ts /** * OAuth 2.1 authorization server module for MCP. * * **Features:** * - OAuth 2.1 authorization code flow with PKCE (RFC 7636) * - Dynamic Client Registration (RFC 7591) * - Authorization server metadata discovery (RFC 8414) * - Stateless authorization codes (short-lived signed JWTs) * - Single-use code enforcement * * **Integration:** * Register the module and configure the realm + protected resource path: * * ```ts * const app = Alepha.create() * .with(AlephaOAuth) * .set(oauthOptions, { realm: "users", resource: "/mcp" }); * ``` * * @module alepha.api.oauth */ const AlephaOAuth = $module({ name: "alepha.api.oauth", services: [OAuthClientService, OAuthController] }); //#endregion export { AlephaOAuth, OAuthClientService, OAuthController, buildOpenIdConfiguration, oauthClientEntity, oauthOptions }; //# sourceMappingURL=index.js.map