Files
fluxer/fluxer_api/src/api/auth/services/SsoService.ts
T

967 lines
33 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash, randomBytes} from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import * as AuthSession from '@app/api/auth/AuthSession';
import {SsoIdentityRepository} from '@app/api/auth/services/SsoIdentityRepository';
import {
parseTokenEndpointResponse,
sanitizeSsoRedirectTo,
tryDiscoverOidcProviderMetadata,
} from '@app/api/auth/services/SsoUtils';
import type {UserID} from '@app/api/BrandedTypes';
import type {ILogger} from '@app/api/ILogger';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
type InstanceConfigRepository,
type InstanceSsoConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {
deriveSsoRedirectUri,
getSsoRequestUrlPolicy,
isTestSsoProvider,
validateSsoPublicOutboundUrl,
} from '@app/api/instance/SsoConfigValidation';
import {Logger} from '@app/api/Logger';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
import {SsoRequiredError} from '@fluxer/errors/src/domains/auth/SsoRequiredError';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import {ms, seconds} from 'itty-time';
import {
type CryptoKey,
createLocalJWKSet,
decodeJwt,
type FlattenedJWSInput,
errors as JoseErrors,
type JSONWebKeySet,
type JWSHeaderParameters,
type JWTPayload,
jwtVerify,
} from 'jose';
interface SsoStatePayload {
codeVerifier: string;
nonce: string;
redirectTo?: string;
redirectUri?: string;
createdAt: number;
}
interface PublicSsoStatus {
enabled: boolean;
enforced: boolean;
display_name: string | null;
redirect_uri: string;
}
interface ResolvedSsoConfig extends InstanceSsoConfig {
redirectUri: string;
scope: string;
ready: boolean;
providerId: string;
isTestProvider: boolean;
issuerForVerification: string | null;
}
interface ResolvedSsoClaims {
email: string;
emailVerified: boolean;
name?: string | null;
sub: string;
}
interface RemoteJwkSetResolver {
(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<CryptoKey>;
coolingDown: boolean;
fresh: boolean;
reloading: boolean;
reload: () => Promise<void>;
jwks: () => JSONWebKeySet | undefined;
}
interface JwksCacheEntry {
jwks: RemoteJwkSetResolver;
cachedAt: number;
}
const CODE_VERIFIER_BYTE_LENGTH = 32;
const STATE_BYTE_LENGTH = 16;
const NONCE_BYTE_LENGTH = 16;
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
let ssoLogger: ILogger | undefined;
function getLogger(): ILogger {
ssoLogger ??= Logger.child({logger: 'SsoService'});
return ssoLogger;
}
function randomBase64UrlToken(byteLength: number): string {
return randomBytes(byteLength).toString('base64url');
}
function randomHexToken(byteLength: number): string {
return randomBytes(byteLength).toString('hex');
}
function buildCodeChallenge(codeVerifier: string): string {
return createHash('sha256').update(codeVerifier).digest('base64url');
}
function buildStateCacheKey(state: string): string {
return `sso:state:${state}`;
}
function buildDiscoveryCacheKey(issuer: string): string {
const key = createHash('sha256').update(issuer).digest('hex').slice(0, 32);
return `sso:oidc-discovery:${key}`;
}
function resolveSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): string {
if (!requestedRedirectUri) return defaultRedirectUri;
const trimmed = requestedRedirectUri.trim();
if (!trimmed) return defaultRedirectUri;
if (trimmed === defaultRedirectUri || trimmed === MOBILE_SSO_REDIRECT_URI) return trimmed;
throw InputValidationError.fromCode('redirect_uri', ValidationErrorCodes.INVALID_URL_FORMAT);
}
function coerceEmailVerified(value: unknown): boolean | undefined {
if (value === true || value === false) return value;
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true') return true;
if (v === 'false') return false;
}
return undefined;
}
function validateEmailAgainstAllowlist(email: string, domains: Array<string>): void {
if (!domains || domains.length === 0) return;
const domain = email.split('@')[1]?.toLowerCase() ?? '';
const allowed = domains.map((d) => d.toLowerCase().trim()).filter(Boolean);
if (!allowed.includes(domain)) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO);
}
}
function buildSsoProviderTrait(providerId: string): string {
const digest = createHash('sha256').update(providerId).digest('hex').slice(0, 32);
return `sso_provider:${digest}`;
}
function buildSsoIdentityTrait(providerId: string, sub: string): string {
const digest = createHash('sha256').update(providerId).update('\0').update(sub).digest('hex');
return `sso_identity:${digest}`;
}
function normalizeSsoEmail(rawEmail: string): string {
const email = rawEmail.trim().toLowerCase();
if (!EmailType.safeParse(email).success) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
return email;
}
function normalizeSsoSubject(rawSubject: string): string {
if (!rawSubject.trim() || rawSubject.length > 1024) {
throw InputValidationError.fromCode('sub', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
return rawSubject;
}
function readStringClaim(
record: Record<string, unknown> | JWTPayload | null | undefined,
key: string,
): string | undefined {
const value = record?.[key];
return typeof value === 'string' ? value : undefined;
}
function parseTestProviderClaims(rawPayload: string): ResolvedSsoClaims {
const parsed = rawPayload.trim().startsWith('{') ? parseJsonRecord(rawPayload) : null;
const rawEmail = parsed ? readStringClaim(parsed, 'email') : rawPayload;
if (!rawEmail) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SSO_TEST_CODE_MISSING_EMAIL);
}
const email = normalizeSsoEmail(rawEmail);
const sub = normalizeSsoSubject(parsed ? (readStringClaim(parsed, 'sub') ?? email) : email);
const emailVerified = parsed ? (coerceEmailVerified(parsed['email_verified']) ?? true) : true;
const name = parsed ? (readStringClaim(parsed, 'name') ?? 'Test SSO User') : 'Test SSO User';
return {email, emailVerified, name, sub};
}
function readMatchingEmailVerified(
record: Record<string, unknown> | JWTPayload | null | undefined,
recordEmail: string | undefined,
expectedEmail: string,
): boolean | undefined {
if (!record || recordEmail !== expectedEmail) {
return undefined;
}
return coerceEmailVerified(record['email_verified']);
}
function resolveEmailVerified({
claims,
userInfo,
idTokenEmail,
userInfoEmail,
email,
}: {
claims: JWTPayload | null;
userInfo: Record<string, unknown> | null;
idTokenEmail: string | undefined;
userInfoEmail: string | undefined;
email: string;
}): boolean {
const values = [
readMatchingEmailVerified(claims, idTokenEmail, email),
readMatchingEmailVerified(userInfo, userInfoEmail, email),
].filter((value): value is boolean => value !== undefined);
if (values.includes(false)) {
return false;
}
return values.length > 0 && values.includes(true);
}
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
if (!isJsonRecord(value)) return false;
const keys = value['keys'];
return Array.isArray(keys) && keys.every(isJsonRecord);
}
export class SsoService {
private static readonly STATE_TTL_SECONDS = seconds('10 minutes');
private static readonly DISCOVERY_TTL_SECONDS = seconds('1 hour');
private static readonly JWKS_CACHE_TTL_MS = ms('1 hour');
private readonly jwksCache = new Map<string, JwksCacheEntry>();
private readonly ssoIdentityRepository = new SsoIdentityRepository();
constructor(
private readonly apiContext: ApiContext,
private readonly instanceConfigRepository: InstanceConfigRepository,
private readonly discriminatorService: IDiscriminatorService,
private readonly kvActivityTracker: KVActivityTracker,
) {}
async getPublicStatus(): Promise<PublicSsoStatus> {
const config = await this.getResolvedConfig();
const enabled = config.enabled && config.ready;
return {
enabled,
enforced: enabled && config.enforced,
display_name: config.displayName ?? null,
redirect_uri: config.redirectUri,
};
}
async isEnforced(): Promise<boolean> {
const config = await this.getResolvedConfig();
return config.enabled && config.ready && config.enforced;
}
async startLogin({redirectTo, redirectUri}: {redirectTo?: string; redirectUri?: string} = {}): Promise<{
authorization_url: string;
state: string;
redirect_uri: string;
}> {
const config = await this.requireReadyConfig();
const state = randomHexToken(STATE_BYTE_LENGTH);
const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH);
const codeChallenge = buildCodeChallenge(codeVerifier);
const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH);
const ssoRedirectUri = resolveSsoRedirectUri(redirectUri, config.redirectUri);
const statePayload: SsoStatePayload = {
codeVerifier,
nonce,
redirectTo: sanitizeSsoRedirectTo(redirectTo),
redirectUri: ssoRedirectUri,
createdAt: Date.now(),
};
const {cache} = this.apiContext.services;
await cache.set(buildStateCacheKey(state), statePayload, SsoService.STATE_TTL_SECONDS);
const searchParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId ?? '',
redirect_uri: ssoRedirectUri,
scope: config.scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
nonce,
});
let authorizationUrlString: string;
try {
const authorizationUrl = new URL(config.authorizationUrl ?? '');
for (const [k, v] of searchParams.entries()) {
authorizationUrl.searchParams.set(k, v);
}
authorizationUrlString = authorizationUrl.toString();
} catch {
if (config.isTestProvider) {
const joiner = (config.authorizationUrl ?? '').includes('?') ? '&' : '?';
authorizationUrlString = `${config.authorizationUrl}${joiner}${searchParams.toString()}`;
} else {
throw new FeatureTemporarilyDisabledError();
}
}
return {authorization_url: authorizationUrlString, state, redirect_uri: ssoRedirectUri};
}
async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{
token: string;
user_id: string;
redirect_to: string;
}> {
const config = await this.requireReadyConfig();
const {cache} = this.apiContext.services;
const statePayload = await cache.getAndDelete<SsoStatePayload>(buildStateCacheKey(state));
if (!statePayload) {
throw InputValidationError.fromCode('state', ValidationErrorCodes.INVALID_OR_EXPIRED_SSO_STATE);
}
const tokenResponse = await this.exchangeCode({
code,
codeVerifier: statePayload.codeVerifier,
redirectUri: statePayload.redirectUri ?? config.redirectUri,
config,
});
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
const user = await this.resolveUserFromClaims(claims, config);
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
}
private async resolveUserFromClaims(claims: ResolvedSsoClaims, config: ResolvedSsoConfig): Promise<User> {
if (!claims.emailVerified) {
throw InputValidationError.fromCode('email_verified', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
const emailLower = claims.email.toLowerCase();
getLogger().info({email: emailLower, has_sub: true}, 'SSO login with sub claim');
const identityUserId = await this.ssoIdentityRepository.findUserId(config.providerId, claims.sub);
if (identityUserId) {
const user = await this.apiContext.services.users.findUnique(identityUserId);
if (!user) {
getLogger().error(
{user_id: identityUserId.toString()},
'SSO identity mapping points at a missing user; refusing reassignment',
);
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
return this.bindSsoIdentity(user, claims.sub, config);
}
const {users} = this.apiContext.services;
const existingUser = await users.findByEmail(emailLower);
if (existingUser) {
return this.bindSsoIdentity(existingUser, claims.sub, config);
}
if (!config.autoProvision) {
throw new SsoRequiredError();
}
const registrationConfig = await this.instanceConfigRepository.getRegistrationConfig();
if (registrationConfig.mode === 'closed') {
throw new RegistrationClosedError();
}
const pendingApproval = registrationConfig.mode === 'approval';
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
if (pendingApproval) {
throw new RegistrationPendingApprovalError();
}
return user;
}
private async claimSsoIdentity(userId: UserID, sub: string, config: ResolvedSsoConfig): Promise<void> {
const claimed = await this.ssoIdentityRepository.tryClaimIdentity({
providerId: config.providerId,
subject: sub,
userId,
claimedAt: new Date(),
});
if (claimed) {
return;
}
const ownerId = await this.ssoIdentityRepository.findUserId(config.providerId, sub);
if (ownerId?.toString() === userId.toString()) {
return;
}
getLogger().error(
{user_id: userId.toString(), owner_user_id: ownerId?.toString() ?? null},
'SSO identity is already linked to another account',
);
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
private async bindSsoIdentity(user: User, sub: string, config: ResolvedSsoConfig): Promise<User> {
const identityTrait = buildSsoIdentityTrait(config.providerId, sub);
const traits = user.traits;
const existingIdentities = Array.from(traits).filter((trait) => trait.startsWith('sso_identity:'));
if (existingIdentities.length > 0 && !traits.has(identityTrait)) {
getLogger().error({user_id: user.id.toString()}, 'SSO identity claim did not match linked account');
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
await this.claimSsoIdentity(user.id, sub, config);
if (traits.has(identityTrait)) {
return user;
}
traits.add('sso');
traits.add(`sso:${config.providerId}`);
traits.add(buildSsoProviderTrait(config.providerId));
traits.add(identityTrait);
const {users} = this.apiContext.services;
return users.patchUpsert(user.id, {traits}, user.toRow());
}
private async provisionUserFromClaims(
claims: ResolvedSsoClaims,
config: ResolvedSsoConfig,
options?: {
pendingApproval?: boolean;
},
): Promise<User> {
const {users, snowflake} = this.apiContext.services;
const userId = (await snowflake.generate()) as UserID;
const baseName = claims.name?.trim() || claims.email.split('@')[0] || generateRandomUsername();
const username = deriveUsernameFromDisplayName(baseName) ?? generateRandomUsername();
const discriminatorResult = await this.discriminatorService.generateDiscriminator({username});
if (!discriminatorResult.available) {
throw InputValidationError.fromCode('username', ValidationErrorCodes.SSO_UNABLE_TO_ALLOCATE_DISCRIMINATOR);
}
const now = new Date();
const traits = new Set<string>([
'sso',
`sso:${config.providerId}`,
buildSsoProviderTrait(config.providerId),
buildSsoIdentityTrait(config.providerId, claims.sub),
]);
if (options?.pendingApproval) {
traits.add(REGISTRATION_PENDING_APPROVAL_TRAIT);
}
const globalName = claims.name?.substring(0, 256) ?? username;
if (
profileSubstringBlocklistCache.containsBannedSubstring('username', username) ||
profileSubstringBlocklistCache.containsBannedSubstring('global_name', globalName)
) {
throw new ContentBlockedError();
}
const userRow = {
user_id: userId,
username,
discriminator: discriminatorResult.discriminator,
global_name: globalName,
bot: false,
system: false,
email: claims.email.toLowerCase(),
email_verified: claims.emailVerified,
email_bounced: false,
phone: null,
password_hash: null,
password_last_changed_at: null,
totp_secret: null,
authenticator_types: null,
avatar_hash: null,
avatar_color: null,
banner_hash: null,
banner_color: null,
bio: null,
pronouns: null,
accent_color: null,
timezone: null,
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
date_of_birth: null,
locale: null,
flags: 0n,
premium_type: null,
premium_since: null,
premium_until: null,
premium_gift_extension_ends_at: null,
premium_will_cancel: null,
premium_billing_cycle: null,
premium_lifetime_sequence: null,
premium_grace_ends_at: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: false,
suspicious_activity_flags: 0,
terms_agreed_at: now,
privacy_agreed_at: now,
last_active_at: now,
last_active_ip: null,
temp_banned_until: null,
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
pending_deletion_at: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
acls: new Set<string>(),
traits,
first_refund_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
premium_onboarding_dismissed_at: null,
mention_flags: null,
last_voice_activity_sharing_change_at: null,
version: 1,
} as const;
await this.claimSsoIdentity(userId, claims.sub, config);
let createAttempted = false;
let userCreated = false;
try {
if (options?.pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username,
discriminator: discriminatorResult.discriminator,
global_name: globalName,
email: userRow.email,
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
});
}
createAttempted = true;
const user = await users.create(userRow);
userCreated = true;
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
locale: 'en-US',
isAdult: true,
}),
);
void this.kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
getLogger().warn({error, userId: user.id}, 'Failed to update real-time user activity');
});
return user;
} catch (error) {
if (!userCreated) {
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
});
if (options?.pendingApproval && !createAttempted) {
await this.instanceConfigRepository
.removePendingRegistration(userId.toString())
.catch((removeError: unknown) => {
getLogger().error(
{userId: userId.toString(), removeError},
'Failed to withdraw the pending approval of an SSO user that was never created',
);
});
}
}
throw error;
}
}
private async resolveClaims(
tokenResponse: {
id_token?: string;
access_token?: string;
},
config: ResolvedSsoConfig,
expectedNonce: string,
): Promise<ResolvedSsoClaims> {
if (config.isTestProvider) {
const rawEmail = tokenResponse.id_token || tokenResponse.access_token;
if (!rawEmail) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SSO_TEST_CODE_MISSING_EMAIL);
}
const testClaims = parseTestProviderClaims(rawEmail);
validateEmailAgainstAllowlist(testClaims.email, config.allowedEmailDomains);
return testClaims;
}
let claims: JWTPayload | null = null;
if (tokenResponse.id_token) {
if (!config.jwksUrl) {
getLogger().warn('SSO id_token returned but no JWKS URL is configured; ignoring id_token claims');
} else {
claims = await this.verifyIdToken(tokenResponse.id_token, config, expectedNonce);
}
}
let userInfo: Record<string, unknown> | null = null;
if (config.userInfoUrl && tokenResponse.access_token) {
userInfo = await this.fetchUserInfo(config.userInfoUrl, tokenResponse.access_token);
}
const idTokenSub = claims ? readStringClaim(claims, 'sub') : undefined;
const userInfoSub = userInfo ? readStringClaim(userInfo, 'sub') : undefined;
if (claims && userInfo) {
if (!idTokenSub || !userInfoSub || idTokenSub !== userInfoSub) {
getLogger().error('SSO sub mismatch between id_token and userinfo');
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
}
if (!claims && !userInfo) {
throw InputValidationError.fromCode('sso', ValidationErrorCodes.SSO_MISCONFIGURED);
}
const rawSub = idTokenSub ?? userInfoSub;
if (!rawSub) {
throw InputValidationError.fromCode('sub', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
const sub = normalizeSsoSubject(rawSub);
const idTokenEmail = readStringClaim(claims, 'email');
const userInfoEmail = readStringClaim(userInfo, 'email');
const normalizedIdTokenEmail = idTokenEmail ? normalizeSsoEmail(idTokenEmail) : undefined;
const normalizedUserInfoEmail = userInfoEmail ? normalizeSsoEmail(userInfoEmail) : undefined;
if (normalizedIdTokenEmail && normalizedUserInfoEmail && normalizedIdTokenEmail !== normalizedUserInfoEmail) {
getLogger().error('SSO email mismatch between id_token and userinfo');
throw InputValidationError.fromCode('email', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
const email =
normalizedIdTokenEmail ??
normalizedUserInfoEmail ??
(() => {
throw InputValidationError.fromCode('email', ValidationErrorCodes.SSO_PROVIDER_DID_NOT_RETURN_EMAIL);
})();
const primaryClaims = normalizedIdTokenEmail ? claims : userInfo;
const emailVerified = resolveEmailVerified({
claims,
userInfo,
idTokenEmail: normalizedIdTokenEmail,
userInfoEmail: normalizedUserInfoEmail,
email,
});
if (!emailVerified) {
throw InputValidationError.fromCode('email_verified', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
const name = readStringClaim(primaryClaims, 'name') ?? null;
validateEmailAgainstAllowlist(email, config.allowedEmailDomains);
const resolvedClaims: ResolvedSsoClaims = {email, emailVerified, name, sub};
return resolvedClaims;
}
private async verifyIdToken(idToken: string, config: ResolvedSsoConfig, expectedNonce: string): Promise<JWTPayload> {
try {
if (config.jwksUrl) {
const jwks = await this.getOrCreateJwks(config.jwksUrl);
const {payload} = await jwtVerify(idToken, jwks, {
issuer: config.issuerForVerification ?? undefined,
audience: config.clientId ?? undefined,
clockTolerance: 10,
});
const nonce = payload['nonce'];
if (nonce === undefined) {
getLogger().warn('SSO id_token missing required nonce claim');
throw new Error('nonce missing');
}
if (typeof nonce !== 'string' || nonce.length === 0 || nonce !== expectedNonce) {
throw new Error('nonce mismatch');
}
return payload;
}
return decodeJwt(idToken);
} catch (error) {
getLogger().error({error}, 'Failed to verify SSO id_token');
throw InputValidationError.fromCode('id_token', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
}
private async getOrCreateJwks(jwksUrl: string): Promise<RemoteJwkSetResolver> {
await this.assertPublicOutboundUrl(jwksUrl, 'jwks_url');
const now = Date.now();
const cached = this.jwksCache.get(jwksUrl);
if (cached && now - cached.cachedAt < SsoService.JWKS_CACHE_TTL_MS) {
return cached.jwks;
}
const fetchJwks = async (): Promise<JSONWebKeySet> => {
const response = await FetchUtils.sendRequest(
{
url: jwksUrl,
method: 'GET',
headers: {Accept: 'application/json'},
timeout: ms('5 seconds'),
serviceName: 'sso_jwks',
},
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (response.status < 200 || response.status >= 300) {
FetchUtils.discardResponseBody(response.stream, response.status);
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
}
const rawBody = await FetchUtils.streamToStringWithLimit(response.stream, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.ssoJwksBytes,
headers: response.headers,
url: jwksUrl,
description: 'SSO JWKS response',
});
const parsed = parseJsonWithGuard(rawBody, isJsonWebKeySet);
if (!parsed) {
throw new Error('SSO JWKS response was not a JSON Web Key Set');
}
return parsed;
};
let currentJwks = await fetchJwks();
let currentResolver = createLocalJWKSet(currentJwks);
let fetchedAt = now;
let pendingReload: Promise<void> | null = null;
let jwks!: RemoteJwkSetResolver;
const reload = async (): Promise<void> => {
pendingReload ??= (async () => {
currentJwks = await fetchJwks();
currentResolver = createLocalJWKSet(currentJwks);
fetchedAt = Date.now();
this.jwksCache.set(jwksUrl, {jwks, cachedAt: fetchedAt});
})().finally(() => {
pendingReload = null;
});
await pendingReload;
};
jwks = (async (protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput) => {
if (Date.now() - fetchedAt >= SsoService.JWKS_CACHE_TTL_MS) {
await reload();
}
try {
return await currentResolver(protectedHeader, token);
} catch (error) {
if (error instanceof JoseErrors.JWKSNoMatchingKey) {
await reload();
return currentResolver(protectedHeader, token);
}
throw error;
}
}) as RemoteJwkSetResolver;
Object.defineProperties(jwks, {
coolingDown: {
get: () => false,
enumerable: true,
configurable: false,
},
fresh: {
get: () => Date.now() - fetchedAt < SsoService.JWKS_CACHE_TTL_MS,
enumerable: true,
configurable: false,
},
reloading: {
get: () => pendingReload != null,
enumerable: true,
configurable: false,
},
reload: {
value: reload,
enumerable: true,
configurable: false,
writable: false,
},
jwks: {
value: () => currentJwks,
enumerable: true,
configurable: false,
writable: false,
},
});
this.jwksCache.set(jwksUrl, {jwks, cachedAt: now});
if (this.jwksCache.size > 10) {
for (const [url, entry] of this.jwksCache.entries()) {
if (now - entry.cachedAt >= SsoService.JWKS_CACHE_TTL_MS) {
this.jwksCache.delete(url);
}
}
}
return jwks;
}
private async fetchUserInfo(userInfoUrl: string, accessToken: string): Promise<Record<string, unknown>> {
const resp = await FetchUtils.sendRequest(
{
url: userInfoUrl,
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
timeout: ms('15 seconds'),
serviceName: 'sso_user_info',
},
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (resp.status < 200 || resp.status >= 300) {
FetchUtils.discardResponseBody(resp.stream, resp.status);
throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_FETCH_SSO_USER_INFO);
}
try {
const rawBody = await FetchUtils.streamToStringWithLimit(resp.stream, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.ssoUserInfoBytes,
headers: resp.headers,
url: userInfoUrl,
description: 'SSO user info response',
});
const parsed = parseJsonRecord(rawBody);
if (!parsed) {
throw new Error('SSO user info response was not a JSON object');
}
return parsed;
} catch {
throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_PARSE_SSO_USER_INFO);
}
}
private async exchangeCode({
code,
codeVerifier,
redirectUri,
config,
}: {
code: string;
codeVerifier: string;
redirectUri: string;
config: ResolvedSsoConfig;
}): Promise<{
id_token?: string;
access_token?: string;
}> {
if (config.isTestProvider) {
return {id_token: code};
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: config.clientId ?? '',
code_verifier: codeVerifier,
});
if (config.clientSecret) {
body.set('client_secret', config.clientSecret);
}
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
};
const resp = await FetchUtils.sendRequest(
{
url: config.tokenUrl ?? '',
method: 'POST',
headers,
body,
timeout: ms('15 seconds'),
serviceName: 'sso_token_exchange',
},
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (resp.status < 200 || resp.status >= 300) {
FetchUtils.discardResponseBody(resp.stream, resp.status);
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_SSO_AUTHORIZATION_CODE);
}
const rawBody = await FetchUtils.streamToStringWithLimit(resp.stream, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.ssoTokenBytes,
headers: resp.headers,
url: config.tokenUrl ?? '',
description: 'SSO token response',
});
return parseTokenEndpointResponse(resp.headers.get('content-type'), rawBody);
}
private async getResolvedConfig(): Promise<ResolvedSsoConfig> {
const {config} = this.apiContext.services;
const stored = await this.instanceConfigRepository.getSsoConfig({includeSecret: true});
const redirectUri = deriveSsoRedirectUri(config.endpoints.webApp);
const scope = stored.scope?.trim() || 'openid email profile';
const providerId = stored.issuer || stored.authorizationUrl || 'sso';
let authorizationUrl = stored.authorizationUrl;
let tokenUrl = stored.tokenUrl;
let userInfoUrl = stored.userInfoUrl;
let jwksUrl = stored.jwksUrl;
let issuerForVerification = stored.issuer;
const isTestProvider = isTestSsoProvider(
{
authorizationUrl,
tokenUrl,
},
config.dev.testModeEnabled,
);
const validatedIssuer =
stored.issuer && !isTestProvider
? await this.validateOptionalPublicOutboundUrl(stored.issuer, 'issuer')
: stored.issuer;
if (validatedIssuer) {
const cacheKey = buildDiscoveryCacheKey(validatedIssuer);
const {cache} = this.apiContext.services;
let discovered = await cache.get<{
issuer: string;
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
jwks_uri?: string;
}>(cacheKey);
if (!discovered && (!authorizationUrl || !tokenUrl || !jwksUrl || !userInfoUrl)) {
discovered = await tryDiscoverOidcProviderMetadata(validatedIssuer);
if (discovered) {
await cache.set(cacheKey, discovered, SsoService.DISCOVERY_TTL_SECONDS);
}
}
authorizationUrl = authorizationUrl ?? discovered?.authorization_endpoint ?? null;
tokenUrl = tokenUrl ?? discovered?.token_endpoint ?? null;
userInfoUrl = userInfoUrl ?? discovered?.userinfo_endpoint ?? null;
jwksUrl = jwksUrl ?? discovered?.jwks_uri ?? null;
issuerForVerification = discovered?.issuer ?? validatedIssuer;
}
if (!isTestProvider) {
authorizationUrl = await this.validateOptionalPublicOutboundUrl(authorizationUrl, 'authorization_url');
tokenUrl = await this.validateOptionalPublicOutboundUrl(tokenUrl, 'token_url');
userInfoUrl = await this.validateOptionalPublicOutboundUrl(userInfoUrl, 'user_info_url');
jwksUrl = await this.validateOptionalPublicOutboundUrl(jwksUrl, 'jwks_url');
}
const canResolveClaims = isTestProvider || Boolean(jwksUrl) || Boolean(userInfoUrl);
const ready =
stored.enabled && Boolean(authorizationUrl) && Boolean(tokenUrl) && Boolean(stored.clientId) && canResolveClaims;
return {
...stored,
authorizationUrl,
tokenUrl,
userInfoUrl,
jwksUrl,
redirectUri,
scope,
ready,
providerId,
isTestProvider,
issuerForVerification,
};
}
private async assertPublicOutboundUrl(rawUrl: string, fieldName: string): Promise<string> {
return validateSsoPublicOutboundUrl(rawUrl, fieldName);
}
private async validateOptionalPublicOutboundUrl(rawUrl: string | null, fieldName: string): Promise<string | null> {
if (!rawUrl) {
return null;
}
try {
return await this.assertPublicOutboundUrl(rawUrl, fieldName);
} catch (error) {
getLogger().warn(
{fieldName, rawUrl: formatUrlForDiagnostics(rawUrl), error},
'Ignoring SSO URL that failed outbound policy validation',
);
return null;
}
}
private async requireReadyConfig(): Promise<ResolvedSsoConfig> {
const config = await this.getResolvedConfig();
if (!config.ready) {
throw new FeatureTemporarilyDisabledError();
}
return config;
}
}