alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
396 lines (379 loc) • 14.3 kB
text/typescript
import { $atom, $inject, $state, z } from "alepha";
import { $logger } from "alepha/logger";
import { JwtProvider } from "alepha/security";
import { $route } from "alepha/server";
import { renderConsentPage } from "../helpers/consentPage.ts";
import {
buildAuthorizationServerMetadata,
buildProtectedResourceMetadata,
} from "../helpers/oauthMetadata.ts";
import { buildOpenIdConfiguration } from "../helpers/oidcMetadata.ts";
import { authorizeDecisionBodySchema } from "../schemas/authorizeDecisionBodySchema.ts";
import { authorizeQuerySchema } from "../schemas/authorizeQuerySchema.ts";
import { registerClientBodySchema } from "../schemas/registerClientBodySchema.ts";
import { tokenRequestBodySchema } from "../schemas/tokenRequestBodySchema.ts";
import { OAuthClientService } from "../services/OAuthClientService.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.
*/
export 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.
*/
export class OAuthController {
protected readonly log = $logger();
protected readonly options = $state(oauthOptions);
protected readonly clients = $inject(OAuthClientService);
protected readonly jwt = $inject(JwtProvider);
/**
* Absolute origin of the current request, e.g. https://app.com.
*/
protected baseUrl(url: URL): string {
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() / 1000,
),
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";
// Skip the consent screen for `prompt=none` (silent SSO) AND for trusted
// first-party clients (the AS's own product — consent is for third-party
// apps). A non-silent trusted client still sends an unauthenticated user
// through login first; it just never shows the "wants to connect" page.
const skipConsent = silent || client.trusted === true;
if (!user) {
if (silent) {
// Silent SSO with no IdP session → OIDC `login_required`.
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) {
// Authenticated + (prompt=none OR trusted client) → skip consent and
// mint the code directly (silent SSO + first-party login).
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 || undefined,
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",
// Show the user the scopes they will actually grant, not the raw
// (possibly over-broad) request.
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 || undefined,
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") {
const ok = await this.clients.verifySecret(
client.clientId,
body.client_secret ?? "",
);
if (!ok) {
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: Record<string, unknown> = {
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: Record<string, unknown> = {
access_token: tokens.access_token,
token_type: "Bearer",
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
};
// Re-mint an OIDC `id_token` so id_token-based relying parties (e.g. a
// stateless OIDC RP that forwards the id_token as the request Bearer)
// actually renew their identity on refresh — without it the RP keeps
// forwarding the now-expired id_token and every call 401s. Per OIDC
// Core §12.2 the refreshed id_token carries no `nonce`; `sub` is
// unchanged. Requires `client_id` to set the `aud`.
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" });
}
},
});
}