alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
481 lines (454 loc) • 15.5 kB
text/typescript
import { createHash, randomUUID } from "node:crypto";
import { $inject, Alepha, AlephaError } from "alepha";
import { CryptoProvider } from "alepha/crypto";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { $repository } from "alepha/orm";
import {
type IssuerPrimitive,
JwtProvider,
type UserAccount,
} from "alepha/security";
import {
type OAuthClientEntity,
oauthClientEntity,
} from "../entities/oauthClientEntity.ts";
/**
* The user projection a realm's `loadUser` hands to the OAuth module —
* a `UserAccount` plus the OIDC profile extras the id_token mints
* (`email_verified`; `firstName`/`lastName` map to `given_name`/`family_name`).
*/
export interface OidcIssuerUser extends UserAccount {
emailVerified?: boolean;
}
export interface RegisterClientOptions {
realm: string;
/**
* Explicit client id (OIDC clients registered by Platform). When omitted,
* a `mcp_<uuid>` id is generated (Dynamic Client Registration).
*/
clientId?: string;
clientName?: string;
redirectUris: string[];
scopes?: string[];
/**
* `confidential` clients require a `secret` (stored hashed) and authenticate
* at the token endpoint. Defaults to `public` (PKCE only).
*/
type?: "public" | "confidential";
/** First-party client — skip the consent screen (see the entity field). */
trusted?: boolean;
/** Raw secret for a confidential client; stored as a scrypt hash. */
secret?: string;
source?: "dcr" | "user" | "admin";
createdByUserId?: string;
}
/**
* 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.
*/
export class OAuthClientService {
protected readonly alepha = $inject(Alepha);
protected readonly dateTime = $inject(DateTimeProvider);
protected readonly log = $logger();
protected readonly repo = $repository(oauthClientEntity);
protected readonly jwt = $inject(JwtProvider);
protected readonly 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.
*/
protected readonly usedCodes = new Set<string>();
/**
* 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.
*/
protected readonly issuers = new Map<
string,
{
issuer: IssuerPrimitive;
loadUser: (userId: string) => Promise<OidcIssuerUser>;
}
>();
/**
* Register a realm issuer and a user loader. Called by `$realm` so the
* OAuth token endpoint can mint access tokens for that realm.
*/
public registerIssuer(
realm: string,
issuer: IssuerPrimitive,
loadUser: (userId: string) => Promise<OidcIssuerUser>,
): void {
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.
*/
public async issueAccessToken(
realm: string,
grant: {
userId: string;
scopes: string[];
resource?: string;
clientId?: string;
},
): Promise<{
access_token: string;
expires_in?: number;
refresh_token?: string;
}> {
const entry = this.issuers.get(realm);
if (!entry) {
throw new AlephaError(`No issuer registered for realm '${realm}'`);
}
const user = await entry.loadUser(grant.userId);
// Tag the session the issuer creates with the OAuth client, so it can
// later be surfaced as a "connected app" and revoked individually.
const tokens = await entry.issuer.createToken(user, undefined, {
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.
*/
public async issueIdToken(
realm: string,
params: {
userId: string;
clientId: string;
issuer: string;
nonce?: string;
},
): Promise<string> {
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 !== undefined
? { 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.
*/
public async refreshAccessToken(
realm: string,
refreshToken: string,
): Promise<{
access_token: string;
expires_in?: number;
refresh_token?: string;
/** Subject of the refreshed session — lets the token endpoint re-mint an
* OIDC `id_token` for relying parties that forward it as the Bearer. */
userId: string;
}> {
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).
*/
public async register(
options: RegisterClientOptions,
): Promise<OAuthClientEntity> {
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)
: undefined;
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).
*/
public async updateRedirectUris(
clientId: string,
redirectUris: string[],
): Promise<void> {
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.
*/
public async verifySecret(
clientId: string,
secret: string,
): Promise<boolean> {
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).
*/
protected assertValidRedirectUri(uri: string): void {
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) {
const host = uri.slice(uri.indexOf("://") + 3).split("/")[0] ?? "";
if (!host.includes("*")) {
throw new AlephaError(
`Wildcard '*' is only allowed in the host: ${uri}`,
);
}
}
}
/**
* Look up a client by its public `clientId`. Returns null if unknown.
*/
public async findByClientId(
clientId: string,
): Promise<OAuthClientEntity | null> {
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.
*/
public intersectScopes(
requested: string[] | undefined,
allowed: string[],
): string[] {
if (!requested || requested.length === 0) {
return allowed;
}
const allowedSet = new Set(allowed);
const granted: string[] = [];
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/...`.
*/
public isRedirectUriAllowed(
client: OAuthClientEntity,
redirectUri: string,
): boolean {
return client.redirectUris.some((pattern) =>
this.redirectUriMatches(pattern, redirectUri),
);
}
protected redirectUriMatches(pattern: string, candidate: string): boolean {
if (!pattern.includes("*")) {
return pattern === candidate;
}
// Escape every regex metachar (incl. the `*` and the dots), then turn the
// single escaped `*` into a one-label match (`[^.]+` — no dots).
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const rx = new RegExp(`^${escaped.replace("\\*", "[^.]+")}$`);
return rx.test(candidate);
}
/**
* Mint a stateless authorization code: a short-lived signed JWT
* (`typ: "oauth_code"`) carrying the grant. No server-side code storage.
*/
public async createAuthorizationCode(
realm: string,
grant: {
userId: string;
clientId: string;
redirectUri: string;
codeChallenge: string;
scopes: string[];
resource?: string;
nonce?: string;
},
): Promise<string> {
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.
*/
public async consumeAuthorizationCode(
realm: string,
code: string,
check: { clientId: string; redirectUri: string; codeVerifier: string },
): Promise<{
userId: string;
scopes: string[];
resource?: string;
nonce?: string;
}> {
const { result } = await this.jwt.parse(code, realm, {
typ: "oauth_code",
});
const payload = result.payload as Record<string, unknown>;
const jti = payload.jti as string;
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");
}
const computed = createHash("sha256")
.update(check.codeVerifier)
.digest("base64url");
if (computed !== payload.code_challenge) {
throw new AlephaError("PKCE verification failed");
}
this.usedCodes.add(jti);
return {
userId: payload.sub as string,
scopes: (payload.scopes as string[]) ?? [],
resource: payload.resource as string | undefined,
nonce: payload.nonce as string | undefined,
};
}
}