From 4dcb64dbb049dfe849356e00cb42ad385c71603f Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sat, 4 Apr 2026 23:48:17 +0200 Subject: [PATCH 01/11] feat: Replace Zitadel with Logto as OIDC identity provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adapt OIDC token validation to handle Logto's opaque and JWT access tokens - Add claim normalization for Logto's different field names (username, name) - Extract roles from JWT access tokens via Custom JWT claims - Support OIDC_RESOURCE config for Logto API resource (JWT access tokens) - Make OIDCUser fields (family_name, given_name, preferred_username) optional - Update upsertSelf to use ctx.oidc.user instead of userinfo endpoint - Handle Logto role objects ({name, id, ...}) in addition to string arrays - Remove Zitadel-specific token introspection, token exchange types, and error messages - Add Mailpit SMTP credentials for Logto email connector - Update mock OIDC landing page with new roles claim format - Add migration scripts for Zitadel→Logto user ID remapping Co-Authored-By: Claude Opus 4.6 (1M context) --- dev.docker-compose.yml | 4 +- mock-oidc-landingpage.html | 4 +- prisma/seed/createMissingLogtoUsers.ts | 102 +++++++++++++++ prisma/seed/migrateOidcSubs.ts | 101 +++++++++++++++ schema.graphql | 6 +- src/api/context/oidc.ts | 25 +++- src/api/resolvers/modules/auth.ts | 6 +- src/api/resolvers/modules/user.ts | 34 ++--- src/api/services/OIDC.ts | 169 ++++++++++++++----------- src/config/private.ts | 1 + 10 files changed, 344 insertions(+), 108 deletions(-) create mode 100644 prisma/seed/createMissingLogtoUsers.ts create mode 100644 prisma/seed/migrateOidcSubs.ts diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index e3e20e42..85e3792d 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -29,7 +29,7 @@ services: # "given_name": "Delegator Jr.", # "preferred_username": "delegatoruser_123", # "locale": "de", - # "urn:zitadel:iam:org:project:275671427955294244:roles": {"admin": {}} + # "roles": ["admin"] # } mockoidc: image: ghcr.io/navikt/mock-oauth2-server:2.1.10 @@ -143,7 +143,7 @@ services: - '1025:1025' # SMTP server - '8025:8025' # Web UI environment: - MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH: 'mailpit:mailpit' MP_SMTP_AUTH_ALLOW_INSECURE: 1 # Bugsink - Self-hosted error tracking (Sentry-compatible) diff --git a/mock-oidc-landingpage.html b/mock-oidc-landingpage.html index 4eafa519..8d8355f7 100644 --- a/mock-oidc-landingpage.html +++ b/mock-oidc-landingpage.html @@ -30,9 +30,7 @@ given_name: faker.person.firstName(), preferred_username: faker.internet.userName(), locale: 'de', - 'urn:zitadel:iam:org:project:275671427955294244:roles': { - admin: {} - } + roles: ['admin'] } }, { diff --git a/prisma/seed/createMissingLogtoUsers.ts b/prisma/seed/createMissingLogtoUsers.ts new file mode 100644 index 00000000..93a39c3c --- /dev/null +++ b/prisma/seed/createMissingLogtoUsers.ts @@ -0,0 +1,102 @@ +import { PrismaClient } from '@prisma/client'; + +const LOGTO_ENDPOINT = process.env.LOGTO_ENDPOINT || 'http://localhost:3001'; +const LOGTO_TOKEN = process.env.LOGTO_TOKEN; + +if (!LOGTO_TOKEN) { + console.error( + 'Error: LOGTO_TOKEN not set. Get one from the Logto admin console (http://localhost:3002).' + ); + process.exit(1); +} + +const db = new PrismaClient(); + +const ZITADEL_IDS = ['287932620396822530', '274935677744381955', '280288225489059842']; + +async function main() { + const users = await db.user.findMany({ + where: { id: { in: ZITADEL_IDS } }, + select: { id: true, email: true, given_name: true, family_name: true, preferred_username: true } + }); + + console.log(`Found ${users.length} users to create in Logto\n`); + + // Discover all FK columns referencing User.id + const fks = await db.$queryRaw<{ table_name: string; column_name: string }[]>` + SELECT kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = 'User' AND ccu.column_name = 'id' + `; + + for (const user of users) { + // 1. Create user in Logto + const response = await fetch(`${LOGTO_ENDPOINT}/api/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${LOGTO_TOKEN}` + }, + body: JSON.stringify({ + primaryEmail: user.email, + username: user.preferred_username.replace(/[@.]/g, '_'), + name: `${user.given_name} ${user.family_name}`, + customData: { + zitadelId: user.id, + migratedAt: new Date().toISOString(), + firstName: user.given_name, + lastName: user.family_name + } + }) + }); + + const body = await response.json(); + + if (!response.ok) { + console.error(` FAIL ${user.email}:`, body.message || JSON.stringify(body)); + continue; + } + + const logtoId = body.id; + console.log(` Created ${user.email} in Logto: ${logtoId}`); + + // 2. Migrate the sub in the DB + const existingUser = await db.user.findUnique({ where: { id: user.id } }); + if (!existingUser) continue; + + await db.$transaction(async (tx) => { + const { id: _, ...userData } = existingUser; + + await tx.user.create({ + data: { id: logtoId, ...userData, email: `__migrating__${userData.email}` } + }); + + for (const fk of fks) { + await tx.$executeRawUnsafe( + `UPDATE "${fk.table_name}" SET "${fk.column_name}" = $1 WHERE "${fk.column_name}" = $2`, + logtoId, + user.id + ); + } + + await tx.user.delete({ where: { id: user.id } }); + await tx.user.update({ where: { id: logtoId }, data: { email: userData.email } }); + }); + + console.log(` Migrated ${user.email}: ${user.id} → ${logtoId}`); + } + + console.log('\nDone.'); +} + +main() + .catch((e) => { + console.error('Failed:', e); + process.exit(1); + }) + .finally(() => db.$disconnect()); diff --git a/prisma/seed/migrateOidcSubs.ts b/prisma/seed/migrateOidcSubs.ts new file mode 100644 index 00000000..81bd2f92 --- /dev/null +++ b/prisma/seed/migrateOidcSubs.ts @@ -0,0 +1,101 @@ +import { PrismaClient } from '@prisma/client'; +import { z } from 'zod'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const IdMappingSchema = z.array( + z.object({ + zitadel_id: z.string().min(1), + logto_id: z.string().min(1), + identifier: z.string().email() + }) +); + +const filePath = process.argv[2]; +if (!filePath) { + console.error('Usage: bun prisma/seed/migrateOidcSubs.ts '); + process.exit(1); +} + +const raw = JSON.parse(readFileSync(resolve(filePath), 'utf-8')); +const mappings = IdMappingSchema.parse(raw); + +console.log(`Parsed ${mappings.length} ID mappings`); + +const db = new PrismaClient(); + +async function migrate() { + // Dynamically discover all FK columns referencing User.id + const fks = await db.$queryRaw<{ table_name: string; column_name: string }[]>` + SELECT + kcu.table_name, + kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = 'User' + AND ccu.column_name = 'id' + `; + + console.log( + `Found ${fks.length} FK columns referencing User.id:`, + fks.map((fk) => `${fk.table_name}.${fk.column_name}`) + ); + + let updated = 0; + let skipped = 0; + + for (const mapping of mappings) { + const existingUser = await db.user.findUnique({ + where: { id: mapping.zitadel_id } + }); + + if (!existingUser) { + console.log(` SKIP ${mapping.identifier} — no user with zitadel_id ${mapping.zitadel_id}`); + skipped++; + continue; + } + + await db.$transaction(async (tx) => { + const { id: _, ...userData } = existingUser; + + // 1. Create new user with temporary email to avoid unique constraint + await tx.user.create({ + data: { id: mapping.logto_id, ...userData, email: `__migrating__${userData.email}` } + }); + + // 2. Move all FK references to the new user ID + for (const fk of fks) { + await tx.$executeRawUnsafe( + `UPDATE "${fk.table_name}" SET "${fk.column_name}" = $1 WHERE "${fk.column_name}" = $2`, + mapping.logto_id, + mapping.zitadel_id + ); + } + + // 3. Delete the old user + await tx.user.delete({ where: { id: mapping.zitadel_id } }); + + // 4. Restore the real email + await tx.user.update({ + where: { id: mapping.logto_id }, + data: { email: userData.email } + }); + }); + + console.log(` OK ${mapping.identifier}: ${mapping.zitadel_id} → ${mapping.logto_id}`); + updated++; + } + + console.log(`\nDone. Updated: ${updated}, Skipped: ${skipped}`); +} + +migrate() + .catch((e) => { + console.error('Migration failed:', e); + process.exit(1); + }) + .finally(() => db.$disconnect()); diff --git a/schema.graphql b/schema.graphql index cc4d0cc3..c002e201 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3823,11 +3823,11 @@ enum OIDCRolesEnum { type OfflineUser { email: String! - family_name: String! - given_name: String! + family_name: String + given_name: String locale: String phone: String - preferred_username: String! + preferred_username: String sub: String! } diff --git a/src/api/context/oidc.ts b/src/api/context/oidc.ts index 364e87d0..d474f5a7 100644 --- a/src/api/context/oidc.ts +++ b/src/api/context/oidc.ts @@ -118,7 +118,18 @@ export async function oidc(cookies: RequestEvent['cookies']) { if (user && configPrivate.OIDC_ROLE_CLAIM) { const rolesRaw = user[configPrivate.OIDC_ROLE_CLAIM]!; - if (rolesRaw) { + if (Array.isArray(rolesRaw)) { + for (const role of rolesRaw) { + if (typeof role === 'string') { + // Simple string array (e.g. ["admin"]) + OIDCRoleNames.push(role as any); + } else if (role && typeof role === 'object' && 'name' in role) { + // Logto returns role objects (e.g. [{name: "admin", ...}]) + OIDCRoleNames.push(role.name as any); + } + } + } else if (rolesRaw && typeof rolesRaw === 'object') { + // Zitadel returned roles as an object with role names as keys const roleNames = Object.keys(rolesRaw); OIDCRoleNames.push(...(roleNames as any)); } @@ -149,7 +160,7 @@ export async function oidc(cookies: RequestEvent['cookies']) { // Security: verify the impersonation token was actually issued for the currently authenticated actor (original user) const actorSub = actorInfo && typeof actorInfo === 'object' - ? actorInfo.sub || actorInfo.subject || (actorInfo as any)['urn:zitadel:act:sub'] + ? actorInfo.sub || actorInfo.subject : undefined; if (!actorSub) { @@ -200,7 +211,15 @@ export async function oidc(cookies: RequestEvent['cookies']) { const impersonatedOIDCRoleNames: (typeof oidcRoles)[number][] = []; if (impersonatedUser && configPrivate.OIDC_ROLE_CLAIM) { const impersonatedRolesRaw = impersonatedUser[configPrivate.OIDC_ROLE_CLAIM]!; - if (impersonatedRolesRaw) { + if (Array.isArray(impersonatedRolesRaw)) { + for (const role of impersonatedRolesRaw) { + if (typeof role === 'string') { + impersonatedOIDCRoleNames.push(role as any); + } else if (role && typeof role === 'object' && 'name' in role) { + impersonatedOIDCRoleNames.push(role.name as any); + } + } + } else if (impersonatedRolesRaw && typeof impersonatedRolesRaw === 'object') { const impersonatedRoleNames = Object.keys(impersonatedRolesRaw); impersonatedOIDCRoleNames.push(...(impersonatedRoleNames as any)); } diff --git a/src/api/resolvers/modules/auth.ts b/src/api/resolvers/modules/auth.ts index 307f4e3f..f9edf9e3 100644 --- a/src/api/resolvers/modules/auth.ts +++ b/src/api/resolvers/modules/auth.ts @@ -37,9 +37,9 @@ builder.queryFields((t) => { fields: (t) => ({ sub: t.string(), email: t.string(), - preferred_username: t.string(), - family_name: t.string(), - given_name: t.string(), + preferred_username: t.string({ nullable: true }), + family_name: t.string({ nullable: true }), + given_name: t.string({ nullable: true }), locale: t.string({ nullable: true }), phone: t.string({ nullable: true }) }) diff --git a/src/api/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index a9725b01..29fc29d1 100644 --- a/src/api/resolvers/modules/user.ts +++ b/src/api/resolvers/modules/user.ts @@ -28,7 +28,6 @@ import { UserGlobalNotesFieldObject, UserEmergencyContactsFieldObject } from '$db/generated/graphql/User'; -import { fetchUserInfoFromIssuer } from '$api/services/OIDC'; import { db } from '$db/db'; import { configPublic } from '$config/public'; import { userFormSchema } from '../../../routes/(authenticated)/my-account/form-schema'; @@ -408,21 +407,12 @@ builder.mutationFields((t) => { }), resolve: async (root, args, ctx) => { const user = ctx.permissions.getLoggedInUserOrThrow(); - if (ctx.oidc.tokenSet?.access_token === undefined) { - throw new GraphQLError('No access token provided'); - } - const issuerUserData = await fetchUserInfoFromIssuer( - ctx.oidc.tokenSet?.access_token, - user.sub - ); - - if ( - !issuerUserData.email || - !issuerUserData.family_name || - !issuerUserData.given_name || - !issuerUserData.preferred_username - ) { - throw new GraphQLError('OIDC result is missing required fields!'); + + // Use the already-validated user data from the OIDC context + // (fetching from userinfo endpoint fails when access tokens are JWTs scoped to an API resource) + const issuerUserData = ctx.oidc.user; + if (!issuerUserData?.email) { + throw new GraphQLError('OIDC result is missing required field: email'); } try { @@ -431,15 +421,19 @@ builder.mutationFields((t) => { create: { id: issuerUserData.sub, email: issuerUserData.email, - family_name: issuerUserData.family_name, - given_name: issuerUserData.given_name, - preferred_username: issuerUserData.preferred_username, + family_name: issuerUserData.family_name ?? '', + given_name: issuerUserData.given_name ?? '', + preferred_username: issuerUserData.preferred_username ?? issuerUserData.email, locale: issuerUserData.locale ?? configPublic.PUBLIC_DEFAULT_LOCALE, phone: issuerUserData.phone ?? user.phone }, update: { email: issuerUserData.email, - preferred_username: issuerUserData.preferred_username, + ...(issuerUserData.family_name && { family_name: issuerUserData.family_name }), + ...(issuerUserData.given_name && { given_name: issuerUserData.given_name }), + ...(issuerUserData.preferred_username && { + preferred_username: issuerUserData.preferred_username + }), locale: issuerUserData.locale ?? configPublic.PUBLIC_DEFAULT_LOCALE, phone: issuerUserData.phone ?? user.phone } diff --git a/src/api/services/OIDC.ts b/src/api/services/OIDC.ts index 011797f8..d0ca344b 100644 --- a/src/api/services/OIDC.ts +++ b/src/api/services/OIDC.ts @@ -13,19 +13,18 @@ import { randomPKCECodeVerifier, randomState, refreshTokenGrant, - tokenIntrospection, type TokenEndpointResponse } from 'openid-client'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; export const oidcRoles = ['admin', 'member', 'service_user'] as const; export type OIDCUser = { sub: string; email: string; - preferred_username: string; - family_name: string; - given_name: string; + preferred_username?: string; + family_name?: string; + given_name?: string; // non checked fields locale?: string; @@ -40,7 +39,34 @@ type OIDCFlowState = { }; export function isValidOIDCUser(user: any): user is OIDCUser { - return user.sub && user.email && user.preferred_username && user.family_name && user.given_name; + return !!user.sub && !!user.email; +} + +/** + * Normalize OIDC claims from different providers into a consistent OIDCUser shape. + * Logto uses `username` instead of `preferred_username` and `name` instead of `family_name`/`given_name`. + */ +function normalizeOIDCClaims(claims: Record): Record { + const normalized = { ...claims }; + + // Logto: username → preferred_username + if (!normalized.preferred_username && normalized.username) { + normalized.preferred_username = normalized.username; + } + + // Logto: name → family_name + given_name (split on last space) + if ((!normalized.family_name || !normalized.given_name) && normalized.name) { + const parts = normalized.name.trim().split(/\s+/); + if (parts.length >= 2) { + normalized.given_name = parts.slice(0, -1).join(' '); + normalized.family_name = parts[parts.length - 1]; + } else { + normalized.given_name = normalized.name; + normalized.family_name = normalized.name; + } + } + + return normalized; } export const codeVerifierCookieName = 'code_verifier'; @@ -101,7 +127,8 @@ export async function startSignin(visitedUrl: URL) { scope: configPrivate.OIDC_SCOPES, code_challenge, code_challenge_method: 'S256', - state: serialized_state + state: serialized_state, + ...(configPrivate.OIDC_RESOURCE ? { resource: configPrivate.OIDC_RESOURCE } : {}) }; const redirect_uri = buildAuthorizationUrl(config, parameters); @@ -124,68 +151,85 @@ export async function resolveSignin( } const verifier = cryptr.decrypt(encrypted_verifier); const state = JSON.parse(cryptr.decrypt(encrypted_state)) as OIDCFlowState; - const tokens = await authorizationCodeGrant(config, visitedUrl, { - pkceCodeVerifier: verifier, - expectedState: JSON.stringify(state) - }); + const tokens = await authorizationCodeGrant( + config, + visitedUrl, + { + pkceCodeVerifier: verifier, + expectedState: JSON.stringify(state) + }, + configPrivate.OIDC_RESOURCE ? { resource: configPrivate.OIDC_RESOURCE } : undefined + ); (state as any).random = undefined; const strippedState: Omit = { ...state }; return { tokens, state: strippedState }; } +/** + * Try to decode custom claims from the access token (JWT). + * Logto injects Custom JWT claims (e.g. roles) into the access token, not the id_token. + * Returns the decoded payload or an empty object if the token is opaque. + */ +function decodeAccessTokenClaims(access_token: string): Record { + try { + return decodeJwt(access_token); + } catch { + return {}; + } +} + export async function validateTokens({ access_token, id_token }: Pick): Promise { - try { - if (!jwks) throw new Error('No jwks available'); - if (!id_token) throw new Error('No id_token available'); + let sub: string | undefined; + const accessTokenClaims = decodeAccessTokenClaims(access_token); - const [accessTokenValue, idTokenValue] = await Promise.all([ - jwtVerify(access_token, jwks, { - issuer: config.serverMetadata().issuer, - audience: configPublic.PUBLIC_OIDC_CLIENT_ID - }), - jwtVerify(id_token, jwks, { + // Try local JWT verification of the id_token first + if (jwks && id_token) { + try { + const idTokenValue = await jwtVerify(id_token, jwks, { issuer: config.serverMetadata().issuer, audience: configPublic.PUBLIC_OIDC_CLIENT_ID - }) - ]); - - if (!accessTokenValue.payload.sub) { - throw new Error('No subject in access token'); - } + }); - if (!idTokenValue.payload.sub) { - throw new Error('No subject in id token'); - } + // Merge access token claims (e.g. roles) into the id_token payload + const normalizedPayload = normalizeOIDCClaims({ + ...idTokenValue.payload, + ...accessTokenClaims, + // Preserve id_token's sub/aud/iss over access token's + sub: idTokenValue.payload.sub, + aud: idTokenValue.payload.aud, + iss: idTokenValue.payload.iss + }); + sub = normalizedPayload.sub; - if (accessTokenValue.payload.sub !== idTokenValue.payload.sub) { - throw new Error('Subject in access token and id token do not match'); - } + if (isValidOIDCUser(normalizedPayload)) { + return normalizedPayload; + } - // some basic fields which we want to be present - // if the id token is configured in a way that it does not contain these fields - // we instead want to use the userinfo endpoint - if (!isValidOIDCUser(idTokenValue.payload)) { - throw new Error('Not all fields in id token are present'); + console.debug( + '[OIDC] id_token verified but missing profile fields, falling back to userinfo' + ); + } catch (error: unknown) { + console.debug( + `[OIDC] Local id_token verification failed (${error instanceof Error ? error.message : 'unknown'}), trying userinfo endpoint` + ); } + } - return idTokenValue.payload; - } catch (error: any) { - console.debug( - `[OIDC] Local token verification failed (${error.message}), trying remote introspection` - ); - - const remoteUserInfo = await tokenIntrospection(config, access_token); - - if (!isValidOIDCUser(remoteUserInfo)) { - throw new Error('Not all fields in remoteUserInfo token are present'); - } + // Fallback: fetch user info from the provider's userinfo endpoint + const remoteUserInfo = normalizeOIDCClaims({ + ...(await fetchUserInfo(config, access_token, sub ?? access_token)), + ...accessTokenClaims + }); - return remoteUserInfo; + if (!isValidOIDCUser(remoteUserInfo)) { + throw new Error('Not all required fields returned from userinfo endpoint'); } + + return remoteUserInfo as OIDCUser; } export function refresh(refresh_token: string) { @@ -201,20 +245,6 @@ export function getLogoutUrl(visitedUrl: URL) { }); } -/** - * Retrieve user information for an access token from the issuer. - * - * @param access_token - The access token presented to the issuer's userinfo endpoint - * @param expectedSubject - The expected `sub` (subject) to validate against the issuer's response - * @returns The user info object returned by the issuer - */ -export function fetchUserInfoFromIssuer( - access_token: string, - expectedSubject: string -): Promise { - return fetchUserInfo(config, access_token, expectedSubject) as Promise; -} - /** * Perform an OAuth 2.0 Token Exchange to obtain a JWT for acting as a specified subject. * @@ -256,7 +286,7 @@ export async function performTokenExchange( const tokenExchangeParams: Record = { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: subjectUserId, - subject_token_type: 'urn:zitadel:params:oauth:token-type:user_id', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', actor_token: actorToken, actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', requested_token_type: 'urn:ietf:params:oauth:token-type:jwt' @@ -308,16 +338,7 @@ export async function performTokenExchange( errorDetail?.error_description?.includes('token-exchange') ) { throw new Error( - `OIDC Client not configured for Token Exchange. Please add the grant type 'urn:ietf:params:oauth:grant-type:token-exchange' to your Zitadel OIDC application configuration.` - ); - } - - if ( - errorDetail?.error === 'invalid_request' && - errorDetail?.error_description?.includes('No matching permissions found') - ) { - throw new Error( - `Impersonation not allowed. The user lacks permission to impersonate the target user. Please check:\n1. User has 'ORG_END_USER_IMPERSONATOR' role\n2. User has 'ORG_USER_SELF_MANAGEMENT' role or project-specific impersonation permissions\n3. Target user is in the same organization/project scope` + `OIDC Client not configured for Token Exchange. Please add the grant type 'urn:ietf:params:oauth:grant-type:token-exchange' to your OIDC application configuration.` ); } diff --git a/src/config/private.ts b/src/config/private.ts index a88113eb..9b628d47 100644 --- a/src/config/private.ts +++ b/src/config/private.ts @@ -14,6 +14,7 @@ const schema = z.object({ // 'OIDC_SCOPES must include "offline_access"' // ), OIDC_ROLE_CLAIM: z.string().nullish(), + OIDC_RESOURCE: z.string().url().nullish(), SECRET: z.string(), NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), OTEL_SERVICE_NAME: z.string().default('MUNIFY-DELEGATOR'), From b3cc12e861e7231a14fc1da2d267ac8d4ebc5177 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 5 Apr 2026 02:13:53 +0200 Subject: [PATCH 02/11] feat (Frontend): Integrate Logto Account Center into my-account page Replace the static login information card with an interactive account management UI that deep-links to Logto's Account Center for credential changes (email, password, username, passkeys, authenticator app, backup codes). Display MFA status, connected social/SSO identities, and password state from Logto Custom JWT claims. Improve responsive layout with two-column grid on larger screens. Co-Authored-By: Claude Opus 4.6 (1M context) --- messages/de.json | 14 ++ messages/en.json | 14 ++ schema.graphql | 9 + src/api/resolvers/modules/auth.ts | 37 +++- src/config/public.ts | 1 + src/lib/queries/fastUserQuery.ts | 7 + .../my-account/+page.server.ts | 13 +- .../(authenticated)/my-account/+page.svelte | 179 ++++++++++++++---- 8 files changed, 233 insertions(+), 41 deletions(-) diff --git a/messages/de.json b/messages/de.json index 7e22a662..e9ed6e7c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -16,6 +16,12 @@ "accessFlowSaved": "Zugangsausweis zugewiesen und Anwesenheit erfasst.", "accountExists": "Konto vorhanden", "accountHolder": "Kontoinhaber*in", + "accountUpdateSuccessBackupCodes": "Deine Backup-Codes wurden erfolgreich aktualisiert.", + "accountUpdateSuccessEmail": "Deine E-Mail-Adresse wurde erfolgreich aktualisiert.", + "accountUpdateSuccessMfa": "Deine Authenticator-App-Einstellungen wurden erfolgreich aktualisiert.", + "accountUpdateSuccessPasskey": "Deine Passkey-Einstellungen wurden erfolgreich aktualisiert.", + "accountUpdateSuccessPassword": "Dein Passwort wurde erfolgreich geändert.", + "accountUpdateSuccessUsername": "Dein Benutzername wurde erfolgreich aktualisiert.", "actions": "Aktionen", "activeConferences": "Aktive Konferenzen", "activeMembers": "Aktive Mitglieder", @@ -193,6 +199,7 @@ "authErrorTechnicalTitle": "Anmeldung fehlgeschlagen", "authErrorTokenExchangeDescription": "Der Anmeldevorgang konnte nicht abgeschlossen werden. Das kann passieren, wenn die Anmeldung zu lange gedauert hat oder ein Netzwerkproblem aufgetreten ist.", "authErrorTryAgain": "Erneut versuchen", + "authenticatorApp": "Authenticator-App", "author": "Autor*in", "authorization": "Berechtigung", "averageAgeOnlyApplied": "Konferenz-Altersdurchschnitt (nur angemeldete)", @@ -201,6 +208,7 @@ "backToConferencePapers": "Zurück zu Konferenz-Papieren", "backToDashboard": "Zurück zum Dashboard", "backToHome": "Zurück zur Homepage", + "backupCodes": "Backup-Codes", "badgeDataDescription": "CSV-Exporte für den Druck von Namensschildern. Enthält Teilnehmernamen, Länder und Einwilligungsstatus.", "badgeDataTitle": "Namensschild-Daten", "bankName": "Name der Bank", @@ -319,6 +327,9 @@ "certificateTemplate": "Basis-PDF für das Zertifikat", "changeAnswer": "Antwort ändern", "changeDelegationPreferences": "Wünsche anpassen", + "changeEmail": "Ändern", + "changePassword": "Ändern", + "changeUsername": "Ändern", "changesSuccessful": "Die Änderungen wurden erfolgreich gespeichert.", "chaseSeedData": "CHASE Seed-Daten", "checkEmails": "E-Mails prüfen", @@ -824,6 +835,7 @@ "male": "Männlich", "malformedPlaceholders": "Textbaustein enthält fehlerhafte Platzhalter. Stelle sicher, dass alle \\{\\{ ein passendes \\}\\} haben.", "manageConference": "Konferenzeinstellungen und Teilnehmende verwalten", + "managePasskeys": "Verwalten", "managePreferences": "Einstellungen verwalten", "manageSnippets": "Textbausteine verwalten", "markAsProblem": "Als Problem markieren", @@ -1036,6 +1048,7 @@ "participation": "Teilnahme", "participationCount": "Teilnahmen", "participationType": "Teilnahmeart", + "passkeys": "Passkeys", "password": "Passwort", "pastConferences": "Vergangene Konferenzen", "pathDoesNotExist": "Der Pfad {path} konnte nicht gefunden werden ({error})", @@ -1382,6 +1395,7 @@ "snippets": "Textbausteine", "specialRole": "Spezielle Rolle", "specialWishes": "Spezielle Wünsche", + "ssoIdentities": "Verknüpfte Konten", "start": "Start", "startAssignment": "Assistent starten", "startCamera": "Kamera starten", diff --git a/messages/en.json b/messages/en.json index 975002eb..b50a6e79 100644 --- a/messages/en.json +++ b/messages/en.json @@ -16,6 +16,12 @@ "accessFlowSaved": "Access card assigned and attendance recorded.", "accountExists": "Account exists", "accountHolder": "Account Holder", + "accountUpdateSuccessBackupCodes": "Your backup codes have been updated successfully.", + "accountUpdateSuccessEmail": "Your email address has been updated successfully.", + "accountUpdateSuccessMfa": "Your authenticator app settings have been updated successfully.", + "accountUpdateSuccessPasskey": "Your passkey settings have been updated successfully.", + "accountUpdateSuccessPassword": "Your password has been changed successfully.", + "accountUpdateSuccessUsername": "Your username has been updated successfully.", "actions": "Actions", "activeConferences": "Active Conferences", "activeMembers": "Active members", @@ -193,6 +199,7 @@ "authErrorTechnicalTitle": "Login Failed", "authErrorTokenExchangeDescription": "Failed to complete the login process. This can happen if the login took too long or if there was a network issue.", "authErrorTryAgain": "Try again", + "authenticatorApp": "Authenticator App", "author": "Author", "authorization": "Authorization", "averageAgeOnlyApplied": "Average age at conference (registered only)", @@ -201,6 +208,7 @@ "backToConferencePapers": "Back to Conference Papers", "backToDashboard": "Back to Dashboard", "backToHome": "Back To Home", + "backupCodes": "Backup Codes", "badgeDataDescription": "CSV exports for badge and nametag printing. Includes participant names, countries, and consent status.", "badgeDataTitle": "Badge Data", "bankName": "Bank Name", @@ -319,6 +327,9 @@ "certificateTemplate": "Base-PDF for the certificate", "changeAnswer": "Change answer", "changeDelegationPreferences": "Change Preferences", + "changeEmail": "Change", + "changePassword": "Change", + "changeUsername": "Change", "changesSuccessful": "The changes were saved successfully.", "chaseSeedData": "CHASE Seed Data", "checkEmails": "Check Emails", @@ -824,6 +835,7 @@ "male": "male", "malformedPlaceholders": "Snippet contains malformed placeholders. Make sure all \\{\\{ have matching \\}\\}.", "manageConference": "Manage conference settings and participants", + "managePasskeys": "Manage", "managePreferences": "Manage settings", "manageSnippets": "Manage Snippets", "markAsProblem": "Mark as Problem", @@ -1036,6 +1048,7 @@ "participation": "Participation", "participationCount": "Participations", "participationType": "Type of Participation", + "passkeys": "Passkeys", "password": "Password", "pastConferences": "Past Conferences", "pathDoesNotExist": "The path {path} could not be found ( {error} )", @@ -1382,6 +1395,7 @@ "snippets": "Snippets", "specialRole": "Special role", "specialWishes": "Special Wishes", + "ssoIdentities": "Connected Accounts", "start": "Start", "startAssignment": "Start Assignment", "startCamera": "Start Camera", diff --git a/schema.graphql b/schema.graphql index c002e201..e6889571 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3825,9 +3825,13 @@ type OfflineUser { email: String! family_name: String given_name: String + hasPassword: Boolean locale: String + mfaVerificationFactors: [String!] phone: String preferred_username: String + socialIdentities: [String!] + ssoIdentities: [OfflineUserSsoIdentity!] sub: String! } @@ -3836,6 +3840,11 @@ type OfflineUserRefresh { user: OfflineUser } +type OfflineUserSsoIdentity { + identityId: String! + issuer: String! +} + type Paper { agendaItem: CommitteeAgendaItem author: User! diff --git a/src/api/resolvers/modules/auth.ts b/src/api/resolvers/modules/auth.ts index f9edf9e3..f454c7b9 100644 --- a/src/api/resolvers/modules/auth.ts +++ b/src/api/resolvers/modules/auth.ts @@ -41,7 +41,21 @@ builder.queryFields((t) => { family_name: t.string({ nullable: true }), given_name: t.string({ nullable: true }), locale: t.string({ nullable: true }), - phone: t.string({ nullable: true }) + phone: t.string({ nullable: true }), + hasPassword: t.boolean({ nullable: true }), + mfaVerificationFactors: t.stringList({ nullable: true }), + ssoIdentities: t.field({ + type: [ + builder.simpleObject('OfflineUserSsoIdentity', { + fields: (t) => ({ + issuer: t.string(), + identityId: t.string() + }) + }) + ], + nullable: true + }), + socialIdentities: t.stringList({ nullable: true }) }) }), nullable: true @@ -53,7 +67,26 @@ builder.queryFields((t) => { }) }), resolve: (root, args, ctx) => { - return { user: ctx.oidc.user, nextTokenRefreshDue: ctx.oidc.nextTokenRefreshDue }; + const user = ctx.oidc.user; + // TYPE-SAFETY-EXCEPTION: `password` and `mfa` are custom JWT claims + // injected by Logto's Custom JWT feature. They exist on the OIDCUser + // index signature but TypeScript loses it after the spread in oidc context. + const claims = user as Record | undefined; + return { + user: user + ? { + ...user, + hasPassword: (claims?.['password'] as boolean) ?? null, + mfaVerificationFactors: (claims?.['mfa'] as string[]) ?? null, + ssoIdentities: + (claims?.['sso_identities'] as + | { issuer: string; identityId: string }[] + | undefined) ?? null, + socialIdentities: (claims?.['social_identities'] as string[] | undefined) ?? null + } + : null, + nextTokenRefreshDue: ctx.oidc.nextTokenRefreshDue + }; } }) }; diff --git a/src/config/public.ts b/src/config/public.ts index 714c8868..d67bab45 100644 --- a/src/config/public.ts +++ b/src/config/public.ts @@ -8,6 +8,7 @@ const schema = z.object({ PUBLIC_OIDC_AUTHORITY: z.string(), PUBLIC_OIDC_CLIENT_ID: z.string(), PUBLIC_DEFAULT_LOCALE: z.string().default('de'), + PUBLIC_OIDC_ACCOUNT_URL: z.string().url().optional(), PUBLIC_FEEDBACK_URL: z.optional(z.string()), PUBLIC_GLOBAL_USER_NOTES_ACTIVE: z.coerce.boolean().default(false), diff --git a/src/lib/queries/fastUserQuery.ts b/src/lib/queries/fastUserQuery.ts index 2c1b9663..5cb7b8f1 100644 --- a/src/lib/queries/fastUserQuery.ts +++ b/src/lib/queries/fastUserQuery.ts @@ -12,6 +12,13 @@ export const fastUserQuery = graphql(` locale phone preferred_username + hasPassword + mfaVerificationFactors + ssoIdentities { + issuer + identityId + } + socialIdentities } } myOIDCRoles diff --git a/src/routes/(authenticated)/my-account/+page.server.ts b/src/routes/(authenticated)/my-account/+page.server.ts index 9c94e6cb..d2b3c5e6 100644 --- a/src/routes/(authenticated)/my-account/+page.server.ts +++ b/src/routes/(authenticated)/my-account/+page.server.ts @@ -7,6 +7,7 @@ import { error, type Actions } from '@sveltejs/kit'; import { m } from '$lib/paraglide/messages'; import { nullFieldsToUndefined } from '$lib/services/nullFieldsToUndefined'; import { fastUserQuery } from '$lib/queries/fastUserQuery'; +import { configPublic } from '$config/public'; const userQuery = graphql(` query FullUserMyAccountQuery($id: String!) { @@ -57,10 +58,20 @@ export const load: PageServerLoad = async (event) => { redirectUrl = undefined; } + // Logto Account Center deep-link support + const accountCenterUrl = + configPublic.PUBLIC_OIDC_ACCOUNT_URL ?? + configPublic.PUBLIC_OIDC_AUTHORITY.replace(/\/oidc\/?$/, '') + '/account'; + const accountRedirectUrl = `${eventUrl.origin}/my-account`; + const accountUpdateSuccess = eventUrl.searchParams.get('show_success') || undefined; + return { form, redirectUrl, - user + user, + accountCenterUrl, + accountRedirectUrl, + accountUpdateSuccess }; }; diff --git a/src/routes/(authenticated)/my-account/+page.svelte b/src/routes/(authenticated)/my-account/+page.svelte index ce2cfcd1..d4f59e1e 100644 --- a/src/routes/(authenticated)/my-account/+page.svelte +++ b/src/routes/(authenticated)/my-account/+page.svelte @@ -27,6 +27,30 @@ }); //TODO pronoun prefill + + // Show toast for successful account center updates + $effect(() => { + const successMap: Record string> = { + email: () => m.accountUpdateSuccessEmail(), + password: () => m.accountUpdateSuccessPassword(), + username: () => m.accountUpdateSuccessUsername(), + passkey: () => m.accountUpdateSuccessPasskey(), + mfa: () => m.accountUpdateSuccessMfa(), + 'backup-codes': () => m.accountUpdateSuccessBackupCodes() + }; + if (data.accountUpdateSuccess && successMap[data.accountUpdateSuccess]) { + toast.success(successMap[data.accountUpdateSuccess]()); + } + }); + + function accountUrl(path: string) { + return `${data.accountCenterUrl}/${path}?redirect=${encodeURIComponent(data.accountRedirectUrl)}`; + } + + const mfaFactors = $derived(data.user.mfaVerificationFactors ?? []); + const hasPasskey = $derived(mfaFactors.includes('WebAuthn')); + const hasTotp = $derived(mfaFactors.includes('Totp')); + const hasBackupCodes = $derived(mfaFactors.includes('BackupCode')); {#if data.redirectUrl} @@ -50,9 +74,9 @@ {/if} -
+
@@ -156,44 +180,123 @@
-
+
{m.loginInformation()}
- - - - - - - - - - - - - - - - - - - - - - - -
{m.email()}{data.user.email}
{m.password()} - - - - - -
{m.userId()}{data.user.sub}
{m.rights()}{data.user.myOIDCRoles.map((x) => x.toUpperCase()).join(', ')}
- - {m.edit()} - - -

{@html m.deleteAccountGPDR()}

+
+
+ +
+
{m.loginName()}
+
{data.user.preferred_username ?? '–'}
+
+ + + +
+
+ +
+
{m.email()}
+
{data.user.email}
+
+ + + +
+
+ +
+
{m.password()}
+ {#if data.user.hasPassword} +
•••••
+ {/if} +
+ + + +
+
+ +
+
{m.passkeys()}
+ {#if hasPasskey} +
+ {/if} +
+ + + +
+
+ +
+
{m.authenticatorApp()}
+ {#if hasTotp} +
+ {/if} +
+ + + +
+ {#if hasPasskey || hasTotp} +
+ +
+
{m.backupCodes()}
+ {#if hasBackupCodes} +
+ {/if} +
+ + + +
+ {/if} + {#if data.user.ssoIdentities?.length || data.user.socialIdentities?.length} +
+ +
+
{m.ssoIdentities()}
+
+ {#each data.user.socialIdentities ?? [] as provider} + {provider} + {/each} + {#each data.user.ssoIdentities ?? [] as sso} + {sso.issuer} + {/each} +
+
+
+ {/if} +
+ +
+
{m.userId()}
+
{data.user.sub}
+
+
+ {#if data.user.myOIDCRoles.length} +
+ +
+
{m.rights()}
+
{data.user.myOIDCRoles.map((x) => x.toUpperCase()).join(', ')}
+
+
+ {/if} +
+

{@html m.deleteAccountGPDR()}

From c284d5345d1e21238b12d6902d9cefee8c2f7438 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 5 Apr 2026 02:19:20 +0200 Subject: [PATCH 03/11] docs: Replace Zitadel with Logto as recommended OIDC provider Update all documentation to recommend Logto instead of Zitadel, remove Zitadel-specific OIDC scopes and role claims from .env.example. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 +++++---- CLAUDE.md | 4 ++-- README.md | 2 +- WARP.md | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 1ffc640e..0f9dd08f 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,7 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres # ─────────────────────────────────────────────────────────────────────────────── # AUTHENTICATION (OIDC) # ─────────────────────────────────────────────────────────────────────────────── -# Supports any OpenID Connect provider (tested with ZITADEL) +# Supports any OpenID Connect provider (recommended: Logto — https://logto.io/) # [REQUIRED] OIDC Discovery URL (usually ends with /.well-known/openid-configuration) # Local mock: http://localhost:8080/default/.well-known/openid-configuration @@ -47,11 +47,12 @@ PUBLIC_OIDC_CLIENT_ID=default # [REQUIRED] OAuth2 scopes to request # Must include "openid" and "offline_access" at minimum -OIDC_SCOPES="openid profile offline_access address email family_name gender given_name locale name phone preferred_username urn:zitadel:iam:org:projects:roles urn:zitadel:iam:user:metadata urn:zitadel:iam:org:project:id:zitadel:aud" +OIDC_SCOPES="openid profile offline_access email" -# [OPTIONAL] JWT claim path for user roles (ZITADEL-specific format shown) +# [OPTIONAL] JWT claim path for user roles # Used to determine team member permissions -OIDC_ROLE_CLAIM=urn:zitadel:iam:org:project:275671427955294244:roles +# Example for Logto: urn:logto:role +# OIDC_ROLE_CLAIM=urn:logto:role # ─────────────────────────────────────────────────────────────────────────────── # APPLICATION FEATURES diff --git a/CLAUDE.md b/CLAUDE.md index eec2bc8a..01ee6fd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ MUNify DELEGATOR is a SvelteKit-based application for managing Model United Nati - **Backend**: Node.js with SvelteKit server routes - **Database**: PostgreSQL via Prisma ORM - **GraphQL**: Pothos schema builder with graphql-yoga server, Houdini client -- **Auth**: OpenID Connect (OIDC) - tested with ZITADEL +- **Auth**: OpenID Connect (OIDC) - recommended provider: Logto - **i18n**: Paraglide-JS for internationalization (default locale: German) - **Runtime**: Bun (package manager and development runtime) - **Observability**: OpenTelemetry tracing support @@ -281,7 +281,7 @@ Required variables (see `.env.example`): - `PUBLIC_OIDC_AUTHORITY` - OIDC provider URL - `PUBLIC_OIDC_CLIENT_ID` - OAuth client ID - `OIDC_SCOPES` - OAuth scopes (must include `openid`) -- `OIDC_ROLE_CLAIM` - JWT claim for roles (ZITADEL format) +- `OIDC_ROLE_CLAIM` - JWT claim for roles - `CERTIFICATE_SECRET` - Secret for signing participation certificates - OpenTelemetry vars (optional): `OTEL_ENDPOINT_URL`, `OTEL_SERVICE_NAME` diff --git a/README.md b/README.md index 0a967771..0b3e6d9d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ bunx lefthook install ## Deployment -The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example docker compose file in the [example](./example/) directoy. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [ZITADEL](https://zitadel.com/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file. +The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example docker compose file in the [example](./example/) directoy. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [Logto](https://logto.io/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file. ## FAQ diff --git a/WARP.md b/WARP.md index 54b1356e..ae4225a6 100644 --- a/WARP.md +++ b/WARP.md @@ -131,7 +131,7 @@ bun run machine-translate #### Authentication & Authorization -- **OIDC Integration**: Uses OpenID Connect (designed for ZITADEL but supports any OIDC provider) +- **OIDC Integration**: Uses OpenID Connect (recommended: Logto, but supports any OIDC provider) - **Context Building**: `src/api/context/context.ts` constructs request context with OIDC data - **Permission System**: CASL ability-based authorization - Definitions in `src/api/abilities/entities/` @@ -221,5 +221,5 @@ Example: `feat(delegation): add nation preference selection` Use provided Docker images: `deutschemodelunitednations/delegator` - Example compose file in `example/` directory -- Requires external OIDC provider (ZITADEL recommended) +- Requires external OIDC provider (Logto recommended) - Environment variables must be configured (see `.env.example`) From 57604f923351f5e97d8ec52d21c42956da250a59 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 5 Apr 2026 02:24:18 +0200 Subject: [PATCH 04/11] docs: Add Logto setup guide, JWT customizer script, and OIDC scope docs to .env.example Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 55 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 0f9dd08f..cdb61719 100644 --- a/.env.example +++ b/.env.example @@ -33,10 +33,39 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres # AUTHENTICATION (OIDC) # ─────────────────────────────────────────────────────────────────────────────── # Supports any OpenID Connect provider (recommended: Logto — https://logto.io/) +# +# ┌─── Logto Setup Guide ───────────────────────────────────────────────────────┐ +# │ │ +# │ 1. Create a "Traditional Web" application in Logto Console │ +# │ 2. Set the redirect URI to: /auth/callback │ +# │ 3. Copy the App ID → PUBLIC_OIDC_CLIENT_ID │ +# │ 4. Copy the App Secret → OIDC_CLIENT_SECRET │ +# │ │ +# │ Custom JWT Claims (required): │ +# │ Go to Logto Console → JWT Customizer → User access token and add: │ +# │ │ +# │ const getCustomJwtClaims = async ({ │ +# │ token, context, environmentVariables, api │ +# │ }) => { │ +# │ return { │ +# │ roles: context.user.roles, │ +# │ mfa: context.user.mfaVerificationFactors, │ +# │ password: context.user.hasPassword, │ +# │ sso_identities: context.user.ssoIdentities, │ +# │ social_identities: │ +# │ Object.keys(context.user.identities) ?? [] │ +# │ }; │ +# │ } │ +# │ │ +# │ This exposes user roles, MFA status, and identity provider info │ +# │ in the access token so the application can use them for │ +# │ authorization and UI display. │ +# │ │ +# └─────────────────────────────────────────────────────────────────────────────┘ # [REQUIRED] OIDC Discovery URL (usually ends with /.well-known/openid-configuration) # Local mock: http://localhost:8080/default/.well-known/openid-configuration -# Production: https://your-oidc-provider.com +# Logto: https://.logto.app/oidc/.well-known/openid-configuration PUBLIC_OIDC_AUTHORITY=http://localhost:8080/default/.well-known/openid-configuration # [REQUIRED] OAuth2 Client ID from your OIDC provider @@ -46,13 +75,29 @@ PUBLIC_OIDC_CLIENT_ID=default # OIDC_CLIENT_SECRET=your-client-secret # [REQUIRED] OAuth2 scopes to request -# Must include "openid" and "offline_access" at minimum -OIDC_SCOPES="openid profile offline_access email" +# These scopes control ID token and userinfo claims, NOT the access token. +# Access token claims are set via the JWT Customizer script above. +# +# openid — required by OIDC spec +# profile — user name, picture, etc. +# offline_access — enables refresh tokens +# email — user email address +# phone — user phone number +# identity — Logto scope: linked social & SSO identities +# role — Logto scope: user roles for authorization +# custom_data — Logto scope: custom user data +OIDC_SCOPES="openid profile offline_access email phone identity role custom_data" # [OPTIONAL] JWT claim path for user roles # Used to determine team member permissions -# Example for Logto: urn:logto:role -# OIDC_ROLE_CLAIM=urn:logto:role +# With the custom JWT claims above, roles are available at the "roles" claim +OIDC_ROLE_CLAIM=roles + +# [OPTIONAL] Machine-to-machine app credentials for Logto Management API +# Required for user impersonation feature (token exchange via subject tokens) +# Create an M2M app in Logto Console with access to the Management API resource +# OIDC_M2M_CLIENT_ID=your-m2m-app-id +# OIDC_M2M_CLIENT_SECRET=your-m2m-app-secret # ─────────────────────────────────────────────────────────────────────────────── # APPLICATION FEATURES From 378c82441f54d9e656880d7d8bc2951d0f30e53a Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 5 Apr 2026 02:46:34 +0200 Subject: [PATCH 05/11] fix: Implement Logto-compatible token exchange for user impersonation Logto requires a two-step impersonation flow: first create a subject token via the Management API, then exchange it at the OIDC token endpoint. Also decode the resource-scoped JWT directly instead of calling userinfo (which rejects resource-scoped tokens), and look up profile claims from the database. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 + schema.graphql | 12 +- src/api/context/oidc.ts | 88 ++++++------ src/api/resolvers/modules/impersonation.ts | 27 ++-- src/api/services/OIDC.ts | 156 ++++++++++++++------- src/config/private.ts | 5 + 6 files changed, 177 insertions(+), 116 deletions(-) diff --git a/.env.example b/.env.example index cdb61719..5495e880 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,11 @@ OIDC_ROLE_CLAIM=roles # Create an M2M app in Logto Console with access to the Management API resource # OIDC_M2M_CLIENT_ID=your-m2m-app-id # OIDC_M2M_CLIENT_SECRET=your-m2m-app-secret +# [OPTIONAL] Logto Management API resource indicator +# For Logto Cloud: https://.logto.app/api +# For self-hosted: check your Logto API Resources settings (e.g. https://default.logto.app/api) +# If not set, derived from PUBLIC_OIDC_AUTHORITY by replacing /oidc with /api +# OIDC_M2M_RESOURCE=https://default.logto.app/api # ─────────────────────────────────────────────────────────────────────────────── # APPLICATION FEATURES diff --git a/schema.graphql b/schema.graphql index e6889571..156a3c55 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2814,9 +2814,9 @@ enum Gender { type ImpersonatedUser { email: String! - family_name: String! - given_name: String! - preferred_username: String! + family_name: String + given_name: String + preferred_username: String sub: String! } @@ -2828,9 +2828,9 @@ type ImpersonationStatus { type ImpersonationUser { email: String! - family_name: String! - given_name: String! - preferred_username: String! + family_name: String + given_name: String + preferred_username: String sub: String! } diff --git a/src/api/context/oidc.ts b/src/api/context/oidc.ts index d474f5a7..3ff91a30 100644 --- a/src/api/context/oidc.ts +++ b/src/api/context/oidc.ts @@ -3,6 +3,8 @@ import { oidcRoles, refresh, validateTokens, type OIDCUser } from '$api/services import { configPrivate } from '$config/private'; import type { RequestEvent } from '@sveltejs/kit'; import { GraphQLError } from 'graphql'; +import { decodeJwt } from 'jose'; +import { db } from '$db/db'; const TokenCookieSchema = z .object({ @@ -149,51 +151,53 @@ export async function oidc(cookies: RequestEvent['cookies']) { const impersonationTokenSet = TokenCookieSchema.safeParse(JSON.parse(impersonationCookie)); if (impersonationTokenSet.success && impersonationTokenSet.data.access_token) { - const impersonatedUser = await validateTokens({ - access_token: impersonationTokenSet.data.access_token, - id_token: impersonationTokenSet.data.id_token - }); + // Decode the impersonation JWT directly — it's a resource-scoped token + // that cannot be used with the userinfo endpoint. + // The token only contains `sub` and custom JWT claims (roles, mfa, etc.) + // but no profile claims, so we look up the user from the database. + const decoded = decodeJwt(impersonationTokenSet.data.access_token); + if (!decoded.sub) { + throw new Error('Impersonation token missing sub claim'); + } - // Extract actor information from the JWT token - const actorInfo = (impersonatedUser as any).act; - - // Security: verify the impersonation token was actually issued for the currently authenticated actor (original user) - const actorSub = - actorInfo && typeof actorInfo === 'object' - ? actorInfo.sub || actorInfo.subject - : undefined; - - if (!actorSub) { - console.warn( - 'Security: Impersonation token missing actor (act.sub) claim. Aborting impersonation.' - ); - // Defensive: clear cookie so we do not repeatedly parse invalid token - cookies.delete(impersonationTokenCookieName, { path: '/' }); - return { - nextTokenRefreshDue: tokenSet.expires_in - ? new Date(Date.now() + tokenSet.expires_in * 1000) - : undefined, - tokenSet, - user: user ? { ...user, hasRole, OIDCRoleNames } : undefined, - impersonation: impersonationContext - }; + const dbUser = await db.user.findUnique({ where: { id: decoded.sub } }); + if (!dbUser) { + throw new Error(`Impersonated user ${decoded.sub} not found in database`); } - if (actorSub !== user.sub) { - console.warn('Security: Actor mismatch in impersonation token. Aborting impersonation.', { - actorSub, - currentUserSub: user.sub - }); - // Clear cookie to prevent repeated attempts with a mismatched token - cookies.delete(impersonationTokenCookieName, { path: '/' }); - return { - nextTokenRefreshDue: tokenSet.expires_in - ? new Date(Date.now() + tokenSet.expires_in * 1000) - : undefined, - tokenSet, - user: user ? { ...user, hasRole, OIDCRoleNames } : undefined, - impersonation: impersonationContext - }; + const impersonatedUser: OIDCUser = { + sub: decoded.sub, + email: dbUser.email ?? '', + preferred_username: dbUser.preferred_username ?? undefined, + family_name: dbUser.family_name ?? undefined, + given_name: dbUser.given_name ?? undefined, + locale: dbUser.locale ?? undefined, + phone: dbUser.phone ?? undefined, + // Spread custom JWT claims (roles, mfa, password, etc.) + ...decoded + }; + + // Extract actor information from the JWT token (if present) + const actorInfo = decoded.act as Record | undefined; + + // If actor claim is present, verify it matches the current user + if (actorInfo && typeof actorInfo === 'object') { + const actorSub = actorInfo.sub || actorInfo.subject; + if (actorSub && actorSub !== user.sub) { + console.warn( + 'Security: Actor mismatch in impersonation token. Aborting impersonation.', + { actorSub, currentUserSub: user.sub } + ); + cookies.delete(impersonationTokenCookieName, { path: '/' }); + return { + nextTokenRefreshDue: tokenSet.expires_in + ? new Date(Date.now() + tokenSet.expires_in * 1000) + : undefined, + tokenSet, + user: user ? { ...user, hasRole, OIDCRoleNames } : undefined, + impersonation: impersonationContext + }; + } } impersonationContext = { diff --git a/src/api/resolvers/modules/impersonation.ts b/src/api/resolvers/modules/impersonation.ts index 482acb46..b36e72ae 100644 --- a/src/api/resolvers/modules/impersonation.ts +++ b/src/api/resolvers/modules/impersonation.ts @@ -14,9 +14,9 @@ builder.queryFields((t) => ({ fields: (t) => ({ sub: t.string(), email: t.string(), - preferred_username: t.string(), - family_name: t.string(), - given_name: t.string() + preferred_username: t.string({ nullable: true }), + family_name: t.string({ nullable: true }), + given_name: t.string({ nullable: true }) }) }), nullable: true @@ -26,9 +26,9 @@ builder.queryFields((t) => ({ fields: (t) => ({ sub: t.string(), email: t.string(), - preferred_username: t.string(), - family_name: t.string(), - given_name: t.string() + preferred_username: t.string({ nullable: true }), + family_name: t.string({ nullable: true }), + given_name: t.string({ nullable: true }) }) }), nullable: true @@ -259,14 +259,10 @@ builder.mutationFields((t) => ({ } try { - // Use the same scopes as the original user's token for consistency - const originalScopes = ctx.oidc.tokenSet.scope || 'openid profile email'; - - // Perform token exchange + // Perform token exchange (no scope needed — Logto uses the resource indicator) const impersonationTokens = await performTokenExchange( ctx.oidc.tokenSet.access_token, - args.targetUserId, - args.scope || originalScopes + args.targetUserId ); // Store impersonation tokens in cookie @@ -281,12 +277,6 @@ builder.mutationFields((t) => ({ }; const event = ctx.event; - console.info('🍪 Setting impersonation cookie:', { - hasEvent: !!event, - hasCookies: !!event?.cookies, - cookieValue: JSON.stringify(impersonationCookieValue) - }); - if (event?.cookies) { event.cookies.set( impersonationTokenCookieName, @@ -299,7 +289,6 @@ builder.mutationFields((t) => ({ maxAge: impersonationTokens.expires_in ? impersonationTokens.expires_in : 3600 // 1 hour default } ); - console.info('🍪 Cookie set successfully'); } else { throw new GraphQLError('Unable to set impersonation cookie: event.cookies unavailable'); } diff --git a/src/api/services/OIDC.ts b/src/api/services/OIDC.ts index d0ca344b..33ea0c09 100644 --- a/src/api/services/OIDC.ts +++ b/src/api/services/OIDC.ts @@ -246,67 +246,131 @@ export function getLogoutUrl(visitedUrl: URL) { } /** - * Perform an OAuth 2.0 Token Exchange to obtain a JWT for acting as a specified subject. + * Obtain an M2M (machine-to-machine) access token for the Logto Management API. + * Uses client_credentials grant with the M2M app credentials. + */ +async function getM2MAccessToken(): Promise { + const m2mClientId = configPrivate.OIDC_M2M_CLIENT_ID; + const m2mClientSecret = configPrivate.OIDC_M2M_CLIENT_SECRET; + + if (!m2mClientId || !m2mClientSecret) { + throw new Error( + 'Impersonation requires M2M credentials. Set OIDC_M2M_CLIENT_ID and OIDC_M2M_CLIENT_SECRET.' + ); + } + + // Use configured resource or derive from OIDC authority + // For Logto Cloud: https://.logto.app/api + // For self-hosted: must be set explicitly via OIDC_M2M_RESOURCE + const issuer = config.serverMetadata().issuer; + const managementApiResource = + configPrivate.OIDC_M2M_RESOURCE ?? `${issuer.replace(/\/oidc\/?$/, '')}/api`; + + const response = await fetch(config.serverMetadata().token_endpoint!, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from(`${m2mClientId}:${m2mClientSecret}`).toString('base64')}` + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + resource: managementApiResource, + scope: 'all' + }), + signal: AbortSignal.timeout(10_000) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to obtain M2M access token: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return data.access_token; +} + +/** + * Create a subject token for impersonation via the Logto Management API. * - * @param actorToken - The actor's access token used to authorize the exchange. - * @param subjectUserId - The user identifier of the subject to impersonate (subject token). + * @param subjectUserId - The Logto user ID of the user to impersonate. + * @returns The subject token string to be used in the token exchange. + */ +async function createSubjectToken(subjectUserId: string): Promise { + const m2mToken = await getM2MAccessToken(); + + // Derive Management API base URL from the OIDC issuer + const issuer = config.serverMetadata().issuer; + const managementApiBase = issuer.replace(/\/oidc\/?$/, ''); + + const response = await fetch(`${managementApiBase}/api/subject-tokens`, { + method: 'POST', + headers: { + Authorization: `Bearer ${m2mToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ userId: subjectUserId }), + signal: AbortSignal.timeout(10_000) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to create subject token: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return data.subjectToken; +} + +/** + * Perform user impersonation via Logto's two-step token exchange flow: + * 1. Create a subject token via the Logto Management API + * 2. Exchange that subject token for an access token at the OIDC token endpoint + * + * @param actorToken - The actor's access token (for the `act` claim in the resulting JWT). + * @param subjectUserId - The user ID of the user to impersonate. * @param scope - Optional scope to request for the exchanged token. - * @param audience - Optional audience to request for the exchanged token. - * @returns The token endpoint response from the issuer as a `TokenEndpointResponse`. - * @throws When the OIDC configuration is not initialized, when the token endpoint returns an error (includes provider error details when available), or when the exchange request fails or times out. + * @returns The token endpoint response with the impersonation access token. */ export async function performTokenExchange( actorToken: string, subjectUserId: string, - scope?: string, - audience?: string + scope?: string ): Promise { if (!config) { throw new Error('OIDC configuration not initialized'); } let actor = 'unknown'; - if (jwks) { - try { - const { payload } = await jwtVerify(actorToken, jwks); - if (payload.sub) { - actor = payload.sub; - } - } catch (err) { - console.warn( - `Could not determine actor from token for audit purposes: ${ - err instanceof Error ? err.message : 'Unknown error' - }` - ); + try { + const decoded = decodeJwt(actorToken); + if (decoded.sub) { + actor = decoded.sub; } - } else { - console.warn('No jwks available for decoding actor token for audit purposes.'); + } catch { + console.warn('Could not determine actor from token for audit purposes.'); } - const tokenExchangeParams: Record = { - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token: subjectUserId, - subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', - actor_token: actorToken, - actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', - requested_token_type: 'urn:ietf:params:oauth:token-type:jwt' - }; - - if (scope) { - tokenExchangeParams.scope = scope; - } + try { + // Step 1: Create a subject token via the Management API + const subjectToken = await createSubjectToken(subjectUserId); + + // Step 2: Exchange the subject token for an access token + // Logto requires: grant_type, subject_token, subject_token_type, resource + // actor_token is optional (adds `act` claim to the resulting JWT) + const tokenExchangeParams: Record = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: subjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token' + }; - if (audience) { - tokenExchangeParams.audience = audience; - } + // Resource is required for Logto token exchange + if (configPrivate.OIDC_RESOURCE) { + tokenExchangeParams.resource = configPrivate.OIDC_RESOURCE; + } - try { - // Add a timeout so the token exchange does not hang indefinitely - const signal = AbortSignal.timeout(10_000); // 10s timeout const response = await fetch(config.serverMetadata().token_endpoint!, { method: 'POST', headers: { - Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', ...(configPrivate.OIDC_CLIENT_SECRET ? { @@ -320,7 +384,7 @@ export async function performTokenExchange( ? {} : { client_id: configPublic.PUBLIC_OIDC_CLIENT_ID }) }), - signal + signal: AbortSignal.timeout(10_000) }); if (!response.ok) { @@ -332,7 +396,6 @@ export async function performTokenExchange( errorDetail = errorText; } - // Specific error handling for different error types if ( errorDetail?.error === 'unauthorized_client' && errorDetail?.error_description?.includes('token-exchange') @@ -344,12 +407,7 @@ export async function performTokenExchange( console.error('Token exchange error details:', { status: response.status, - error: errorDetail, - tokenExchangeParams: { - ...tokenExchangeParams, - actor_token: '[REDACTED]', - subject_token: '[REDACTED]' - } + error: errorDetail }); throw new Error( diff --git a/src/config/private.ts b/src/config/private.ts index 9b628d47..e2449a59 100644 --- a/src/config/private.ts +++ b/src/config/private.ts @@ -15,6 +15,11 @@ const schema = z.object({ // ), OIDC_ROLE_CLAIM: z.string().nullish(), OIDC_RESOURCE: z.string().url().nullish(), + // Machine-to-machine credentials for Logto Management API (required for impersonation) + OIDC_M2M_CLIENT_ID: z.string().optional(), + OIDC_M2M_CLIENT_SECRET: z.string().optional(), + // Logto Management API resource indicator (e.g. https://default.logto.app/api) + OIDC_M2M_RESOURCE: z.string().url().optional(), SECRET: z.string(), NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), OTEL_SERVICE_NAME: z.string().default('MUNIFY-DELEGATOR'), From 574be1f7b3fc00c9eeec8892d4ba4f0bf9ab145a Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Sun, 5 Apr 2026 02:47:17 +0200 Subject: [PATCH 06/11] update workflow cache to v5 --- .github/actions/setup-bun/action.yml | 2 +- .github/workflows/ci.yml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index f18b4cb4..45c87aa8 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -6,7 +6,7 @@ runs: steps: - uses: oven-sh/setup-bun@v2 - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: | ~/.bun/install/cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b996330..5060c9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - run: bun run format:check lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - run: bunx svelte-kit sync - run: bun run lint @@ -33,7 +33,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - run: bunx svelte-kit sync - run: bun run test @@ -41,7 +41,7 @@ jobs: i18n: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - run: bun run i18n:check - run: bun run i18n:validate @@ -49,7 +49,7 @@ jobs: security: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - uses: aquasecurity/trivy-action@v0.35.0 with: @@ -67,7 +67,7 @@ jobs: typecheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - run: bunx svelte-kit sync - run: bunx prisma generate @@ -81,7 +81,7 @@ jobs: env: NODE_OPTIONS: '--max-old-space-size=8192' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup-bun - run: bunx svelte-kit sync - run: bunx prisma generate @@ -101,7 +101,7 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_URL: ${{ secrets.SENTRY_URL }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: docker/setup-buildx-action@v3 @@ -198,7 +198,7 @@ jobs: needs: [format, lint, test, i18n, security, typecheck] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 2 @@ -285,7 +285,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Extract version id: version From f67bf35894f3f45c8941b2abe0ac3380ac2ec643 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:09:04 +0200 Subject: [PATCH 07/11] fix: CodeRabbit auto-fixes for PR #415 (#416) Co-authored-by: CodeRabbit Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Tade Strehk --- .env.example | 4 +- README.md | 2 +- src/api/context/oidc.ts | 128 ++++++++++++------ src/api/resolvers/modules/auth.ts | 52 +++++-- src/api/resolvers/modules/impersonation.ts | 3 +- src/api/resolvers/modules/user.ts | 14 +- src/api/services/OIDC.ts | 21 +++ src/config/private.ts | 4 +- src/config/public.ts | 14 +- .../(authenticated)/my-account/+page.svelte | 17 ++- 10 files changed, 186 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index 5495e880..8fc9d20a 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres # ┌─── Logto Setup Guide ───────────────────────────────────────────────────────┐ # │ │ # │ 1. Create a "Traditional Web" application in Logto Console │ -# │ 2. Set the redirect URI to: /auth/callback │ +# │ 2. Set the redirect URI to: /auth/login-callback │ # │ 3. Copy the App ID → PUBLIC_OIDC_CLIENT_ID │ # │ 4. Copy the App Secret → OIDC_CLIENT_SECRET │ # │ │ @@ -246,4 +246,4 @@ SMTP_FROM_NAME=MUNIFY Delegator # PUBLIC_VERSION=1.0.0 # [AUTO] Git commit SHA -# PUBLIC_SHA=abc123 +# PUBLIC_SHA=abc123 \ No newline at end of file diff --git a/README.md b/README.md index 0b3e6d9d..9dd652ff 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ bunx lefthook install ## Deployment -The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example docker compose file in the [example](./example/) directoy. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [Logto](https://logto.io/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file. +The easiest way to deploy delegator on your own hardware is to use our provided [docker images](https://hub.docker.com/r/deutschemodelunitednations/delegator). You can find an example Docker Compose file in the [example](./example/) directory. Please note that delegator relies on an [OIDC](https://auth0.com/intro-to-iam/what-is-openid-connect-oidc) issuer to be connected and properly configured. We recommend [Logto](https://logto.io/) but any issuer of your choice will work. There are some additional instructions on this topic to be found in the example compose file. ## FAQ diff --git a/src/api/context/oidc.ts b/src/api/context/oidc.ts index 3ff91a30..516fb6ca 100644 --- a/src/api/context/oidc.ts +++ b/src/api/context/oidc.ts @@ -1,9 +1,16 @@ import { z } from 'zod'; -import { oidcRoles, refresh, validateTokens, type OIDCUser } from '$api/services/OIDC'; +import { + oidcRoles, + refresh, + validateTokens, + getJwks, + getConfig, + type OIDCUser +} from '$api/services/OIDC'; import { configPrivate } from '$config/private'; import type { RequestEvent } from '@sveltejs/kit'; import { GraphQLError } from 'graphql'; -import { decodeJwt } from 'jose'; +import { jwtVerify } from 'jose'; import { db } from '$db/db'; const TokenCookieSchema = z @@ -31,6 +38,43 @@ export type ImpersonationContext = { export const tokensCookieName = 'token_set'; export const impersonationTokenCookieName = 'impersonation_token_set'; +/** + * Parse and validate OIDC roles from raw claim data. + * Handles multiple provider formats: plain string arrays, objects with `.name` property, or key-value objects. + * + * @param rolesRaw - The raw roles claim value from the OIDC token + * @param allowedRoles - Set or array of allowed role values + * @returns Array of validated role names + */ +function parseOidcRoles( + rolesRaw: unknown, + allowedRoles: readonly string[] +): (typeof oidcRoles)[number][] { + const result: string[] = []; + + if (Array.isArray(rolesRaw)) { + for (const role of rolesRaw) { + if (typeof role === 'string') { + // Simple string array (e.g. ["admin"]) + result.push(role); + } else if (role && typeof role === 'object' && 'name' in role) { + // Logto returns role objects (e.g. [{name: "admin", ...}]) + const roleName = role.name; + if (typeof roleName === 'string') { + result.push(roleName); + } + } + } + } else if (rolesRaw && typeof rolesRaw === 'object') { + // Zitadel returned roles as an object with role names as keys + const roleNames = Object.keys(rolesRaw); + result.push(...roleNames); + } + + // Filter to only allowed roles + return result.filter((role) => allowedRoles.includes(role)) as (typeof oidcRoles)[number][]; +} + /** * Builds an OIDC context from request cookies: validates or refreshes tokens, extracts roles, and handles optional impersonation. * @@ -116,25 +160,11 @@ export async function oidc(cookies: RequestEvent['cookies']) { } } - const OIDCRoleNames: (typeof oidcRoles)[number][] = []; + let OIDCRoleNames: (typeof oidcRoles)[number][] = []; if (user && configPrivate.OIDC_ROLE_CLAIM) { - const rolesRaw = user[configPrivate.OIDC_ROLE_CLAIM]!; - if (Array.isArray(rolesRaw)) { - for (const role of rolesRaw) { - if (typeof role === 'string') { - // Simple string array (e.g. ["admin"]) - OIDCRoleNames.push(role as any); - } else if (role && typeof role === 'object' && 'name' in role) { - // Logto returns role objects (e.g. [{name: "admin", ...}]) - OIDCRoleNames.push(role.name as any); - } - } - } else if (rolesRaw && typeof rolesRaw === 'object') { - // Zitadel returned roles as an object with role names as keys - const roleNames = Object.keys(rolesRaw); - OIDCRoleNames.push(...(roleNames as any)); - } + const rolesRaw = user[configPrivate.OIDC_ROLE_CLAIM]; + OIDCRoleNames = parseOidcRoles(rolesRaw, oidcRoles); } const hasRole = (role: (typeof OIDCRoleNames)[number]) => { @@ -151,22 +181,43 @@ export async function oidc(cookies: RequestEvent['cookies']) { const impersonationTokenSet = TokenCookieSchema.safeParse(JSON.parse(impersonationCookie)); if (impersonationTokenSet.success && impersonationTokenSet.data.access_token) { - // Decode the impersonation JWT directly — it's a resource-scoped token - // that cannot be used with the userinfo endpoint. - // The token only contains `sub` and custom JWT claims (roles, mfa, etc.) - // but no profile claims, so we look up the user from the database. - const decoded = decodeJwt(impersonationTokenSet.data.access_token); - if (!decoded.sub) { + // Verify the impersonation JWT cryptographically against the OIDC issuer JWKS + const jwks = getJwks(); + if (!jwks) { + throw new Error('JWKS not available for impersonation token verification'); + } + + let verifiedPayload; + try { + const verification = await jwtVerify(impersonationTokenSet.data.access_token, jwks, { + issuer: getConfig().serverMetadata().issuer, + audience: configPrivate.OIDC_RESOURCE ?? undefined + }); + verifiedPayload = verification.payload; + } catch (verificationError) { + console.warn('Impersonation token verification failed:', verificationError); + cookies.delete(impersonationTokenCookieName, { path: '/' }); + return { + nextTokenRefreshDue: tokenSet.expires_in + ? new Date(Date.now() + tokenSet.expires_in * 1000) + : undefined, + tokenSet, + user: user ? { ...user, hasRole, OIDCRoleNames } : undefined, + impersonation: impersonationContext + }; + } + + if (!verifiedPayload.sub) { throw new Error('Impersonation token missing sub claim'); } - const dbUser = await db.user.findUnique({ where: { id: decoded.sub } }); + const dbUser = await db.user.findUnique({ where: { id: verifiedPayload.sub } }); if (!dbUser) { - throw new Error(`Impersonated user ${decoded.sub} not found in database`); + throw new Error(`Impersonated user ${verifiedPayload.sub} not found in database`); } const impersonatedUser: OIDCUser = { - sub: decoded.sub, + sub: verifiedPayload.sub, email: dbUser.email ?? '', preferred_username: dbUser.preferred_username ?? undefined, family_name: dbUser.family_name ?? undefined, @@ -174,11 +225,11 @@ export async function oidc(cookies: RequestEvent['cookies']) { locale: dbUser.locale ?? undefined, phone: dbUser.phone ?? undefined, // Spread custom JWT claims (roles, mfa, password, etc.) - ...decoded + ...verifiedPayload }; // Extract actor information from the JWT token (if present) - const actorInfo = decoded.act as Record | undefined; + const actorInfo = verifiedPayload.act as Record | undefined; // If actor claim is present, verify it matches the current user if (actorInfo && typeof actorInfo === 'object') { @@ -212,21 +263,10 @@ export async function oidc(cookies: RequestEvent['cookies']) { user = impersonatedUser; // Update role information for impersonated user - const impersonatedOIDCRoleNames: (typeof oidcRoles)[number][] = []; + let impersonatedOIDCRoleNames: (typeof oidcRoles)[number][] = []; if (impersonatedUser && configPrivate.OIDC_ROLE_CLAIM) { - const impersonatedRolesRaw = impersonatedUser[configPrivate.OIDC_ROLE_CLAIM]!; - if (Array.isArray(impersonatedRolesRaw)) { - for (const role of impersonatedRolesRaw) { - if (typeof role === 'string') { - impersonatedOIDCRoleNames.push(role as any); - } else if (role && typeof role === 'object' && 'name' in role) { - impersonatedOIDCRoleNames.push(role.name as any); - } - } - } else if (impersonatedRolesRaw && typeof impersonatedRolesRaw === 'object') { - const impersonatedRoleNames = Object.keys(impersonatedRolesRaw); - impersonatedOIDCRoleNames.push(...(impersonatedRoleNames as any)); - } + const impersonatedRolesRaw = impersonatedUser[configPrivate.OIDC_ROLE_CLAIM]; + impersonatedOIDCRoleNames = parseOidcRoles(impersonatedRolesRaw, oidcRoles); } // Override role functions for impersonated user diff --git a/src/api/resolvers/modules/auth.ts b/src/api/resolvers/modules/auth.ts index f454c7b9..f7a9bfc3 100644 --- a/src/api/resolvers/modules/auth.ts +++ b/src/api/resolvers/modules/auth.ts @@ -68,21 +68,51 @@ builder.queryFields((t) => { }), resolve: (root, args, ctx) => { const user = ctx.oidc.user; - // TYPE-SAFETY-EXCEPTION: `password` and `mfa` are custom JWT claims - // injected by Logto's Custom JWT feature. They exist on the OIDCUser - // index signature but TypeScript loses it after the spread in oidc context. - const claims = user as Record | undefined; + + // Type guards for custom JWT claims + const isBooleanClaim = (value: unknown): value is boolean => { + return typeof value === 'boolean'; + }; + + const isStringArrayClaim = (value: unknown): value is string[] => { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); + }; + + const isSsoIdentitiesArrayClaim = ( + value: unknown + ): value is { issuer: string; identityId: string }[] => { + return ( + Array.isArray(value) && + value.every( + (item) => + item && + typeof item === 'object' && + 'issuer' in item && + 'identityId' in item && + typeof item.issuer === 'string' && + typeof item.identityId === 'string' + ) + ); + }; + + // Extract custom JWT claims with runtime validation + const passwordClaim = user?.['password']; + const mfaClaim = user?.['mfa']; + const ssoIdentitiesClaim = user?.['sso_identities']; + const socialIdentitiesClaim = user?.['social_identities']; + return { user: user ? { ...user, - hasPassword: (claims?.['password'] as boolean) ?? null, - mfaVerificationFactors: (claims?.['mfa'] as string[]) ?? null, - ssoIdentities: - (claims?.['sso_identities'] as - | { issuer: string; identityId: string }[] - | undefined) ?? null, - socialIdentities: (claims?.['social_identities'] as string[] | undefined) ?? null + hasPassword: isBooleanClaim(passwordClaim) ? passwordClaim : null, + mfaVerificationFactors: isStringArrayClaim(mfaClaim) ? mfaClaim : null, + ssoIdentities: isSsoIdentitiesArrayClaim(ssoIdentitiesClaim) + ? ssoIdentitiesClaim + : null, + socialIdentities: isStringArrayClaim(socialIdentitiesClaim) + ? socialIdentitiesClaim + : null } : null, nextTokenRefreshDue: ctx.oidc.nextTokenRefreshDue diff --git a/src/api/resolvers/modules/impersonation.ts b/src/api/resolvers/modules/impersonation.ts index b36e72ae..5a95f042 100644 --- a/src/api/resolvers/modules/impersonation.ts +++ b/src/api/resolvers/modules/impersonation.ts @@ -262,7 +262,8 @@ builder.mutationFields((t) => ({ // Perform token exchange (no scope needed — Logto uses the resource indicator) const impersonationTokens = await performTokenExchange( ctx.oidc.tokenSet.access_token, - args.targetUserId + args.targetUserId, + args.scope ?? undefined ); // Store impersonation tokens in cookie diff --git a/src/api/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index 29fc29d1..8c84c2c4 100644 --- a/src/api/resolvers/modules/user.ts +++ b/src/api/resolvers/modules/user.ts @@ -429,11 +429,15 @@ builder.mutationFields((t) => { }, update: { email: issuerUserData.email, - ...(issuerUserData.family_name && { family_name: issuerUserData.family_name }), - ...(issuerUserData.given_name && { given_name: issuerUserData.given_name }), - ...(issuerUserData.preferred_username && { - preferred_username: issuerUserData.preferred_username - }), + ...(issuerUserData.family_name != null + ? { family_name: issuerUserData.family_name } + : {}), + ...(issuerUserData.given_name != null + ? { given_name: issuerUserData.given_name } + : {}), + ...(issuerUserData.preferred_username != null + ? { preferred_username: issuerUserData.preferred_username } + : {}), locale: issuerUserData.locale ?? configPublic.PUBLIC_DEFAULT_LOCALE, phone: issuerUserData.phone ?? user.phone } diff --git a/src/api/services/OIDC.ts b/src/api/services/OIDC.ts index 33ea0c09..34170478 100644 --- a/src/api/services/OIDC.ts +++ b/src/api/services/OIDC.ts @@ -106,6 +106,22 @@ const { config, cryptr, jwks } = await (async () => { return { config, cryptr, jwks }; })(); +/** + * Get the JWKS for token verification. + * @returns The JWKS remote set or undefined if not available. + */ +export function getJwks() { + return jwks; +} + +/** + * Get the OIDC configuration. + * @returns The OIDC configuration object. + */ +export function getConfig() { + return config; +} + export async function startSignin(visitedUrl: URL) { //TODO https://github.com/gornostay25/svelte-adapter-bun/issues/62 if (configPrivate.NODE_ENV === 'production') { @@ -368,6 +384,11 @@ export async function performTokenExchange( tokenExchangeParams.resource = configPrivate.OIDC_RESOURCE; } + // Optional scope parameter + if (scope) { + tokenExchangeParams.scope = scope; + } + const response = await fetch(config.serverMetadata().token_endpoint!, { method: 'POST', headers: { diff --git a/src/config/private.ts b/src/config/private.ts index e2449a59..0e2df182 100644 --- a/src/config/private.ts +++ b/src/config/private.ts @@ -19,7 +19,7 @@ const schema = z.object({ OIDC_M2M_CLIENT_ID: z.string().optional(), OIDC_M2M_CLIENT_SECRET: z.string().optional(), // Logto Management API resource indicator (e.g. https://default.logto.app/api) - OIDC_M2M_RESOURCE: z.string().url().optional(), + OIDC_M2M_RESOURCE: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()), SECRET: z.string(), NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), OTEL_SERVICE_NAME: z.string().default('MUNIFY-DELEGATOR'), @@ -39,7 +39,7 @@ const schema = z.object({ SMTP_FROM_ADDRESS: z.string().email().default('noreply@munify.cloud'), SMTP_FROM_NAME: z.string().default('MUNIFY Delegator'), // Sentry/Bugsink error tracking - SENTRY_DSN: z.string().url().optional(), + SENTRY_DSN: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()), SENTRY_SEND_DEFAULT_PII: z.stringbool().optional() }); diff --git a/src/config/public.ts b/src/config/public.ts index d67bab45..2804cacd 100644 --- a/src/config/public.ts +++ b/src/config/public.ts @@ -8,7 +8,10 @@ const schema = z.object({ PUBLIC_OIDC_AUTHORITY: z.string(), PUBLIC_OIDC_CLIENT_ID: z.string(), PUBLIC_DEFAULT_LOCALE: z.string().default('de'), - PUBLIC_OIDC_ACCOUNT_URL: z.string().url().optional(), + PUBLIC_OIDC_ACCOUNT_URL: z.preprocess( + (v) => (v === '' ? undefined : v), + z.string().url().optional() + ), PUBLIC_FEEDBACK_URL: z.optional(z.string()), PUBLIC_GLOBAL_USER_NOTES_ACTIVE: z.coerce.boolean().default(false), @@ -18,14 +21,17 @@ const schema = z.object({ PUBLIC_MAINTENANCE_WINDOW_START: z.iso.datetime({ offset: true }).optional(), PUBLIC_MAINTENANCE_WINDOW_END: z.iso.datetime({ offset: true }).optional(), // Sentry/Bugsink error tracking - PUBLIC_SENTRY_DSN: z.string().url().optional(), + PUBLIC_SENTRY_DSN: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()), PUBLIC_SENTRY_SEND_DEFAULT_PII: z.stringbool().optional(), // Badge generator URL (optional) - PUBLIC_BADGE_GENERATOR_URL: z.string().url().optional(), + PUBLIC_BADGE_GENERATOR_URL: z.preprocess( + (v) => (v === '' ? undefined : v), + z.string().url().optional() + ), // Documentation URL (optional) - global link to DELEGATOR app documentation - PUBLIC_DOCS_URL: z.string().url().optional(), + PUBLIC_DOCS_URL: z.preprocess((v) => (v === '' ? undefined : v), z.string().url().optional()), // Support email for error pages and help requests PUBLIC_SUPPORT_EMAIL: z.string().email().default('support@dmun.de'), diff --git a/src/routes/(authenticated)/my-account/+page.svelte b/src/routes/(authenticated)/my-account/+page.svelte index d4f59e1e..08a17312 100644 --- a/src/routes/(authenticated)/my-account/+page.svelte +++ b/src/routes/(authenticated)/my-account/+page.svelte @@ -190,7 +190,11 @@
{m.loginName()}
{data.user.preferred_username ?? '–'}
- +
@@ -200,7 +204,7 @@
{m.email()}
{data.user.email}
- + @@ -212,7 +216,11 @@
•••••
{/if} - + @@ -227,6 +235,7 @@ @@ -242,6 +251,7 @@ @@ -258,6 +268,7 @@ From 6182c2e607ecb6f7bb5cd0039bbf7f0a9da3259c Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Mon, 6 Apr 2026 14:29:59 +0200 Subject: [PATCH 08/11] fix (Auth): verify access token claims against JWKS before merging into session Previously decodeAccessTokenClaims used jose's decodeJwt which returns unverified claims. Now verifies the JWT signature against the issuer JWKS, preventing unverified claims (e.g. roles) from being trusted. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/services/OIDC.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/api/services/OIDC.ts b/src/api/services/OIDC.ts index 34170478..6a2bbdbc 100644 --- a/src/api/services/OIDC.ts +++ b/src/api/services/OIDC.ts @@ -183,14 +183,24 @@ export async function resolveSignin( } /** - * Try to decode custom claims from the access token (JWT). + * Verify and decode custom claims from the access token (JWT). * Logto injects Custom JWT claims (e.g. roles) into the access token, not the id_token. - * Returns the decoded payload or an empty object if the token is opaque. + * Verifies the JWT signature against the issuer JWKS when available. + * Returns the verified payload or an empty object if the token is opaque or verification fails. */ -function decodeAccessTokenClaims(access_token: string): Record { +async function verifyAccessTokenClaims(access_token: string): Promise> { + if (!jwks) { + return {}; + } try { - return decodeJwt(access_token); + // Access tokens use the resource indicator as audience, not the client ID + const result = await jwtVerify(access_token, jwks, { + issuer: config.serverMetadata().issuer, + ...(configPrivate.OIDC_RESOURCE ? { audience: configPrivate.OIDC_RESOURCE } : {}) + }); + return result.payload; } catch { + // Token may be opaque or have a non-standard format — skip silently return {}; } } @@ -200,7 +210,7 @@ export async function validateTokens({ id_token }: Pick): Promise { let sub: string | undefined; - const accessTokenClaims = decodeAccessTokenClaims(access_token); + const accessTokenClaims = await verifyAccessTokenClaims(access_token); // Try local JWT verification of the id_token first if (jwks && id_token) { From dacf19d3d96fa68f49b36a76a08517183b4e87e5 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Mon, 6 Apr 2026 15:40:39 +0200 Subject: [PATCH 09/11] feat: Add temporary OIDC migration notice page Shows an intermediate warning page before OIDC login when PUBLIC_OIDC_MIGRATION_NOTICE is enabled, informing users about the Zitadel-to-Logto migration with FAQ and instructions. Uses a version-keyed cookie so the notice can be re-shown by bumping the version. Disabled by default. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 + messages/de.json | 11 +++ messages/en.json | 11 +++ src/assets/undraw/moving.svg | 1 + src/config/public.ts | 3 + src/lib/constants/migrationNotice.ts | 4 + src/routes/(authenticated)/+layout.server.ts | 14 ++++ .../auth/migration-notice/+page.server.ts | 49 +++++++++++ src/routes/auth/migration-notice/+page.svelte | 84 +++++++++++++++++++ 9 files changed, 181 insertions(+) create mode 100644 src/assets/undraw/moving.svg create mode 100644 src/lib/constants/migrationNotice.ts create mode 100644 src/routes/auth/migration-notice/+page.server.ts create mode 100644 src/routes/auth/migration-notice/+page.svelte diff --git a/.env.example b/.env.example index 8fc9d20a..6d5a51c8 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,10 @@ OIDC_ROLE_CLAIM=roles # [OPTIONAL] Enable global user notes visible to team members (default: false) PUBLIC_GLOBAL_USER_NOTES_ACTIVE=true +# [OPTIONAL] Show OIDC migration notice before login (default: false) +# TEMPORARY: Enable during identity provider migration to warn users +# PUBLIC_OIDC_MIGRATION_NOTICE=false + # [OPTIONAL] Maximum character length for application motivation text (default: 1200) PUBLIC_MAX_APPLICATION_TEXT_LENGTH=1200 diff --git a/messages/de.json b/messages/de.json index e9ed6e7c..e188f57b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -846,6 +846,17 @@ "mediaConsentStatus": "Fotostatus", "members": "Mitglieder", "membersPerDelegation": "Plätze pro Delegation", + "migrationNoticeContinue": "Weiter zum Login", + "migrationNoticeFaqEmailPasswordBody": "Melde dich einfach mit deiner bisherigen E-Mail-Adresse und deinem Passwort an. Alles sollte wie gewohnt funktionieren.", + "migrationNoticeFaqEmailPasswordTitle": "Ich habe mich mit E-Mail und Passwort angemeldet \u2014 was muss ich tun?", + "migrationNoticeFaqHelpBody": "Bei Problemen kontaktiere uns bitte unter {email}.", + "migrationNoticeFaqHelpTitle": "Ich brauche Hilfe \u2014 wen kann ich kontaktieren?", + "migrationNoticeFaqIdentityProviderBody": "Wir haben unseren Identit\u00e4tsanbieter \u2014 das System, das deine Anmeldedaten verwaltet \u2014 von Zitadel auf Logto umgestellt. Das verbessert die Sicherheit und Zuverl\u00e4ssigkeit. Deine Kontodaten wurden \u00fcbertragen.", + "migrationNoticeFaqIdentityProviderTitle": "Was hat sich ge\u00e4ndert und warum?", + "migrationNoticeFaqSocialBody": "Social-Login-Konten (z. B. Google) konnten nicht automatisch migriert werden. Bitte melde dich stattdessen mit deiner E-Mail-Adresse an. Falls du noch kein Passwort festgelegt hast, nutze die Option \u201ePasswort vergessen\u201c auf der Login-Seite, um eines zu erstellen.", + "migrationNoticeFaqSocialTitle": "Ich habe Google oder einen anderen Social-Login verwendet \u2014 was muss ich tun?", + "migrationNoticeSubtitle": "Wir haben unser Login-System aktualisiert. Bitte lies die folgenden Informationen, bevor du fortf\u00e4hrst.", + "migrationNoticeTitle": "Wichtig: Login-System aktualisiert", "missingInformation": "Fehlende Informationen", "motivation": "Motivation", "myAccount": "Mein Konto", diff --git a/messages/en.json b/messages/en.json index b50a6e79..9a0df1ad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -846,6 +846,17 @@ "mediaConsentStatus": "Media Status", "members": "Members", "membersPerDelegation": "Seats per Delegation", + "migrationNoticeContinue": "Continue to Login", + "migrationNoticeFaqEmailPasswordBody": "Simply log in with your existing email address and password. Everything should work as before.", + "migrationNoticeFaqEmailPasswordTitle": "I used email and password to log in \u2014 what do I need to do?", + "migrationNoticeFaqHelpBody": "If you experience any issues, please contact us at {email}.", + "migrationNoticeFaqHelpTitle": "I need help \u2014 who can I contact?", + "migrationNoticeFaqIdentityProviderBody": "We migrated our identity provider \u2014 the system that manages your login credentials \u2014 from Zitadel to Logto. This improves security and reliability. Your account data has been transferred.", + "migrationNoticeFaqIdentityProviderTitle": "What changed and why?", + "migrationNoticeFaqSocialBody": "Social login accounts (e.g. Google) could not be automatically migrated. Please log in using your email address instead. If you have not set a password yet, use the \"Forgot password\" option on the login page to create one.", + "migrationNoticeFaqSocialTitle": "I used Google or another social login \u2014 what do I need to do?", + "migrationNoticeSubtitle": "We have updated our login system. Please read the following information before continuing.", + "migrationNoticeTitle": "Important: Login System Update", "missingInformation": "Missing information", "motivation": "Motivation", "myAccount": "My Account", diff --git a/src/assets/undraw/moving.svg b/src/assets/undraw/moving.svg new file mode 100644 index 00000000..e0f4c9e6 --- /dev/null +++ b/src/assets/undraw/moving.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/config/public.ts b/src/config/public.ts index 2804cacd..33630073 100644 --- a/src/config/public.ts +++ b/src/config/public.ts @@ -15,6 +15,9 @@ const schema = z.object({ PUBLIC_FEEDBACK_URL: z.optional(z.string()), PUBLIC_GLOBAL_USER_NOTES_ACTIVE: z.coerce.boolean().default(false), + // --- TEMPORARY: Migration notice (remove after migration period) --- + PUBLIC_OIDC_MIGRATION_NOTICE: z.coerce.boolean().default(false), + PUBLIC_MAX_APPLICATION_TEXT_LENGTH: z.coerce.number().default(1200), PUBLIC_MAX_APPLICATION_SCHOOL_LENGTH: z.coerce.number().default(100), diff --git a/src/lib/constants/migrationNotice.ts b/src/lib/constants/migrationNotice.ts new file mode 100644 index 00000000..b13b4210 --- /dev/null +++ b/src/lib/constants/migrationNotice.ts @@ -0,0 +1,4 @@ +// --- TEMPORARY: Migration notice constants (remove after migration period) --- +export const MIGRATION_NOTICE_VERSION = 'v1'; +export const MIGRATION_NOTICE_COOKIE = 'migration_notice_ack'; +// --- END TEMPORARY --- diff --git a/src/routes/(authenticated)/+layout.server.ts b/src/routes/(authenticated)/+layout.server.ts index b9766b9c..1b5d6e33 100644 --- a/src/routes/(authenticated)/+layout.server.ts +++ b/src/routes/(authenticated)/+layout.server.ts @@ -2,6 +2,10 @@ import type { LayoutServerLoad } from './$types'; import { codeVerifierCookieName, oidcStateCookieName, startSignin } from '$api/services/OIDC'; import { redirect } from '@sveltejs/kit'; import { fastUserQuery } from '$lib/queries/fastUserQuery'; +import { configPublic } from '$config/public'; +// --- TEMPORARY: Migration notice imports (remove after migration period) --- +import { MIGRATION_NOTICE_VERSION, MIGRATION_NOTICE_COOKIE } from '$lib/constants/migrationNotice'; +// --- END TEMPORARY --- //TODO: a more clean approach would be to do this inside the api and not // in the layout server @@ -24,6 +28,16 @@ export const load: LayoutServerLoad = async (event) => { }; } + // --- TEMPORARY: Migration notice redirect (remove after migration period) --- + if (configPublic.PUBLIC_OIDC_MIGRATION_NOTICE) { + const ackCookie = event.cookies.get(MIGRATION_NOTICE_COOKIE); + if (ackCookie !== MIGRATION_NOTICE_VERSION) { + const next = encodeURIComponent(event.url.pathname + event.url.search); + throw redirect(302, `/auth/migration-notice?next=${next}`); + } + } + // --- END TEMPORARY --- + const { encrypted_state, encrypted_verifier, redirect_uri } = await startSignin(event.url); event.cookies.set(codeVerifierCookieName, encrypted_verifier, { diff --git a/src/routes/auth/migration-notice/+page.server.ts b/src/routes/auth/migration-notice/+page.server.ts new file mode 100644 index 00000000..4afa6713 --- /dev/null +++ b/src/routes/auth/migration-notice/+page.server.ts @@ -0,0 +1,49 @@ +// --- TEMPORARY: Migration notice route (remove after migration period) --- +import type { Actions } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { codeVerifierCookieName, oidcStateCookieName, startSignin } from '$api/services/OIDC'; +import { MIGRATION_NOTICE_VERSION, MIGRATION_NOTICE_COOKIE } from '$lib/constants/migrationNotice'; + +function isSafeRedirectPath(path: string): boolean { + return path.startsWith('/') && !path.startsWith('//') && !path.includes('://'); +} + +export const actions: Actions = { + default: async (event) => { + const formData = await event.request.formData(); + const next = formData.get('next')?.toString() || '/'; + const safePath = isSafeRedirectPath(next) ? next : '/'; + + // Set acknowledgment cookie (30 days) + event.cookies.set(MIGRATION_NOTICE_COOKIE, MIGRATION_NOTICE_VERSION, { + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + path: '/', + secure: true, + httpOnly: true + }); + + // Build the target URL and start OIDC flow + const targetUrl = new URL(safePath, event.url.origin); + const { encrypted_state, encrypted_verifier, redirect_uri } = await startSignin(targetUrl); + + event.cookies.set(codeVerifierCookieName, encrypted_verifier, { + sameSite: 'lax', + maxAge: 60 * 5, + path: '/', + secure: true, + httpOnly: true + }); + + event.cookies.set(oidcStateCookieName, encrypted_state, { + sameSite: 'lax', + maxAge: 60 * 5, + path: '/', + secure: true, + httpOnly: true + }); + + throw redirect(302, redirect_uri); + } +}; +// --- END TEMPORARY --- diff --git a/src/routes/auth/migration-notice/+page.svelte b/src/routes/auth/migration-notice/+page.svelte new file mode 100644 index 00000000..db7efda8 --- /dev/null +++ b/src/routes/auth/migration-notice/+page.svelte @@ -0,0 +1,84 @@ + + + +
+
+ +
+ +

MUNify

+

Delegator

+
+ + + + + +
+ +
+

{m.migrationNoticeTitle()}

+

{m.migrationNoticeSubtitle()}

+
+
+ + +
+
+

+ + {m.migrationNoticeFaqIdentityProviderTitle()} +

+

{m.migrationNoticeFaqIdentityProviderBody()}

+
+ +
+

+ + {m.migrationNoticeFaqEmailPasswordTitle()} +

+

{m.migrationNoticeFaqEmailPasswordBody()}

+
+ +
+

+ + {m.migrationNoticeFaqSocialTitle()} +

+

+ + + + + {m.migrationNoticeFaqSocialBody()} +

+
+ +
+

+ + {m.migrationNoticeFaqHelpTitle()} +

+

+ {@html m.migrationNoticeFaqHelpBody({ email: configPublic.PUBLIC_SUPPORT_EMAIL })} +

+
+
+ + +
+ + +
+
+
From a2d05b9bb91831038b4548d29138ac6cdecddca3 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Mon, 6 Apr 2026 15:43:51 +0200 Subject: [PATCH 10/11] feat: Add new user FAQ and provider links to migration notice Add FAQ section for first-time registrants explaining the change doesn't affect them. Add Zitadel/Logto links in the identity provider explanation using @html rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- messages/de.json | 4 +++- messages/en.json | 4 +++- src/routes/auth/migration-notice/+page.svelte | 10 +++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/messages/de.json b/messages/de.json index e188f57b..7a0b54a3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -851,8 +851,10 @@ "migrationNoticeFaqEmailPasswordTitle": "Ich habe mich mit E-Mail und Passwort angemeldet \u2014 was muss ich tun?", "migrationNoticeFaqHelpBody": "Bei Problemen kontaktiere uns bitte unter {email}.", "migrationNoticeFaqHelpTitle": "Ich brauche Hilfe \u2014 wen kann ich kontaktieren?", - "migrationNoticeFaqIdentityProviderBody": "Wir haben unseren Identit\u00e4tsanbieter \u2014 das System, das deine Anmeldedaten verwaltet \u2014 von Zitadel auf Logto umgestellt. Das verbessert die Sicherheit und Zuverl\u00e4ssigkeit. Deine Kontodaten wurden \u00fcbertragen.", + "migrationNoticeFaqIdentityProviderBody": "Wir haben unseren Identit\u00e4tsanbieter \u2014 das System, das deine Anmeldedaten verwaltet \u2014 von Zitadel auf Logto umgestellt. Das verbessert die Sicherheit und Zuverl\u00e4ssigkeit. Deine Kontodaten wurden \u00fcbertragen.", "migrationNoticeFaqIdentityProviderTitle": "Was hat sich ge\u00e4ndert und warum?", + "migrationNoticeFaqNewUserBody": "Wenn du dich zum ersten Mal registrierst, betrifft dich diese \u00c4nderung nicht. Erstelle einfach auf der n\u00e4chsten Seite ein neues Konto.", + "migrationNoticeFaqNewUserTitle": "Ich bin neu und m\u00f6chte mich registrieren \u2014 was muss ich tun?", "migrationNoticeFaqSocialBody": "Social-Login-Konten (z. B. Google) konnten nicht automatisch migriert werden. Bitte melde dich stattdessen mit deiner E-Mail-Adresse an. Falls du noch kein Passwort festgelegt hast, nutze die Option \u201ePasswort vergessen\u201c auf der Login-Seite, um eines zu erstellen.", "migrationNoticeFaqSocialTitle": "Ich habe Google oder einen anderen Social-Login verwendet \u2014 was muss ich tun?", "migrationNoticeSubtitle": "Wir haben unser Login-System aktualisiert. Bitte lies die folgenden Informationen, bevor du fortf\u00e4hrst.", diff --git a/messages/en.json b/messages/en.json index 9a0df1ad..373935f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -851,8 +851,10 @@ "migrationNoticeFaqEmailPasswordTitle": "I used email and password to log in \u2014 what do I need to do?", "migrationNoticeFaqHelpBody": "If you experience any issues, please contact us at {email}.", "migrationNoticeFaqHelpTitle": "I need help \u2014 who can I contact?", - "migrationNoticeFaqIdentityProviderBody": "We migrated our identity provider \u2014 the system that manages your login credentials \u2014 from Zitadel to Logto. This improves security and reliability. Your account data has been transferred.", + "migrationNoticeFaqIdentityProviderBody": "We migrated our identity provider \u2014 the system that manages your login credentials \u2014 from Zitadel to Logto. This improves security and reliability. Your account data has been transferred.", "migrationNoticeFaqIdentityProviderTitle": "What changed and why?", + "migrationNoticeFaqNewUserBody": "If you are registering for the first time, this change does not affect you. Simply create a new account on the next page.", + "migrationNoticeFaqNewUserTitle": "I am new and want to register \u2014 what do I need to do?", "migrationNoticeFaqSocialBody": "Social login accounts (e.g. Google) could not be automatically migrated. Please log in using your email address instead. If you have not set a password yet, use the \"Forgot password\" option on the login page to create one.", "migrationNoticeFaqSocialTitle": "I used Google or another social login \u2014 what do I need to do?", "migrationNoticeSubtitle": "We have updated our login system. Please read the following information before continuing.", diff --git a/src/routes/auth/migration-notice/+page.svelte b/src/routes/auth/migration-notice/+page.svelte index db7efda8..f3aff3b3 100644 --- a/src/routes/auth/migration-notice/+page.svelte +++ b/src/routes/auth/migration-notice/+page.svelte @@ -36,7 +36,7 @@ {m.migrationNoticeFaqIdentityProviderTitle()} -

{m.migrationNoticeFaqIdentityProviderBody()}

+

{@html m.migrationNoticeFaqIdentityProviderBody()}

@@ -47,6 +47,14 @@

{m.migrationNoticeFaqEmailPasswordBody()}

+
+

+ + {m.migrationNoticeFaqNewUserTitle()} +

+

{m.migrationNoticeFaqNewUserBody()}

+
+

From 3530e9de15965f70994312cbc541efb99fb3d597 Mon Sep 17 00:00:00 2001 From: Tade Strehk Date: Mon, 6 Apr 2026 17:34:11 +0200 Subject: [PATCH 11/11] fix: Use OIDC_M2M_RESOURCE for subject token endpoint in createSubjectToken Align createSubjectToken with getM2MAccessToken by using the configurable OIDC_M2M_RESOURCE with fallback, instead of hardcoding the base URL derivation from the issuer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/services/OIDC.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/services/OIDC.ts b/src/api/services/OIDC.ts index 6a2bbdbc..239c6e5d 100644 --- a/src/api/services/OIDC.ts +++ b/src/api/services/OIDC.ts @@ -324,11 +324,12 @@ async function getM2MAccessToken(): Promise { async function createSubjectToken(subjectUserId: string): Promise { const m2mToken = await getM2MAccessToken(); - // Derive Management API base URL from the OIDC issuer + // Use configured resource or derive from OIDC authority (mirrors getM2MAccessToken) const issuer = config.serverMetadata().issuer; - const managementApiBase = issuer.replace(/\/oidc\/?$/, ''); + const managementApiBase = + configPrivate.OIDC_M2M_RESOURCE ?? `${issuer.replace(/\/oidc\/?$/, '')}/api`; - const response = await fetch(`${managementApiBase}/api/subject-tokens`, { + const response = await fetch(`${managementApiBase}/subject-tokens`, { method: 'POST', headers: { Authorization: `Bearer ${m2mToken}`,