Skip to content

feat: Replace Zitadel with Logto as OIDC identity provider#415

Open
Strehk wants to merge 7 commits intomainfrom
auth/replace-zitadel-with-logto
Open

feat: Replace Zitadel with Logto as OIDC identity provider#415
Strehk wants to merge 7 commits intomainfrom
auth/replace-zitadel-with-logto

Conversation

@Strehk
Copy link
Copy Markdown
Member

@Strehk Strehk commented Apr 5, 2026

Summary

  • Replace Zitadel with Logto as the OIDC identity provider, updating OIDC discovery, token validation, claim normalization, and role extraction
  • Integrate Logto Account Center deep-links into the my-account page, allowing users to change email, password, username, passkeys, MFA, and backup codes directly from the app with redirect-back toast notifications
  • Implement Logto-compatible impersonation via the two-step token exchange flow (Management API subject tokens + OIDC token exchange)
  • Add M2M credentials config (OIDC_M2M_CLIENT_ID, OIDC_M2M_CLIENT_SECRET, OIDC_M2M_RESOURCE) for Management API access
  • Surface MFA status, SSO/social identities, and password status from Logto Custom JWT claims in the my-account page
  • Update documentation (README, CLAUDE.md, .env.example) to reflect Logto as the recommended provider with setup guides

Test plan

  • Verify login/logout flow works with Logto OIDC
  • Verify my-account page displays user info, MFA status, connected identities
  • Verify each Account Center deep-link (email, password, username, passkeys, authenticator app, backup codes) redirects to Logto and back with success toast
  • Verify user impersonation works (start + stop) with M2M credentials configured
  • Verify impersonation banner shows correct original/impersonated user
  • Verify role-based access control still works with Logto role format
  • Run bun run check — no type errors

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced account settings: passkey, authenticator app, backup codes management; shows MFA status and connected SSO identities; success toasts after updates.
    • Deep-linking support to account center and improved account-related navigation/links.
  • Documentation

    • OIDC configuration guidance updated to recommend Logto; example env docs and README/WARP/CLAUDE references revised.

Strehk and others added 6 commits April 4, 2026 23:48
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…cs to .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@Strehk Strehk added the PR: Feature New feature label Apr 5, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

Migrates default OIDC provider guidance from ZITADEL to Logto, adds Logto M2M impersonation/token-exchange flow, normalizes provider-specific claims, relaxes several user profile fields to nullable, adds migration seed scripts, and updates UI/messages, configs, and CI/deployment settings.

Changes

Cohort / File(s) Summary
Env & Docs
\.env.example, CLAUDE.md, README.md, WARP.md
Replaced ZITADEL-focused OIDC examples/text with Logto guidance, updated example discovery URL and scopes, changed OIDC_ROLE_CLAIM to roles, and added optional M2M env vars: OIDC_M2M_CLIENT_ID, OIDC_M2M_CLIENT_SECRET, OIDC_M2M_RESOURCE.
CI / Infra / Compose
.github/actions/setup-bun/action.yml, .github/workflows/ci.yml, dev.docker-compose.yml
Bumped GitHub Actions cache/checkout actions to v5; adjusted Mailpit SMTP auth and example dev OIDC mock claim shape to top-level roles array.
I18n
messages/en.json, messages/de.json
Added new account/security-related UI strings (backup codes, passkeys, MFA, email/password/username change success, SSO identities).
Mock OIDC
mock-oidc-landingpage.html
Updated mock preset to emit a top-level roles array instead of ZITADEL URN-shaped claim.
Prisma / Migration Scripts
prisma/seed/createMissingLogtoUsers.ts, prisma/seed/migrateOidcSubs.ts
Added two seed/migration scripts: create missing Logto users via management API (requires LOGTO_TOKEN) and bulk migrate OIDC subject mappings from JSON; both dynamically update FK references.
GraphQL Schema
schema.graphql
Made family_name, given_name, preferred_username nullable on impersonation/offline types; added hasPassword, mfaVerificationFactors, socialIdentities, ssoIdentities and new OfflineUserSsoIdentity type.
OIDC Core & Context
src/api/services/OIDC.ts, src/api/context/oidc.ts
Added claim normalization for Logto, relaxed OIDC user validation (only sub+email required), introduced M2M subject-token impersonation flow (get M2M token → create subject token → token exchange), exposed getJwks()/getConfig(), and improved role-parsing for multiple claim shapes.
Resolvers / Auth Logic
src/api/resolvers/modules/auth.ts, src/api/resolvers/modules/impersonation.ts, src/api/resolvers/modules/user.ts
Adjusted resolvers/types to match nullable schema fields, removed direct userinfo fetch path (derive from JWT claims/db), changed impersonation token exchange to not derive scope from original token, and updated upsert logic to tolerate nullable profile fields.
Config Schema
src/config/private.ts, src/config/public.ts
Added optional OIDC_RESOURCE, OIDC_M2M_CLIENT_ID, OIDC_M2M_CLIENT_SECRET, OIDC_M2M_RESOURCE, and PUBLIC_OIDC_ACCOUNT_URL; several public/private URL/string fields now coerce '' to undefined.
Frontend & Queries
src/lib/queries/fastUserQuery.ts, src/routes/(authenticated)/my-account/+page.server.ts, src/routes/(authenticated)/my-account/+page.svelte
Extended fastUserQuery to request new MFA/SSO fields; added account-center deep-linking and success flag; updated my-account UI to show MFA/passkey/backup-code status, SSO identities, conditional UI, and to emit localized success toasts.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server
    participant OIDCProvider as OIDC Provider
    participant ManagementAPI as Logto Management API
    participant Database

    rect rgba(200, 150, 100, 0.5)
        Note over Server,OIDCProvider: Legacy flow (validateTokens-based)
        Client->>Server: Impersonation request (impersonation token)
        Server->>OIDCProvider: validateTokens()/userinfo (verified path)
        OIDCProvider-->>Server: token claims
        Server->>Database:_lookup_impersonated_user_by_sub
        Server->>Server: validate actor claim vs session
        Server->>OIDCProvider: token exchange (audience)
    end

    rect rgba(100, 150, 200, 0.5)
        Note over Server,ManagementAPI: New Logto M2M subject-token flow
        Client->>Server: Impersonation request (impersonation token)
        Server->>Server: jwtVerify()/decode impersonation token
        Server->>Database: fetch user by sub from token
        Server->>Server: validate actor claim vs session
        Server->>ManagementAPI: getM2MAccessToken(client_id, secret)
        ManagementAPI-->>Server: m2m_access_token
        Server->>ManagementAPI: POST /api/subject-tokens (m2m_token + sub)
        ManagementAPI-->>Server: subject_token
        Server->>OIDCProvider: token exchange using subject_token (include resource if configured)
        OIDCProvider-->>Server: exchanged tokens
        Server->>Database: establish impersonation/session
    end
Loading
sequenceDiagram
    participant Client
    participant Server
    participant OIDCIssuer as OIDC Issuer
    participant Database

    rect rgba(100, 200, 100, 0.5)
        Note over Server,OIDCIssuer: Claim normalization & flexible profile
        Client->>Server: Login / token refresh
        Server->>OIDCIssuer: validate id_token, decode access_token
        OIDCIssuer-->>Server: id_token + access_token claims
        Server->>Server: normalizeOIDCClaims() (e.g. Logto: username→preferred_username, name→given/family)
        Server->>Server: ensure sub + email present
        Server->>Database: upsert user (nullable name fields accepted)
        Database-->>Server: user row created/updated
        Server->>Client: session / offline user response with new fields
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • m1212e

Poem

🐰 Hopping from ZITADEL to Logto's glade,

I brokered tokens in a merry parade.
M2M whispers, subject-tokens hum,
Claims align and migrations come—
New roles, soft names, a smoother trade. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Boy Scout Scope ⚠️ Warning PR includes undocumented opportunistic changes (GitHub Actions version updates) beyond the primary Zitadel→Logto replacement task, violating Boy Scout principle transparency requirements. Add a 'Boy Scout changes' section to PR description documenting GitHub Actions updates, or move these changes to a separate maintenance PR to keep scope focused.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: replacing Zitadel with Logto as the OIDC identity provider, which is the primary objective throughout the changeset.
Pr Label Required ✅ Passed The pull request has the 'PR: Feature' label applied, which is a valid release-category label as required.
German Gender-Inclusive Language ✅ Passed All person-related nouns in the new German translations use gender-inclusive markers or avoid gendered terms entirely, consistent with the established convention of using asterisks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch auth/replace-zitadel-with-logto

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Strehk Strehk requested a review from m1212e April 5, 2026 00:52
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (1)
src/routes/(authenticated)/my-account/+page.server.ts (1)

62-65: Build the fallback account URL via URL instead of string concatenation.

The current concatenation can produce malformed paths in edge cases (for example, trailing slash/path variants). Using URL keeps this robust.

♻️ Suggested refactor
-	const accountCenterUrl =
-		configPublic.PUBLIC_OIDC_ACCOUNT_URL ??
-		configPublic.PUBLIC_OIDC_AUTHORITY.replace(/\/oidc\/?$/, '') + '/account';
+	const accountCenterUrl =
+		configPublic.PUBLIC_OIDC_ACCOUNT_URL ??
+		(() => {
+			const authority = new URL(configPublic.PUBLIC_OIDC_AUTHORITY);
+			authority.pathname = authority.pathname.replace(/\/oidc\/?$/, '/');
+			return new URL('account', authority).toString();
+		})();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/`(authenticated)/my-account/+page.server.ts around lines 62 - 65,
The fallback accountCenterUrl building currently uses string concatenation which
can create malformed URLs; update the logic that sets accountCenterUrl to use
the URL API instead of concatenation: if configPublic.PUBLIC_OIDC_ACCOUNT_URL is
present use it, otherwise construct a new URL('/account',
configPublic.PUBLIC_OIDC_AUTHORITY.replace(/\/oidc\/?$/, '')) (or equivalent
using new URL with eventUrl.origin as needed) so paths are joined correctly;
keep the existing accountRedirectUrl (`${eventUrl.origin}/my-account`) behavior
unchanged or likewise build it via new URL('/my-account', eventUrl.origin) for
consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Around line 39-43: The .env.example redirect URI is incorrect: update the
documentation to instruct registering the redirect URI as
<your-domain>/auth/login-callback to match the actual callback used by the code
(startSignin() in src/api/services/OIDC.ts); change the text that currently says
<your-domain>/auth/callback to <your-domain>/auth/login-callback so operators'
provider redirect URI matches the startSignin() behavior.

In `@prisma/seed/createMissingLogtoUsers.ts`:
- Around line 37-89: The Logto user creation (the POST to
`${LOGTO_ENDPOINT}/api/users` inside the for (const user of users) loop
producing response/body and logtoId) is non-idempotent and runs before the
Prisma transaction (db.$transaction) so failures leave orphaned Logto users;
change the flow to perform local preflight and use an existing Logto user if
present or move the Logto creation until after the transaction succeeds, or if
you must create it before the DB changes implement a compensating delete of that
Logto user (using logtoId) inside the catch/failure path of the db.$transaction
block; reference the variables/functions response, body, logtoId,
db.$transaction, tx.user.create, and tx.user.delete to locate where to add the
preflight check or compensating delete.
- Line 61: The progress logs in createMissingLogtoUsers.ts currently print raw
PII (user.email and identity provider IDs) to the console (see the
console.error/console.log calls that include user.email and any provider ID
values); change those log statements to avoid emitting full emails or stable
provider IDs by replacing them with a non-PII identifier (e.g., user.id, a
truncated/masked email like user.email.replace(/(.{2}).+(@.+)/, '$1***$2'), or a
short hash of the email) and remove or mask any provider ID strings before
logging, preserving the original message context (body.message or JSON) but not
the raw PII.
- Around line 58-65: Add a runtime type-narrowing guard before reading body.id:
after parsing body from response, verify body is a non-null object and that
typeof body.id === 'string' (and optionally typeof body.message === 'string' if
you access it); if the check fails, log an error including the raw body and
continue the loop instead of assigning to logtoId or performing DB operations.
Update the block around the existing response/body logic (where response.json()
is assigned and logtoId is set) to enforce this guard and handle the
invalid-response path safely.

In `@prisma/seed/migrateOidcSubs.ts`:
- Line 57: The stdout uses raw identifiers (mapping.identifier and
mapping.zitadel_id) which may expose emails and provider subject IDs; replace
those console.log calls in migrateOidcSubs.ts with masked versions (e.g., show
only first 1–3 chars + redacted placeholder or hash) or emit counts-only
messages; update both the log that currently prints mapping.identifier and
mapping.zitadel_id and the similar log later in the file to use a
mask/obfuscation helper (e.g., maskEmail/maskId) or a summarized message so no
full email or subject ID is written to stdout.
- Around line 51-87: Before calling tx.user.create with mapping.logto_id, detect
if a target user already exists and handle it instead of blindly creating—use
db.user.findUnique or tx.user.findUnique to check for a row with id ===
mapping.logto_id; if found, either move FK references to that existing target
and delete the source (perform the same FK UPDATE loop and source delete) or
skip this mapping and increment skipped, and only create the temporary/migrating
row when no target exists; update the logic in the block around tx.user.create /
tx.$executeRawUnsafe / tx.user.delete / tx.user.update to branch on the
existence of mapping.logto_id to avoid duplicate-key failures and half-completed
transactions.

In `@README.md`:
- Line 51: Fix the typo and wording in the README string that mentions the
example compose file: change "directoy" to "directory" and capitalize/format
"docker compose" as "Docker Compose" in the sentence that reads "You can find an
example docker compose file in the [example](./example/) directoy." Ensure the
updated sentence references the example directory and uses "Docker Compose"
consistently.

In `@schema.graphql`:
- Around line 2817-2819: OfflineUser now marks family_name and given_name
nullable, but fastUserQuery spreads OfflineUser into layout data and components
may assume non-null names; update the downstream consumers to defensively handle
nulls by coalescing or conditional-rendering: locate usages of
OfflineUser/family_name and OfflineUser/given_name in fastUserQuery results and
in components receiving layout data (search for fastUserQuery, layoutData
spread, and components that read family_name/given_name), then replace direct
access with safe fallbacks (e.g., use displayName = family_name ?? given_name ??
email ?? '' or conditionally render name blocks) so no runtime errors occur when
those fields are null while leaving ImpersonatedUser/ImpersonationUser handling
unchanged.

In `@src/api/context/oidc.ts`:
- Around line 123-137: Replace the inline role-parsing blocks that push into
OIDCRoleNames (the branches that read rolesRaw and the Object.keys path) with a
shared helper (e.g., parseOidcRoles or extractOidcRoleNames) that accepts
unknown rolesRaw and the allowed oidcRoles set/array and returns a string[] of
validated role names; inside the helper treat the input as unknown, narrow types
(Array.isArray, typeof element === 'string', element is object with typeof
element.name === 'string'), do NOT use any casts, extract the name string when
present, and filter the results against the allowed oidcRoles so only
'admin'|'member'|'service_user' are returned; replace both occurrences (user and
impersonated-user paths) to call this helper and append its returned values to
OIDCRoleNames.
- Around line 154-199: The code currently uses
decodeJwt(impersonationTokenSet.data.access_token) which does no signature
verification; replace this with a cryptographic verification using jwtVerify()
against the OIDC issuer JWKS (reuse the same JWKS fetching/verification logic
used for id_token verification in this file) to validate the impersonation
access_token before trusting its claims, then extract the verified payload and
use that to look up the DB user (symbols: decodeJwt -> jwtVerify,
impersonationTokenSet.data.access_token, issuer/JWKS fetch routine,
db.user.findUnique, impersonatedUser). Also ensure the actor check uses the
verified token payload (act.sub) and on verification failure or actor mismatch
delete the impersonation cookie (impersonationTokenCookieName), abort
impersonation and return the same token response path as currently implemented.

In `@src/api/resolvers/modules/auth.ts`:
- Around line 70-89: The code currently asserts custom JWT claims on
ctx.oidc.user into claims and casts values directly into hasPassword,
mfaVerificationFactors, ssoIdentities, and socialIdentities; add runtime
validation instead of blind casts by implementing small type guards (e.g.,
isBooleanClaim, isStringArrayClaim, isSsoIdentitiesArrayClaim that checks
objects have issuer and identityId strings) and use them when reading claims
from ctx.oidc.user/claims so that invalid shapes set the GraphQL fields to null
(or a safe default) rather than casting; alternatively, if you choose the
type-level fix, update the OIDCUser type to explicitly declare password, mfa,
sso_identities, and social_identities so the compiler enforces shapes at the
source (referencing ctx.oidc.user, claims, hasPassword, mfaVerificationFactors,
ssoIdentities, socialIdentities).

In `@src/api/resolvers/modules/impersonation.ts`:
- Around line 262-266: The resolver is accepting args.scope but never forwarding
it to performTokenExchange, so callers think down-scoping occurs while it is
ignored; update the impersonation resolver to pass args.scope into
performTokenExchange (e.g., performTokenExchange(ctx.oidc.tokenSet.access_token,
args.targetUserId, args.scope)) or alternatively remove the GraphQL scope
argument from the schema and resolver signature; look for usages of
performTokenExchange and args.scope in impersonation.ts and either forward
args.scope through the call or delete the scope argument and all references to
it.

In `@src/api/resolvers/modules/user.ts`:
- Around line 432-436: The current spreads use truthy checks (e.g.,
...(issuerUserData.family_name && { family_name: issuerUserData.family_name }))
which drop empty-string values; change these to nullish checks so intentional
empty values are preserved—replace each truthy guard for
issuerUserData.family_name, issuerUserData.given_name, and
issuerUserData.preferred_username with a nullish check (e.g., use
issuerUserData.family_name != null ? { family_name: issuerUserData.family_name }
: {} ) so only null/undefined are omitted while empty strings are allowed
through.

In `@src/api/services/OIDC.ts`:
- Around line 353-364: The token exchange builds tokenExchangeParams but omits
the actor_token fields so impersonation tokens are never bound; modify the
exchange to include actor_token and actor_token_type (use the existing
actorToken variable and set actor_token_type to
'urn:ietf:params:oauth:token-type:access_token') when actorToken is present, so
the request sent from the function that creates subjectToken and performs the
token exchange includes those fields and the resulting JWT contains the act
claim expected by the verification in src/api/context/oidc.ts.
- Around line 174-180: The decodeAccessTokenClaims helper currently returns
unverified claims from decodeJwt; stop merging those into session by either
verifying the access_token signature against the issuer JWKS before returning
claims (reuse the same JWKS verification logic used for id_token) or replace
usage with a server-side call to the userinfo endpoint to obtain authoritative
claims—update callers that merge claims into session to use the
verified/userinfo result instead of decodeAccessTokenClaims. Also fix
performTokenExchange to include the provided actorToken by adding an actor_token
entry to the tokenExchangeParams body so the token exchange request binds the
actor (preserve the existing subject_token behavior and include actor_token
alongside it).

In `@src/config/public.ts`:
- Line 11: Change the optional URL/string zod schemas to first normalize empty
strings to undefined using z.preprocess so empty env values are treated as
unset; specifically update the schemas for PUBLIC_OIDC_ACCOUNT_URL,
PUBLIC_SENTRY_DSN, PUBLIC_BADGE_GENERATOR_URL, PUBLIC_DOCS_URL (and in the other
config file for OIDC_M2M_RESOURCE and SENTRY_DSN) to use z.preprocess(v => v ===
"" ? undefined : v, z.string().url().optional() or z.string().optional() as
appropriate) so the existing nullish-coalescing logic (??) works and empty
strings no longer fail validation.

In `@src/routes/`(authenticated)/my-account/+page.svelte:
- Around line 193-263: The action anchors that render only an icon need
accessible names: update each <a class="btn btn-ghost btn-sm"
href={accountUrl(...)}> (used with accountUrl('username'), accountUrl('email'),
accountUrl('password'), accountUrl(hasPasskey ? 'passkey/manage' :
'passkey/add'), accountUrl(hasTotp ? 'authenticator-app/replace' :
'authenticator-app'), and accountUrl(hasBackupCodes ? 'backup-codes/manage' :
'backup-codes/generate')) to include either an appropriate aria-label (e.g.,
"Change username", "Change email", "Change password", "Manage passkeys" / "Add
passkey", "Replace authenticator app" / "Set up authenticator app", "Manage
backup codes" / "Generate backup codes") or add visually hidden text inside the
anchor so screen readers and keyboard users get descriptive labels; ensure
labels reflect the conditional text determined by hasPasskey, hasTotp, and
hasBackupCodes.

---

Nitpick comments:
In `@src/routes/`(authenticated)/my-account/+page.server.ts:
- Around line 62-65: The fallback accountCenterUrl building currently uses
string concatenation which can create malformed URLs; update the logic that sets
accountCenterUrl to use the URL API instead of concatenation: if
configPublic.PUBLIC_OIDC_ACCOUNT_URL is present use it, otherwise construct a
new URL('/account', configPublic.PUBLIC_OIDC_AUTHORITY.replace(/\/oidc\/?$/,
'')) (or equivalent using new URL with eventUrl.origin as needed) so paths are
joined correctly; keep the existing accountRedirectUrl
(`${eventUrl.origin}/my-account`) behavior unchanged or likewise build it via
new URL('/my-account', eventUrl.origin) for consistency.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3921efe3-6860-4a20-b750-3268391b70af

📥 Commits

Reviewing files that changed from the base of the PR and between f2587d1 and 574be1f.

📒 Files selected for processing (23)
  • .env.example
  • .github/actions/setup-bun/action.yml
  • .github/workflows/ci.yml
  • CLAUDE.md
  • README.md
  • WARP.md
  • dev.docker-compose.yml
  • messages/de.json
  • messages/en.json
  • mock-oidc-landingpage.html
  • prisma/seed/createMissingLogtoUsers.ts
  • prisma/seed/migrateOidcSubs.ts
  • schema.graphql
  • src/api/context/oidc.ts
  • src/api/resolvers/modules/auth.ts
  • src/api/resolvers/modules/impersonation.ts
  • src/api/resolvers/modules/user.ts
  • src/api/services/OIDC.ts
  • src/config/private.ts
  • src/config/public.ts
  • src/lib/queries/fastUserQuery.ts
  • src/routes/(authenticated)/my-account/+page.server.ts
  • src/routes/(authenticated)/my-account/+page.svelte

Comment on lines +174 to +180
function decodeAccessTokenClaims(access_token: string): Record<string, unknown> {
try {
return decodeJwt(access_token);
} catch {
return {};
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

git ls-files | head -20

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 552


🏁 Script executed:

find . -type f -name "*.ts" | grep -E "(OIDC|oidc)" | head -20

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 139


🏁 Script executed:

wc -l src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 115


🏁 Script executed:

sed -n '170,230p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 2050


🏁 Script executed:

cat -n src/api/context/oidc.ts 2>/dev/null || echo "File not found, searching for context files"

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 11585


🏁 Script executed:

find . -type f -name "*.ts" -path "*/context/*" | head -20

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 174


🏁 Script executed:

sed -n '220,280p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 2069


🏁 Script executed:

sed -n '300,380p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 2818


🏁 Script executed:

rg "jwtVerify|decodeJwt|access_token" src/api/services/OIDC.ts -A 2 -B 2

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 1572


🏁 Script executed:

rg "actor_token|actorToken" src/api/services/OIDC.ts -A 3 -B 3

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 1166


🏁 Script executed:

sed -n '400,440p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 1266


🏁 Script executed:

rg "actorToken|actor_token" src/api/ -A 1 -B 1 --type ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 916


🏁 Script executed:

sed -n '380,410p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 935


Do not merge unverified access-token claims into the authenticated session.

The decodeJwt call on line 176 performs no signature validation. Because the id_token is verified against the issuer's JWKS (line 190) but the access_token claims are not, an attacker who controls the client-stored token_set cookie can replace the access_token with a forged JWT and inject false roles, mfa, or password claims. These unverified claims are then merged into the session state on lines 202 and 224.

Either verify the access_token JWT signature with the issuer's JWKS before merging its claims, or fetch these claims server-side from the userinfo endpoint (which already uses the access_token for authentication).

The same vulnerability exists in the impersonation token handling (src/api/context/oidc.ts, line 158).


Add actor_token to the token exchange request.

The performTokenExchange() function accepts an actorToken parameter intended to bind the act claim to the resulting JWT, but it is never included in the token exchange request body (lines 355–387). Currently only the subject_token is sent, losing the audit trail of who initiated the impersonation. Include actor_token in tokenExchangeParams so downstream systems can verify the actor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/services/OIDC.ts` around lines 174 - 180, The decodeAccessTokenClaims
helper currently returns unverified claims from decodeJwt; stop merging those
into session by either verifying the access_token signature against the issuer
JWKS before returning claims (reuse the same JWKS verification logic used for
id_token) or replace usage with a server-side call to the userinfo endpoint to
obtain authoritative claims—update callers that merge claims into session to use
the verified/userinfo result instead of decodeAccessTokenClaims. Also fix
performTokenExchange to include the provided actorToken by adding an actor_token
entry to the tokenExchangeParams body so the token exchange request binds the
actor (preserve the existing subject_token behavior and include actor_token
alongside it).

Comment on lines +353 to +364
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<string, string> = {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token'
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the OIDC.ts file and examine the context around lines 353-364
find . -name "OIDC.ts" -path "*/api/*"

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 113


🏁 Script executed:

# Get the file size first to see if we can read it
wc -l src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 115


🏁 Script executed:

# Read the specific lines and surrounding context (lines 340-380)
sed -n '340,380p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 1463


🏁 Script executed:

# Also check the oidc.ts context file
find . -name "oidc.ts" -path "*/api/context/*"

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 112


🏁 Script executed:

# Read the oidc.ts context file to check the impersonation logic
cat src/api/context/oidc.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 9681


🏁 Script executed:

# Read the function that contains these lines - start from line 300 to see the function signature
sed -n '300,375p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 2643


🏁 Script executed:

# Also search for where actor_token might be used or any conditional additions to tokenExchangeParams
rg -A 20 "tokenExchangeParams" src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 1388


🏁 Script executed:

# Check if actorToken is added anywhere else after line 370
sed -n '350,420p' src/api/services/OIDC.ts

Repository: DeutscheModelUnitedNations/munify-delegator

Length of output: 2378


Include actor_token and actor_token_type in the token exchange request.

The actorToken parameter is received but only extracted for audit logging; it's never included in tokenExchangeParams sent to the OIDC token endpoint. The comment on lines 358–359 correctly notes that actor_token adds the act claim to the resulting JWT, but this field is omitted from the actual request. Downstream validation in src/api/context/oidc.ts only verifies impersonation ownership when decoded.act is present, creating a security gap where impersonation tokens lack binding to the user who initiated them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/services/OIDC.ts` around lines 353 - 364, The token exchange builds
tokenExchangeParams but omits the actor_token fields so impersonation tokens are
never bound; modify the exchange to include actor_token and actor_token_type
(use the existing actorToken variable and set actor_token_type to
'urn:ietf:params:oauth:token-type:access_token') when actorToken is present, so
the request sent from the function that creates subjectToken and performs the
token exchange includes those fields and the resulting JWT contains the act
claim expected by the verification in src/api/context/oidc.ts.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 10 file(s) based on 11 unresolved review comments.

A stacked PR containing fixes has been created.

  • Stacked PR: #416
  • Files modified:
  • .env.example
  • README.md
  • src/api/context/oidc.ts
  • src/api/resolvers/modules/auth.ts
  • src/api/resolvers/modules/impersonation.ts
  • src/api/resolvers/modules/user.ts
  • src/api/services/OIDC.ts
  • src/config/private.ts
  • src/config/public.ts
  • src/routes/(authenticated)/my-account/+page.svelte

Time taken: 19m 24s

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tade Strehk <git@strehk.eu>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/routes/(authenticated)/my-account/+page.svelte (1)

32-44: Consider preventing duplicate toast notifications on re-renders.

The $effect runs whenever data.accountUpdateSuccess changes. However, if the page component re-renders for other reasons while accountUpdateSuccess is still set (e.g., navigation back to this page), the toast may fire again unexpectedly. Consider tracking whether the toast was already shown for the current value.

♻️ Suggested improvement
+	let lastToastShown = $state<string | null>(null);
+
 	// Show toast for successful account center updates
 	$effect(() => {
 		const successMap: Record<string, () => 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]) {
+		if (data.accountUpdateSuccess && successMap[data.accountUpdateSuccess] && lastToastShown !== data.accountUpdateSuccess) {
 			toast.success(successMap[data.accountUpdateSuccess]());
+			lastToastShown = data.accountUpdateSuccess;
 		}
 	});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/`(authenticated)/my-account/+page.svelte around lines 32 - 44, The
effect currently calls toast.success whenever data.accountUpdateSuccess is
truthy which can re-fire on unrelated re-renders; add a small local tracker
(e.g., a module-scoped variable like lastShownSuccess or a Set) outside the
$effect to remember the last shown value, then inside the $effect compare
data.accountUpdateSuccess to that tracker and only call
toast.success(successMap[... ]()) and update the tracker when the value is new;
ensure you still handle different string keys (email, password, username,
passkey, mfa, backup-codes) and optionally clear the tracker when
accountUpdateSuccess is cleared so future updates can show again.
src/api/services/OIDC.ts (1)

57-67: Name splitting heuristic may produce unexpected results for some name formats.

The logic splits name on the last space, assuming a "Given Family" order. This works for many Western names but may produce unexpected results for:

  • Names with multiple middle names (e.g., "John Paul George Ringo" → given: "John Paul George", family: "Ringo")
  • Single-word names or mononyms (correctly handled by the fallback)
  • Names in "Family Given" order (common in some cultures)

Since family_name and given_name are now optional, this is acceptable as a best-effort fallback when the provider only supplies a combined name claim.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/services/OIDC.ts` around lines 57 - 67, The current heuristic splits
normalized.name on the last space which can misassign multi-part names; change
the logic in OIDC.ts (the block that manipulates normalized.given_name and
normalized.family_name) to only split when parts.length === 2 (set given_name =
parts[0], family_name = parts[1]); for single-word names keep the existing
fallback (set given_name = normalized.name and family_name = normalized.name or
leave family_name undefined as your policy dictates), and for names with more
than two parts avoid applying the "last-space" split (e.g., set given_name =
normalized.name and leave family_name undefined) so you don't incorrectly map
multi-part names.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Line 249: The file .env.example is missing a trailing newline; edit the file
so that the final line "# PUBLIC_SHA=abc123" is terminated with a newline
character (i.e., add an empty line at EOF) to satisfy dotenv-linter and standard
POSIX text file conventions.

---

Nitpick comments:
In `@src/api/services/OIDC.ts`:
- Around line 57-67: The current heuristic splits normalized.name on the last
space which can misassign multi-part names; change the logic in OIDC.ts (the
block that manipulates normalized.given_name and normalized.family_name) to only
split when parts.length === 2 (set given_name = parts[0], family_name =
parts[1]); for single-word names keep the existing fallback (set given_name =
normalized.name and family_name = normalized.name or leave family_name undefined
as your policy dictates), and for names with more than two parts avoid applying
the "last-space" split (e.g., set given_name = normalized.name and leave
family_name undefined) so you don't incorrectly map multi-part names.

In `@src/routes/`(authenticated)/my-account/+page.svelte:
- Around line 32-44: The effect currently calls toast.success whenever
data.accountUpdateSuccess is truthy which can re-fire on unrelated re-renders;
add a small local tracker (e.g., a module-scoped variable like lastShownSuccess
or a Set) outside the $effect to remember the last shown value, then inside the
$effect compare data.accountUpdateSuccess to that tracker and only call
toast.success(successMap[... ]()) and update the tracker when the value is new;
ensure you still handle different string keys (email, password, username,
passkey, mfa, backup-codes) and optionally clear the tracker when
accountUpdateSuccess is cleared so future updates can show again.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e114b1ff-05e7-47b3-b36c-db81fa2742de

📥 Commits

Reviewing files that changed from the base of the PR and between 574be1f and f67bf35.

📒 Files selected for processing (10)
  • .env.example
  • README.md
  • src/api/context/oidc.ts
  • src/api/resolvers/modules/auth.ts
  • src/api/resolvers/modules/impersonation.ts
  • src/api/resolvers/modules/user.ts
  • src/api/services/OIDC.ts
  • src/config/private.ts
  • src/config/public.ts
  • src/routes/(authenticated)/my-account/+page.svelte
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/config/private.ts
  • src/api/resolvers/modules/user.ts
  • src/api/resolvers/modules/impersonation.ts
  • src/config/public.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: Feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants