import { Issuer, BaseClient, type UserinfoResponse, TokenSet, custom } from "openid-client"; import { addHours, addWeeks } from "date-fns"; import { COOKIE_NAME, OPENID_CLIENT_ID, OPENID_CLIENT_SECRET, OPENID_PROVIDER_URL, OPENID_SCOPES, OPENID_NAME_CLAIM, OPENID_TOLERANCE, OPENID_RESOURCE, OPENID_CONFIG, ALLOW_INSECURE_COOKIES, } from "$env/static/private"; import { sha256 } from "$lib/utils/sha256"; import { z } from "zod"; import { dev } from "$app/environment"; import type { Cookies } from "@sveltejs/kit"; import { collections } from "./database"; import JSON5 from "json5"; export interface OIDCSettings { redirectURI: string; } export interface OIDCUserInfo { token: TokenSet; userData: UserinfoResponse; } const stringWithDefault = (value: string) => z .string() .default(value) .transform((el) => (el ? el : value)); export const OIDConfig = z .object({ CLIENT_ID: stringWithDefault(OPENID_CLIENT_ID), CLIENT_SECRET: stringWithDefault(OPENID_CLIENT_SECRET), PROVIDER_URL: stringWithDefault(OPENID_PROVIDER_URL), SCOPES: stringWithDefault(OPENID_SCOPES), NAME_CLAIM: stringWithDefault(OPENID_NAME_CLAIM).refine( (el) => !["preferred_username", "email", "picture", "sub"].includes(el), { message: "nameClaim cannot be one of the restricted keys." } ), TOLERANCE: stringWithDefault(OPENID_TOLERANCE), RESOURCE: stringWithDefault(OPENID_RESOURCE), }) .parse(JSON5.parse(OPENID_CONFIG)); export const requiresUser = !!OIDConfig.CLIENT_ID && !!OIDConfig.CLIENT_SECRET; export function refreshSessionCookie(cookies: Cookies, sessionId: string) { cookies.set(COOKIE_NAME, sessionId, { path: "/", // So that it works inside the space's iframe sameSite: dev || ALLOW_INSECURE_COOKIES === "true" ? "lax" : "none", secure: !dev && !(ALLOW_INSECURE_COOKIES === "true"), httpOnly: true, expires: addWeeks(new Date(), 2), }); } export async function findUser(sessionId: string) { const session = await collections.sessions.findOne({ sessionId }); if (!session) { return null; } return await collections.users.findOne({ _id: session.userId }); } export const authCondition = (locals: App.Locals) => { return locals.user ? { userId: locals.user._id } : { sessionId: locals.sessionId, userId: { $exists: false } }; }; /** * Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough. */ export async function generateCsrfToken(sessionId: string, redirectUrl: string): Promise { const data = { expiration: addHours(new Date(), 1).getTime(), redirectUrl, }; return Buffer.from( JSON.stringify({ data, signature: await sha256(JSON.stringify(data) + "##" + sessionId), }) ).toString("base64"); } async function getOIDCClient(settings: OIDCSettings): Promise { const issuer = await Issuer.discover(OIDConfig.PROVIDER_URL); return new issuer.Client({ client_id: OIDConfig.CLIENT_ID, client_secret: OIDConfig.CLIENT_SECRET, redirect_uris: [settings.redirectURI], response_types: ["code"], [custom.clock_tolerance]: OIDConfig.TOLERANCE || undefined, }); } export async function getOIDCAuthorizationUrl( settings: OIDCSettings, params: { sessionId: string } ): Promise { const client = await getOIDCClient(settings); const csrfToken = await generateCsrfToken(params.sessionId, settings.redirectURI); return client.authorizationUrl({ scope: OIDConfig.SCOPES, state: csrfToken, resource: OIDConfig.RESOURCE || undefined, }); } export async function getOIDCUserData(settings: OIDCSettings, code: string): Promise { const client = await getOIDCClient(settings); const token = await client.callback(settings.redirectURI, { code }); const userData = await client.userinfo(token); return { token, userData }; } export async function validateAndParseCsrfToken( token: string, sessionId: string ): Promise<{ /** This is the redirect url that was passed to the OIDC provider */ redirectUrl: string; } | null> { try { const { data, signature } = z .object({ data: z.object({ expiration: z.number().int(), redirectUrl: z.string().url(), }), signature: z.string().length(64), }) .parse(JSON.parse(token)); const reconstructSign = await sha256(JSON.stringify(data) + "##" + sessionId); if (data.expiration > Date.now() && signature === reconstructSign) { return { redirectUrl: data.redirectUrl }; } } catch (e) { console.error(e); } return null; }