Files
Musify/web-player/src/lib/server/auth.ts
T
AdminandClaude Sonnet 5 ab51d9256e Fix permanent login failure after Zitadel restarts
getOidcConfig() memoized the discovery promise even when it rejected,
so a single failed OIDC discovery (e.g. Zitadel not ready yet when the
dev server starts) permanently broke /auth/login until the process was
restarted. Clear the cached promise on rejection so it retries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:29:58 +02:00

113 lines
3.5 KiB
TypeScript

import * as client from 'openid-client';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { dev } from '$app/environment';
import { authConfig } from '$lib/server/config';
import type { SessionUser } from '$lib/types';
export const SESSION_COOKIE = 'mf_session';
export const STATE_COOKIE = 'mf_oidc_state';
export const VERIFIER_COOKIE = 'mf_oidc_verifier';
export const NONCE_COOKIE = 'mf_oidc_nonce';
export const RETURN_COOKIE = 'mf_oidc_return';
export interface Session extends SessionUser {
accessToken: string;
refreshToken: string;
idToken: string;
expiresAt: number;
}
let configPromise: Promise<client.Configuration> | undefined;
export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => {
const issuer = new URL(authConfig.issuer);
const insecureAllowed = (dev || authConfig.allowInsecureHttp) && issuer.protocol === 'http:';
const options = insecureAllowed ? { execute: [client.allowInsecureRequests] } : undefined;
const secret = authConfig.clientSecret;
return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})().catch((exception) => {
configPromise = undefined;
throw exception;
});
return configPromise;
}
let keyPromise: Promise<Uint8Array>;
function sessionKey(): Promise<Uint8Array> {
keyPromise ??= crypto.subtle
.digest('SHA-256', new TextEncoder().encode(authConfig.sessionSecret))
.then((digest) => new Uint8Array(digest));
return keyPromise;
}
export function sessionCookieOptions(url: URL) {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: url.protocol === 'https:',
path: '/',
maxAge: authConfig.sessionTtlSeconds
};
}
export async function encodeSession(session: Session): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new EncryptJWT({ ...session })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt(now)
.setExpirationTime(now + authConfig.sessionTtlSeconds)
.encrypt(await sessionKey());
}
export async function decodeSession(token: string | undefined): Promise<Session | null> {
if (!token) return null;
try {
const { payload } = await jwtDecrypt(token, await sessionKey());
return payload as unknown as Session;
} catch {
return null;
}
}
function claimString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function toSessionUser(
claims: Record<string, unknown>,
fallback?: SessionUser
): SessionUser {
const sub = claimString(claims.sub) ?? fallback?.sub ?? '';
return {
sub,
name:
claimString(claims.name) ??
claimString(claims.preferred_username) ??
fallback?.name ??
claimString(claims.email) ??
sub,
email: claimString(claims.email) ?? fallback?.email ?? '',
picture: claimString(claims.picture) ?? fallback?.picture ?? ''
};
}
export async function refreshSession(session: Session): Promise<Session | null> {
try {
const config = await getOidcConfig();
const tokens = await client.refreshTokenGrant(config, session.refreshToken);
return {
...toSessionUser(tokens.claims() ?? {}, session),
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? session.refreshToken,
idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
} catch (exception) {
console.error('Token refresh failed', exception);
return null;
}
}