diff --git a/.env.example b/.env.example index 1ffc640e..6d5a51c8 100644 --- a/.env.example +++ b/.env.example @@ -32,11 +32,40 @@ 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/) +# +# ┌─── Logto Setup Guide ───────────────────────────────────────────────────────┐ +# │ │ +# │ 1. Create a "Traditional Web" application in Logto Console │ +# │ 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 │ +# │ │ +# │ 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,12 +75,34 @@ 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 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" - -# [OPTIONAL] JWT claim path for user roles (ZITADEL-specific format shown) +# 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 -OIDC_ROLE_CLAIM=urn:zitadel:iam:org:project:275671427955294244:roles +# 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 +# [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 @@ -64,6 +115,10 @@ OIDC_ROLE_CLAIM=urn:zitadel:iam:org:project:275671427955294244: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 @@ -195,4 +250,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/.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 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..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 [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/) 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/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`) 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/messages/de.json b/messages/de.json index 7e22a662..7a0b54a3 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", @@ -834,6 +846,19 @@ "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?", + "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.", + "migrationNoticeTitle": "Wichtig: Login-System aktualisiert", "missingInformation": "Fehlende Informationen", "motivation": "Motivation", "myAccount": "Mein Konto", @@ -1036,6 +1061,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 +1408,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..373935f8 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", @@ -834,6 +846,19 @@ "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?", + "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.", + "migrationNoticeTitle": "Important: Login System Update", "missingInformation": "Missing information", "motivation": "Motivation", "myAccount": "My Account", @@ -1036,6 +1061,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 +1408,7 @@ "snippets": "Snippets", "specialRole": "Special role", "specialWishes": "Special Wishes", + "ssoIdentities": "Connected Accounts", "start": "Start", "startAssignment": "Start Assignment", "startCamera": "Start Camera", 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..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! } @@ -3823,11 +3823,15 @@ enum OIDCRolesEnum { type OfflineUser { email: String! - family_name: String! - given_name: String! + family_name: String + given_name: String + hasPassword: Boolean locale: String + mfaVerificationFactors: [String!] phone: String - preferred_username: 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/context/oidc.ts b/src/api/context/oidc.ts index 364e87d0..516fb6ca 100644 --- a/src/api/context/oidc.ts +++ b/src/api/context/oidc.ts @@ -1,8 +1,17 @@ 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 { jwtVerify } from 'jose'; +import { db } from '$db/db'; const TokenCookieSchema = z .object({ @@ -29,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. * @@ -114,14 +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 (rolesRaw) { - 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]) => { @@ -138,42 +181,21 @@ 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 - }); - - // 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 || (actorInfo as any)['urn:zitadel:act:sub'] - : 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 - }; + // 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'); } - if (actorSub !== user.sub) { - console.warn('Security: Actor mismatch in impersonation token. Aborting impersonation.', { - actorSub, - currentUserSub: user.sub + let verifiedPayload; + try { + const verification = await jwtVerify(impersonationTokenSet.data.access_token, jwks, { + issuer: getConfig().serverMetadata().issuer, + audience: configPrivate.OIDC_RESOURCE ?? undefined }); - // Clear cookie to prevent repeated attempts with a mismatched token + verifiedPayload = verification.payload; + } catch (verificationError) { + console.warn('Impersonation token verification failed:', verificationError); cookies.delete(impersonationTokenCookieName, { path: '/' }); return { nextTokenRefreshDue: tokenSet.expires_in @@ -185,6 +207,50 @@ export async function oidc(cookies: RequestEvent['cookies']) { }; } + if (!verifiedPayload.sub) { + throw new Error('Impersonation token missing sub claim'); + } + + const dbUser = await db.user.findUnique({ where: { id: verifiedPayload.sub } }); + if (!dbUser) { + throw new Error(`Impersonated user ${verifiedPayload.sub} not found in database`); + } + + const impersonatedUser: OIDCUser = { + sub: verifiedPayload.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.) + ...verifiedPayload + }; + + // Extract actor information from the JWT token (if present) + const actorInfo = verifiedPayload.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 = { isImpersonating: true, originalUser: user, @@ -197,13 +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 (impersonatedRolesRaw) { - 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 307f4e3f..f7a9bfc3 100644 --- a/src/api/resolvers/modules/auth.ts +++ b/src/api/resolvers/modules/auth.ts @@ -37,11 +37,25 @@ 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 }) + 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,56 @@ builder.queryFields((t) => { }) }), resolve: (root, args, ctx) => { - return { user: ctx.oidc.user, nextTokenRefreshDue: ctx.oidc.nextTokenRefreshDue }; + const user = ctx.oidc.user; + + // 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: 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 482acb46..5a95f042 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,11 @@ 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.scope ?? undefined ); // Store impersonation tokens in cookie @@ -281,12 +278,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 +290,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/resolvers/modules/user.ts b/src/api/resolvers/modules/user.ts index a9725b01..8c84c2c4 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,23 @@ 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 != 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 011797f8..239c6e5d 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'; @@ -80,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') { @@ -101,7 +143,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 +167,95 @@ 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 }; } +/** + * 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. + * 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. + */ +async function verifyAccessTokenClaims(access_token: string): Promise> { + if (!jwks) { + return {}; + } + try { + // 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 {}; + } +} + 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 = await verifyAccessTokenClaims(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) { @@ -202,81 +272,137 @@ export function getLogoutUrl(visitedUrl: URL) { } /** - * Retrieve user information for an access token from the issuer. + * 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 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 + * @param subjectUserId - The Logto user ID of the user to impersonate. + * @returns The subject token string to be used in the token exchange. */ -export function fetchUserInfoFromIssuer( - access_token: string, - expectedSubject: string -): Promise { - return fetchUserInfo(config, access_token, expectedSubject) as Promise; +async function createSubjectToken(subjectUserId: string): Promise { + const m2mToken = await getM2MAccessToken(); + + // Use configured resource or derive from OIDC authority (mirrors getM2MAccessToken) + const issuer = config.serverMetadata().issuer; + const managementApiBase = + configPrivate.OIDC_M2M_RESOURCE ?? `${issuer.replace(/\/oidc\/?$/, '')}/api`; + + const response = await fetch(`${managementApiBase}/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 an OAuth 2.0 Token Exchange to obtain a JWT for acting as a specified subject. + * 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 used to authorize the exchange. - * @param subjectUserId - The user identifier of the subject to impersonate (subject token). + * @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:zitadel:params:oauth:token-type:user_id', - actor_token: actorToken, - actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', - requested_token_type: 'urn:ietf:params:oauth:token-type:jwt' - }; + 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 (scope) { - tokenExchangeParams.scope = scope; - } + // Resource is required for Logto token exchange + if (configPrivate.OIDC_RESOURCE) { + tokenExchangeParams.resource = configPrivate.OIDC_RESOURCE; + } - if (audience) { - tokenExchangeParams.audience = audience; - } + // Optional scope parameter + if (scope) { + tokenExchangeParams.scope = scope; + } - 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 ? { @@ -290,7 +416,7 @@ export async function performTokenExchange( ? {} : { client_id: configPublic.PUBLIC_OIDC_CLIENT_ID }) }), - signal + signal: AbortSignal.timeout(10_000) }); if (!response.ok) { @@ -302,33 +428,18 @@ export async function performTokenExchange( errorDetail = errorText; } - // Specific error handling for different error types if ( errorDetail?.error === 'unauthorized_client' && 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.` ); } 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/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/private.ts b/src/config/private.ts index a88113eb..0e2df182 100644 --- a/src/config/private.ts +++ b/src/config/private.ts @@ -14,6 +14,12 @@ const schema = z.object({ // 'OIDC_SCOPES must include "offline_access"' // ), 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.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'), @@ -33,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 714c8868..33630073 100644 --- a/src/config/public.ts +++ b/src/config/public.ts @@ -8,23 +8,33 @@ 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.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), + // --- 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), 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/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/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)/+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/(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..08a17312 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,134 @@
-
+
{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()}

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..f3aff3b3 --- /dev/null +++ b/src/routes/auth/migration-notice/+page.svelte @@ -0,0 +1,92 @@ + + + +
+
+ +
+ +

MUNify

+

Delegator

+
+ + + + + +
+ +
+

{m.migrationNoticeTitle()}

+

{m.migrationNoticeSubtitle()}

+
+
+ + +
+
+

+ + {m.migrationNoticeFaqIdentityProviderTitle()} +

+

{@html m.migrationNoticeFaqIdentityProviderBody()}

+
+ +
+

+ + {m.migrationNoticeFaqEmailPasswordTitle()} +

+

{m.migrationNoticeFaqEmailPasswordBody()}

+
+ +
+

+ + {m.migrationNoticeFaqNewUserTitle()} +

+

{m.migrationNoticeFaqNewUserBody()}

+
+ +
+

+ + {m.migrationNoticeFaqSocialTitle()} +

+

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

+
+ +
+

+ + {m.migrationNoticeFaqHelpTitle()} +

+

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

+
+
+ + +
+ + +
+
+