Skip to content

feat: propagate arbitrary identity traits into consent session tokens - #389

Closed
bobbyiliev wants to merge 3 commits into
ory:masterfrom
bobbyiliev:propagate-extra-identity-traits
Closed

bobbyiliev wants to merge 3 commits into
ory:masterfrom
bobbyiliev:propagate-extra-identity-traits

Conversation

@bobbyiliev

@bobbyiliev bobbyiliev commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

extractSession in src/routes/consent.ts hardcodes which Kratos identity attributes are propagated into the Hydra consent session: email (from verifiable_addresses[0]) and a handful of profile claims (preferred_username, website, given_name, family_name, name, updated_at). Any other trait defined on the identity is silently dropped when Hydra issues the ID / access token.

This bites deployments that store custom traits on the Kratos identity and want them in the JWT for downstream authorization. For example: groups extracted from a SAML assertion (via Polis or another SAML-to-OIDC bridge) or upstream OIDC groups claim, department, tenant_id, etc.

Today the only workarounds are to fork the UI or to patch consent.js in place at pod startup, both of which break on any upstream refactor.

Change

Adds two optional env vars:

  • SESSION_EXTRA_TRAITS_ID_TOKEN: comma-separated list of trait names to copy from identity.traits into session.id_token.
  • SESSION_EXTRA_TRAITS_ACCESS_TOKEN: same, for session.access_token.

Missing traits are skipped silently. Copying is unconditional on OIDC scope (customers who want a groups claim without also granting the profile scope shouldn't have to). Values are copied verbatim, so array / string / object shapes all flow through as-is.

Example

Kratos identity schema declares a groups trait; the OIDC mapper populates it from the upstream provider. Setting

SESSION_EXTRA_TRAITS_ID_TOKEN=groups

on the UI pod gets "groups": ["engineering", "admins"] into every issued ID token. Downstream apps consume the claim like any other JWT field.

Alternatives considered

  • jsonnet-style mapping (mirroring Kratos's OIDC mapper): more powerful (rename, transform, filter) but higher lift and duplicates config surface that Kratos already exposes.
  • Webhook at consent time: most flexible but a much bigger design change.

Env-driven allowlist is the smallest change that covers the "just include this trait" case.

Test plan

  • tsc --noEmit passes
  • prettier --check clean
  • Manual: deploy patched image with SESSION_EXTRA_TRAITS_ID_TOKEN=groups, confirm groups appears in the ID token issued via a Hydra OAuth2 authorization code flow.
  • No existing test infra in this repo ("test": "exit 0"), otherwise happy to add cases.

Summary by CodeRabbit

  • New Features

    • Consent sessions can now include additional identity trait values in issued token payloads when explicitly configured via environment variables.
    • Only allowlisted trait names are copied, and missing optional traits are ignored; the configured traits are included in every issued token regardless of requested OIDC scopes.
  • Documentation

    • Updated Configuration docs to describe the new optional environment variables and how to provide a comma-separated trait allowlist.

…ESSION_EXTRA_TRAITS_ID_TOKEN and SESSION_EXTRA_TRAITS_ACCESS_TOKEN env vars
@bobbyiliev bobbyiliev changed the title Propagate arbitrary identity traits into consent session tokens feat: propagate arbitrary identity traits into consent session tokens Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@bobbyiliev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 929675f1-d8c2-4230-845f-b77a165a64ea

📥 Commits

Reviewing files that changed from the base of the PR and between bcc255a and 0636dc8.

📒 Files selected for processing (1)
  • .github/workflows/format.yml
📝 Walkthrough

Walkthrough

Adds allowlist-based propagation of selected identity traits into consent session id_token and access_token payloads, controlled by two new optional environment variables. Includes parsing and copying helpers, route wiring, and README updates.

Changes

Trait propagation feature

Layer / File(s) Summary
Trait parsing and copying helpers
src/routes/consent.ts
Adds parseTraitList to parse comma-separated env vars into trait name lists, and copyTraitsInto to copy only allowed keys from identity.traits into a target token map.
Wire propagation into extractSession and document config
src/routes/consent.ts, README.md
Extends extractSession to copy traits listed in SESSION_EXTRA_TRAITS_ID_TOKEN and SESSION_EXTRA_TRAITS_ACCESS_TOKEN into session.id_token and session.access_token respectively, without OIDC scope gating; README documents both variables with usage and behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change well, but it omits the required Related Issue/Design Document reference for this new feature and the checklist. Add a Related Issue/Design Document link for the feature and complete the relevant checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding support for propagating arbitrary identity traits into consent session tokens.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
README.md (1)

59-68: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Document that trait propagation is scope-independent.

The README accurately describes the mechanics but doesn't mention that these traits are copied unconditionally — regardless of which OIDC scopes the client requested. This differs from the built-in email and profile claim mappings, which are scope-gated. Adding a brief note would help operators understand the privacy implications.

📝 Suggested addition
 - `SESSION_EXTRA_TRAITS_ID_TOKEN` (optional): Comma-separated list of Kratos
   identity trait names to propagate verbatim into `session.id_token` during the
   OAuth2 consent flow. Any trait present on `identity.traits` under one of the
   listed names is copied to the ID token; missing traits are skipped. Useful
   when downstream apps rely on identity attributes that aren't part of the
   built-in `email` / `profile` claim mapping (e.g. `groups`, `department`,
-  `tenant_id`). Example: `SESSION_EXTRA_TRAITS_ID_TOKEN=groups,department`.
+  `tenant_id`). Unlike the built-in `email` and `profile` claims, these traits
+  are copied regardless of the OIDC scopes requested by the client. Example:
+  `SESSION_EXTRA_TRAITS_ID_TOKEN=groups,department`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 59 - 68, Update the README entry for
SESSION_EXTRA_TRAITS_ID_TOKEN and SESSION_EXTRA_TRAITS_ACCESS_TOKEN to
explicitly state that these extra traits are propagated unconditionally,
independent of the OIDC scopes requested by the client. Keep the note close to
the existing description in README.md and contrast it with the built-in
email/profile claim mappings so operators understand the scope-independent
behavior.
src/routes/consent.ts (1)

15-34: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider memoizing parseTraitList results.

parseTraitList(process.env.SESSION_EXTRA_TRAITS_ID_TOKEN) and parseTraitList(process.env.SESSION_EXTRA_TRAITS_ACCESS_TOKEN) are called on every consent request, but environment variables are immutable during the process lifetime. Parsing could be done once at module load.

♻️ Optional refactor: parse once at module scope
+const EXTRA_TRAITS_ID_TOKEN = parseTraitList(process.env.SESSION_EXTRA_TRAITS_ID_TOKEN)
+const EXTRA_TRAITS_ACCESS_TOKEN = parseTraitList(process.env.SESSION_EXTRA_TRAITS_ACCESS_TOKEN)
+
 const copyTraitsInto = (
   target: Record<string, unknown>,
   traits: Record<string, unknown> | undefined,
   names: string[],
 ): void => {
   if (!traits) return
   for (const name of names) {
     if (Object.prototype.hasOwnProperty.call(traits, name)) {
       target[name] = traits[name]
     }
   }
 }

Then at the call sites:

   copyTraitsInto(
     session.id_token as Record<string, unknown>,
     identity.traits as Record<string, unknown> | undefined,
-    parseTraitList(process.env.SESSION_EXTRA_TRAITS_ID_TOKEN),
+    EXTRA_TRAITS_ID_TOKEN,
   )
   copyTraitsInto(
     session.access_token as Record<string, unknown>,
     identity.traits as Record<string, unknown> | undefined,
-    parseTraitList(process.env.SESSION_EXTRA_TRAITS_ACCESS_TOKEN),
+    EXTRA_TRAITS_ACCESS_TOKEN,
   )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/consent.ts` around lines 15 - 34, parseTraitList is being
recomputed on every consent request even though process.env values are static
for the process lifetime. Move the
parseTraitList(process.env.SESSION_EXTRA_TRAITS_ID_TOKEN) and
parseTraitList(process.env.SESSION_EXTRA_TRAITS_ACCESS_TOKEN) calls to module
scope in src/routes/consent.ts, keep using parseTraitList and copyTraitsInto for
locating the logic, and reuse the cached arrays at the call sites instead of
parsing inside the request path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/consent.ts`:
- Around line 87-102: The trait-copying logic in consent handling applies
operator-configured extras to both tokens without any OIDC scope gate, so update
the project documentation to explicitly describe this behavior and its
consent/privacy implications. Reference the consent token enrichment flow in
copyTraitsInto, session.id_token, session.access_token, and parseTraitList so
operators understand that SESSION_EXTRA_TRAITS_ID_TOKEN and
SESSION_EXTRA_TRAITS_ACCESS_TOKEN are scope-independent before enabling them.

---

Nitpick comments:
In `@README.md`:
- Around line 59-68: Update the README entry for SESSION_EXTRA_TRAITS_ID_TOKEN
and SESSION_EXTRA_TRAITS_ACCESS_TOKEN to explicitly state that these extra
traits are propagated unconditionally, independent of the OIDC scopes requested
by the client. Keep the note close to the existing description in README.md and
contrast it with the built-in email/profile claim mappings so operators
understand the scope-independent behavior.

In `@src/routes/consent.ts`:
- Around line 15-34: parseTraitList is being recomputed on every consent request
even though process.env values are static for the process lifetime. Move the
parseTraitList(process.env.SESSION_EXTRA_TRAITS_ID_TOKEN) and
parseTraitList(process.env.SESSION_EXTRA_TRAITS_ACCESS_TOKEN) calls to module
scope in src/routes/consent.ts, keep using parseTraitList and copyTraitsInto for
locating the logic, and reuse the cached arrays at the call sites instead of
parsing inside the request path.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b3b2265c-913b-4e0f-91c1-e2b24ff8250a

📥 Commits

Reviewing files that changed from the base of the PR and between 9fe4512 and 7a77d40.

📒 Files selected for processing (2)
  • README.md
  • src/routes/consent.ts

Comment thread src/routes/consent.ts
Comment on lines +87 to +102

// Propagate additional identity traits into the session tokens, based on
// operator-provided allowlists. Applied unconditionally (no scope gate) so
// downstream apps get the trait even when the OIDC client requested only
// openid. Missing traits are skipped silently.
copyTraitsInto(
session.id_token as Record<string, unknown>,
identity.traits as Record<string, unknown> | undefined,
parseTraitList(process.env.SESSION_EXTRA_TRAITS_ID_TOKEN),
)
copyTraitsInto(
session.access_token as Record<string, unknown>,
identity.traits as Record<string, unknown> | undefined,
parseTraitList(process.env.SESSION_EXTRA_TRAITS_ACCESS_TOKEN),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Security consideration: traits propagate unconditionally regardless of OIDC scope.

The code explicitly copies extra traits into id_token and access_token without any scope check. This means any OIDC client — even one requesting only openid — will receive all operator-configured traits (e.g., groups, tenant_id) in its tokens. While this is by design per the PR objectives, operators should be aware that these traits bypass the standard scope-based consent model. Consider documenting this scope-independent behavior in the README so operators understand the privacy implications before enabling it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/consent.ts` around lines 87 - 102, The trait-copying logic in
consent handling applies operator-configured extras to both tokens without any
OIDC scope gate, so update the project documentation to explicitly describe this
behavior and its consent/privacy implications. Reference the consent token
enrichment flow in copyTraitsInto, session.id_token, session.access_token, and
parseTraitList so operators understand that SESSION_EXTRA_TRAITS_ID_TOKEN and
SESSION_EXTRA_TRAITS_ACCESS_TOKEN are scope-independent before enabling them.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants