alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
904 lines (794 loc) • 27.6 kB
text/typescript
import { $hook, $inject, Alepha, z } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import {
InvalidCredentialsError,
type IssuerPrimitive,
SecurityError,
type UserAccount,
} from "alepha/security";
import {
$route,
BadRequestError,
type ServerRawRequest,
type ServerReply,
} from "alepha/server";
import {
$cookie,
type Cookies,
ServerCookiesProvider,
} from "alepha/server/cookies";
import { ServerLinksProvider } from "alepha/server/links";
import {
authorizationCodeGrant,
buildAuthorizationUrl,
buildEndSessionUrl,
calculatePKCECodeChallenge,
randomPKCECodeVerifier,
randomState,
} from "openid-client";
import { alephaServerAuthRoutes } from "../constants/routes.ts";
import { $auth, type AuthPrimitive } from "../primitives/$auth.ts";
import type { AuthenticationProvider } from "../schemas/authenticationProviderSchema.ts";
import { tokenResponseSchema } from "../schemas/tokenResponseSchema.ts";
import { type Tokens, tokensSchema } from "../schemas/tokensSchema.ts";
import { userinfoResponseSchema } from "../schemas/userinfoResponseSchema.ts";
export class ServerAuthProvider {
protected readonly log = $logger();
protected readonly alepha = $inject(Alepha);
protected readonly serverCookiesProvider = $inject(ServerCookiesProvider);
protected readonly dateTimeProvider = $inject(DateTimeProvider);
protected readonly serverLinksProvider = $inject(ServerLinksProvider);
/**
* Validates that a redirect URI is a safe relative path, or — when
* COOKIE_PARENT_DOMAIN is configured — an https URL whose host is the
* parent domain or a subdomain of it. Used by SaaS deployments where the
* OAuth callback dispatches users back to their tenant subdomain.
*
* Prevents open redirect attacks by rejecting any other absolute URL.
*/
protected validateRedirectUri(uri: string): string {
if (uri.startsWith("/") && !uri.startsWith("//")) {
return uri;
}
const parent = this.alepha.env.COOKIE_PARENT_DOMAIN;
if (typeof parent === "string" && parent) {
try {
const parsed = new URL(uri);
const parentHost = parent.startsWith(".") ? parent.slice(1) : parent;
if (parsed.protocol !== "https:") return "/";
if (parsed.host === parentHost) return uri;
if (parsed.host.endsWith(`.${parentHost}`)) return uri;
} catch {
// fall through
}
}
return "/";
}
public get identities(): Array<AuthPrimitive> {
return this.alepha
.primitives($auth)
.filter((auth) => !auth.options.disabled);
}
protected readonly authorizationCode = $cookie({
name: "authorizationCode",
ttl: [15, "minutes"],
httpOnly: true,
encrypt: true,
schema: z.object({
provider: z.text(),
realm: z.text().optional(),
codeVerifier: z.text({ size: "long" }).optional(),
redirectUri: z.text({ size: "long" }).optional(),
loginUri: z.text({ size: "long" }).optional(),
state: z.text().optional(),
nonce: z.text().optional(),
}),
});
public readonly tokens = $cookie({
name: "tokens",
ttl: [30, "days"],
httpOnly: true,
compress: true,
encrypt: true,
schema: tokensSchema,
});
protected readonly configure = $hook({
on: "configure",
handler: async () => {
for (const identity of this.identities) {
await identity.prepare();
}
},
});
/**
* Fill request headers with access token from cookies or fallback to provider's fallback function.
*/
protected readonly onRequest = $hook({
on: "server:onRequest",
after: this.serverCookiesProvider,
handler: async ({ request }) => {
const cookies = request.cookies;
// [feature] forward cookies to request headers
if (cookies) {
const tokens = await this.cookiesToTokens(cookies);
if (tokens) {
request.headers.authorization = `Bearer ${this.extractAccessToken(tokens)}`;
this.log.trace("Access token set in request headers", {
provider: tokens.provider,
});
}
}
// [feature] support for auth providers with fallback
if (!request.headers.authorization) {
for (const provider of this.identities) {
if ("fallback" in provider.options && provider.options.fallback) {
const token = await provider.options.fallback();
if (token) {
request.headers.authorization = `Bearer ${token}`;
break;
}
}
}
}
},
});
// -------------------------------------------------------------------------------------------------------------------
/**
* Get user information.
*/
public readonly userinfo = $route({
path: alephaServerAuthRoutes.userinfo,
use: [],
schema: {
response: userinfoResponseSchema,
},
handler: async ({ user, headers, cookies }) => {
// Prefer the REQUEST's resolved user (the currentUserAtom): global
// `server:onRequest` hooks may have rewritten it — e.g. per-org role
// resolution on a multi-tenant relying party. Re-deriving from the raw
// tokens here would silently drop those roles, so the token path is
// only a FALLBACK for sessions whose resolvers don't populate the atom
// (plain external OAuth2/OIDC providers without an internal issuer).
let resolved = user;
if (!resolved) {
const tokens = this.getTokens(cookies);
if (tokens) {
const provider = this.provider(tokens);
if (!("issuer" in provider.options)) {
resolved = await provider.user(tokens);
}
}
}
const api = await this.serverLinksProvider.getUserApiLinks({
authorization: headers.authorization,
user: resolved,
});
return {
api,
user: resolved,
};
},
});
/**
* Refresh a token for internal providers.
*/
public readonly refresh = $route({
path: alephaServerAuthRoutes.refresh,
method: "POST",
schema: {
query: z.object({
provider: z.text(),
}),
body: z.object({
refresh_token: z.text({
size: "rich",
}),
access_token: z
.text({
size: "rich",
description:
"Required if provider has stateless refresh token on credentials mode",
})
.optional(),
}),
response: tokensSchema,
},
handler: async ({ query, body, cookies }) => {
const provider = this.provider(query);
const tokens = {
provider: query.provider,
...(await provider.refresh(body.refresh_token, body.access_token)),
};
// for web applications, we store tokens in cookies
this.setTokens(tokens, cookies);
return tokens;
},
});
/**
* Login for local password-based authentication.
*/
public readonly token = $route({
path: alephaServerAuthRoutes.token,
method: "POST",
schema: {
query: z.object({
provider: z.text(),
realm: z
.text({ description: "Realm name for multi-realm setups" })
.optional(),
}),
body: z.object({
username: z.text(),
password: z.text(),
}),
response: tokenResponseSchema,
},
handler: async ({ query, body, cookies }) => {
const provider = this.provider({
provider: query.provider,
realm: query.realm,
});
const issuer = provider.issuer;
if (!issuer) {
throw new SecurityError(
`Auth provider '${query.provider}' does not support password grant`,
);
}
const credentials =
"credentials" in provider.options && provider.options.credentials;
if (!credentials) {
throw new SecurityError(
`Auth provider '${query.provider}' does not support password grant`,
);
}
let user: UserAccount | undefined;
try {
user = await credentials.account(body);
} catch (e) {
if (e instanceof InvalidCredentialsError) {
throw e;
}
this.log.error("Failed to authenticate user", e);
throw new InvalidCredentialsError();
}
if (!user) {
throw new InvalidCredentialsError();
}
const tokens = {
provider: query.provider,
...(await issuer.createToken(user)),
};
// for web applications, we store tokens in cookies
this.setTokens(tokens, cookies);
const api = await this.serverLinksProvider.getUserApiLinks({
user,
});
// mobile apps require this
return {
...tokens,
user,
api,
};
},
});
/**
* Oauth2/OIDC login route.
*/
public readonly login = $route({
path: alephaServerAuthRoutes.login,
schema: {
query: z.object({
provider: z.text(),
realm: z
.text({ description: "Realm name for multi-realm setups" })
.optional(),
redirect_uri: z.text({ size: "rich" }).optional(),
}),
},
handler: async ({ query, url, reply, headers }) => {
const loginUri = headers.referer
? new URL(headers.referer).pathname + new URL(headers.referer).search
: undefined;
const provider = this.provider({
provider: query.provider,
realm: query.realm,
});
const oauth = await provider.getOAuth();
if (!oauth) {
throw new SecurityError(
`Auth provider '${query.provider}' does not support OAuth2`,
);
}
const scope = provider.scope;
let redirect_uri =
provider.redirect_uri || alephaServerAuthRoutes.callback;
if (redirect_uri.startsWith("/")) {
redirect_uri = `${url.protocol}//${url.host}${redirect_uri}`;
}
const oidc = "oidc" in provider.options && provider.options.oidc;
if (!oauth.serverMetadata().supportsPKCE()) {
const state = randomState();
const parameters: Record<string, string> = {
redirect_uri,
state,
};
if (oidc) {
parameters.nonce = randomState();
}
if (scope) {
parameters.scope = scope;
}
// biome-ignore lint/complexity/useOptionalChain: oidc is `false | OidcOptions`; optional chaining doesn't narrow `false`
if (oidc && oidc.responseMode) {
parameters.response_mode = oidc.responseMode;
}
// biome-ignore lint/complexity/useOptionalChain: oidc is `false | OidcOptions`; optional chaining doesn't narrow `false`
if (oidc && oidc.authorizationParameters) {
Object.assign(parameters, oidc.authorizationParameters);
}
this.authorizationCode.set({
state,
nonce: parameters.nonce,
redirectUri: this.validateRedirectUri(query.redirect_uri ?? "/"),
loginUri,
provider: query.provider,
realm: query.realm,
});
reply.redirect(
buildAuthorizationUrl(oauth, parameters).toString(),
302,
);
return;
}
// Security note: No state or nonce in the PKCE path is intentional.
// PKCE provides equivalent CSRF protection to state: the code_verifier is bound
// to the session cookie, and the authorization code is bound to the code_challenge.
// An attacker cannot forge the callback without the code_verifier. OAuth 2.1 (RFC 9126)
// makes PKCE mandatory and state optional. For OIDC nonce: the id_token is received
// over back-channel TLS from the token endpoint, making nonce replay irrelevant.
// openid-client/oauth4webapi correctly validates that no state is in the response
// when none was sent (expectNoState).
const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const parameters: Record<string, string> = {
redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: "S256",
};
if (scope) {
parameters.scope = scope;
}
// biome-ignore lint/complexity/useOptionalChain: oidc is `false | OidcOptions`; optional chaining doesn't narrow `false`
if (oidc && oidc.responseMode) {
parameters.response_mode = oidc.responseMode;
}
// biome-ignore lint/complexity/useOptionalChain: oidc is `false | OidcOptions`; optional chaining doesn't narrow `false`
if (oidc && oidc.authorizationParameters) {
Object.assign(parameters, oidc.authorizationParameters);
}
this.authorizationCode.set({
codeVerifier,
redirectUri: this.validateRedirectUri(query.redirect_uri ?? "/"),
loginUri,
provider: query.provider,
realm: query.realm,
});
reply.redirect(buildAuthorizationUrl(oauth, parameters).toString(), 302);
},
});
/**
* Extracts provider-specific extra profile fields delivered via the
* authorization callback form body rather than the ID token or userinfo
* endpoint. Currently handles Apple Sign In's `user` field, which is sent
* only on the user's first authorization and contains their name.
*/
protected async extractFormPostProfile(
req: Request,
): Promise<Record<string, unknown> | undefined> {
try {
const form = await req.formData();
const userField = form.get("user");
if (typeof userField !== "string") {
return undefined;
}
const parsed = JSON.parse(userField) as {
name?: { firstName?: string; lastName?: string };
email?: string;
};
const profile: Record<string, unknown> = {};
if (parsed.name?.firstName) {
profile.given_name = parsed.name.firstName;
}
if (parsed.name?.lastName) {
profile.family_name = parsed.name.lastName;
}
if (parsed.name?.firstName || parsed.name?.lastName) {
profile.name = [parsed.name?.firstName, parsed.name?.lastName]
.filter(Boolean)
.join(" ");
}
if (parsed.email) {
profile.email = parsed.email;
}
return Object.keys(profile).length > 0 ? profile : undefined;
} catch (e) {
this.log.warn("Failed to parse form_post profile from callback body", e);
return undefined;
}
}
/**
* Shared callback logic for both GET and POST OAuth2/OIDC callbacks.
* For form_post response mode (e.g. Apple Sign In), the raw Request object
* is passed so openid-client can read the authorization code from the POST body.
*/
protected async handleCallback(
url: URL,
reply: ServerReply,
cookies: Cookies,
raw?: ServerRawRequest,
) {
const authorizationCode = this.authorizationCode.get({ cookies });
if (!authorizationCode) {
throw new BadRequestError("Missing code verifier");
}
const provider = this.provider(authorizationCode);
const oauth = await provider.getOAuth();
if (!oauth) {
throw new SecurityError(
`Auth provider '${provider.name}' does not support OAuth2`,
);
}
const redirectUri = authorizationCode.redirectUri ?? "/";
const loginUri = authorizationCode.loginUri;
// For form_post response mode (e.g. Apple), pass the raw Request object
// so openid-client can read the authorization code from the POST body.
// Clone first so we can also extract provider-specific fields (e.g. Apple's
// `user` form field, only sent once on first authorization) without
// consuming the body that openid-client needs to read.
let currentUrl: URL | Request = url;
let externalProfile: Record<string, unknown> | undefined;
if (raw?.web?.req && raw.web.req.method === "POST") {
const cloned = raw.web.req.clone();
currentUrl = raw.web.req;
externalProfile = await this.extractFormPostProfile(cloned);
}
const externalTokens = await authorizationCodeGrant(oauth, currentUrl, {
pkceCodeVerifier: authorizationCode.codeVerifier,
expectedState: authorizationCode.state,
expectedNonce: authorizationCode.nonce,
})
.then((tokens) => ({
issued_at: this.dateTimeProvider.now().unix(),
provider: provider.name,
...tokens,
}))
.catch((e) => {
this.log.error("Failed to get access token", e);
throw new SecurityError("Failed to get access token", {
cause: e,
});
});
this.authorizationCode.del({ cookies });
const issuer = provider.issuer;
// external, full OIDC System (e.g. Keycloak, Auth0)
if (!issuer) {
this.setTokens(externalTokens, cookies);
reply.redirect(redirectUri, 302);
return;
}
// internal, we need to create our own tokens
let user: UserAccount;
try {
user = await provider.user(externalTokens, externalProfile);
} catch (e) {
this.log.warn("OAuth2 account linking failed", e);
const errorTarget = loginUri || redirectUri;
const errorUrl = new URL(errorTarget, url.origin);
errorUrl.searchParams.set(
"error",
e instanceof BadRequestError ? e.message : "Authentication failed",
);
reply.redirect(errorUrl.pathname + errorUrl.search, 302);
return;
}
await this.establishSession(user, issuer, provider.name, cookies);
reply.redirect(redirectUri, 302);
}
/**
* Establish a local session for an already-resolved user: mint realm tokens
* and write the `tokens` cookie. Used by the OAuth callback and by federated
* (broker) login. `issuer` is the realm issuer (provider.issuer / realm).
*/
public async establishSession(
user: UserAccount,
issuer: IssuerPrimitive,
providerName: string,
cookies: Cookies,
): Promise<void> {
const tokens = await issuer.createToken(user);
this.setTokens(
{
...tokens,
issued_at: this.dateTimeProvider.now().unix(),
provider: providerName,
},
cookies,
);
}
/**
* Callback for OAuth2/OIDC providers.
* It handles the authorization code flow and retrieves the access token.
*/
public readonly callback = $route({
path: alephaServerAuthRoutes.callback,
handler: async ({ url, reply, cookies }) => {
await this.handleCallback(url, reply, cookies);
},
});
/**
* POST callback for OAuth2/OIDC providers using form_post response mode.
* Apple Sign In sends the authorization code via POST body instead of URL query parameters.
*/
public readonly callbackPost = $route({
path: alephaServerAuthRoutes.callback,
method: "POST",
handler: async ({ url, reply, cookies, raw }) => {
await this.handleCallback(url, reply, cookies, raw);
},
});
/**
* Logout route for OAuth2/OIDC providers.
*/
public readonly logout = $route({
path: alephaServerAuthRoutes.logout,
method: "POST",
schema: {
query: z.object({
post_logout_redirect_uri: z.text().optional(),
}),
},
handler: async ({ query, reply, cookies }) => {
const redirect = this.validateRedirectUri(
query.post_logout_redirect_uri ?? "/",
);
const tokens = this.getTokens(cookies);
if (!tokens) {
reply.redirect(redirect, 302);
return;
}
const provider = this.provider(tokens.provider);
this.tokens.del({ cookies });
// for internal providers, we can delete the session - if available
if (provider.issuer && tokens.refresh_token) {
const onDeleteSession =
provider.issuer.options.settings?.onDeleteSession;
if (onDeleteSession) {
try {
await onDeleteSession(tokens.refresh_token);
} catch (e) {
this.log.error("Failed to delete session", e);
}
}
}
const oauth = await provider.getOAuth();
if (!oauth) {
reply.redirect(redirect, 302);
return;
}
const params = new URLSearchParams();
const idToken = tokens?.id_token;
params.set("post_logout_redirect_uri", redirect);
if (idToken) {
params.set("id_token_hint", idToken);
}
const customLogoutUri =
"oidc" in provider.options
? provider.options.oidc?.logoutUri
: undefined;
if (customLogoutUri) {
reply.redirect(`${customLogoutUri}?${params}`, 302);
return;
}
if (!oauth.serverMetadata().end_session_endpoint) {
// await tokenRevocation(
// oauth,
// tokens?.refresh_token ?? tokens.access_token,
// );
reply.redirect(redirect, 302);
return;
}
reply.redirect(buildEndSessionUrl(oauth, params).toString(), 302);
},
});
// -------------------------------------------------------------------------------------------------------------------
public getAuthenticationProviders(
filters: { realmName?: string } = {},
): AuthenticationProvider[] {
const providers: AuthenticationProvider[] = [];
for (const identity of this.identities) {
if (filters.realmName) {
const issuer = identity.issuer;
if (!issuer || issuer.name !== filters.realmName) {
continue;
}
}
const type =
"oidc" in identity.options
? "OIDC"
: "oauth" in identity.options
? "OAUTH2"
: "credentials" in identity.options
? "CREDENTIALS"
: undefined;
if (!type) {
continue;
}
providers.push({
name: identity.name,
type,
});
}
return providers;
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Find an auth provider by name and optionally by realm.
* When realm is specified, it filters providers by both name and realm.
* This enables multi-realm setups where multiple providers share the same name (e.g., "credentials").
*/
protected provider(
opts: string | { provider: string; realm?: string },
): AuthPrimitive {
const name = typeof opts === "string" ? opts : opts.provider;
const realmName = typeof opts === "string" ? undefined : opts.realm;
const identity = this.identities.find((identity) => {
if (identity.name !== name) {
return false;
}
// If realm filter is specified, match against provider's issuer
if (realmName && identity.issuer?.name !== realmName) {
return false;
}
return true;
});
if (!identity) {
const realmInfo = realmName ? ` for realm '${realmName}'` : "";
throw new SecurityError(`Auth provider '${name}'${realmInfo} not found`);
}
return identity;
}
/**
* Convert cookies to tokens.
* If the tokens are expired, try to refresh them using the refresh token.
*/
protected async cookiesToTokens(
cookies: Cookies,
): Promise<Tokens | undefined> {
const tokens = this.getTokens(cookies);
if (!tokens) {
// no cookie, no tokens
this.log.trace("No tokens found in cookies");
return;
}
this.log.trace("Tokens found in cookies", {
expires_in: tokens.expires_in,
issued_at: tokens.issued_at,
});
// check if tokens are expired
const refreshedTokens = await this.refreshTokens(tokens);
if (!refreshedTokens) {
this.tokens.del({ cookies });
// 08/25: exception here will go to Server error handler, not the React one
// better to remove cookie & session and let the page handle 401 Unauthorized
//throw new SessionExpiredError("Session expired. Please login again.");
return;
}
// Non-constant-time comparison is fine here — this determines whether to update
// the cookie, not whether to grant access. No authentication decision is made.
if (refreshedTokens.access_token !== tokens.access_token) {
this.setTokens(refreshedTokens, cookies);
}
return refreshedTokens;
}
protected getTokens(cookies?: Cookies): Tokens | undefined {
return this.tokens.get({ cookies });
}
protected setTokens(tokens: Tokens, cookies?: Cookies): void {
const exp =
tokens.refresh_token_expires_in ||
tokens.refresh_expires_in ||
tokens.expires_in;
const ttl = exp
? this.dateTimeProvider.duration(exp, "seconds")
: undefined;
this.tokens.set(tokens, {
cookies,
ttl,
});
}
protected extractAccessToken(tokens: Tokens) {
const idp = this.provider(tokens.provider);
if (
"oidc" in idp.options &&
!("issuer" in idp.options) &&
idp.options.oidc?.useIdToken
) {
return tokens.id_token;
}
return tokens.access_token;
}
protected async refreshTokens(tokens: Tokens): Promise<Tokens | undefined> {
// Note: concurrent requests refreshing with the same token is safe here because
// Alepha does not rotate refresh tokens — the same token is reused across refreshes
// (session-based: same UUID in the session row; token-based: same JWT).
// If single-use rotation is ever added (e.g., for SPA/public clients per OAuth 2.1),
// a reuse grace window (à la Auth0) should be implemented to avoid race conditions.
if (tokens.expires_in && tokens.issued_at) {
const gracePeriodSec = 10;
const expiresAt = tokens.issued_at + (tokens.expires_in - gracePeriodSec);
if (expiresAt < this.dateTimeProvider.now().unix()) {
this.log.trace("Tokens are expired");
// oh no, it is expired
if (tokens.refresh_token) {
this.log.trace("Trying to refresh tokens using refresh token");
// but has refresh token!
try {
const provider = this.provider(tokens);
const result = await provider.refresh(
tokens.refresh_token,
tokens.access_token,
);
const newTokens = {
...result,
provider: tokens.provider,
issued_at: this.dateTimeProvider.now().unix(),
};
this.log.debug("Tokens refreshed successfully");
return newTokens;
} catch (e) {
this.log.warn("Failed to refresh token", e);
}
}
// session expired and no (valid) refresh token
return;
}
}
if (!tokens.issued_at && tokens.access_token) {
return;
}
return tokens;
}
}
// ---------------------------------------------------------------------------------------------------------------------
export interface OAuth2Profile {
sub: string; // Subject - unique ID per user (required by OpenID)
email?: string;
name?: string;
given_name?: string;
family_name?: string;
middle_name?: string;
nickname?: string;
preferred_username?: string;
profile?: string;
picture?: string;
website?: string;
email_verified?: boolean;
gender?: string;
birthdate?: string; // ISO 8601: YYYY-MM-DD
zoneinfo?: string;
locale?: string;
phone_number?: string;
phone_number_verified?: boolean;
address?: {
formatted?: string;
street_address?: string;
locality?: string;
region?: string;
postal_code?: string;
country?: string;
};
updated_at?: number; // seconds since epoch
// Allow additional fields (provider-specific)
[key: string]: unknown;
}