alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
800 lines (799 loc) • 30.5 kB
JavaScript
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) => ({
"&": "&",
"<": "<",
">": ">",
"\"": """,
"'": "'"
})[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