Skip to content

feat: CDI 5.8 - SAML client attributeMapping and acsURLs - #103

Draft
porcellus wants to merge 2 commits into
5.8from
feat/5.8-saml-attribute-mapping
Draft

porcellus wants to merge 2 commits into
5.8from
feat/5.8-saml-attribute-mapping

Conversation

@porcellus

Copy link
Copy Markdown
Collaborator

CDI 5.8 — SAML client attributeMapping and acsURLs (PLAN-M-016)

Core: supertokens-core#1506 (draft, 12.5.0), supertokens-plugin-interface#239, supertokens-postgresql-plugin#429. Keep this PR in draft until core#1506 merges, and re-check the field validation against the merged version.

Spec changes

PUT /appid-<appId>/<tenantId>/recipe/saml/clients

  • New optional attributeMapping (userId, email, firstName, lastName, each 1–10 claim names ≤ 512 chars, no surrounding whitespace, unknown keys rejected; null / {} = no mapping). New samlAttributeMapping schema with the default lists.
  • Documents acsURLs (accepted by core since 12.3.1 but never in a spec): allow-list for the login acsURL and for IdP-initiated Destination / Recipient; required when allowIDPInitiatedLogin is true; null / [] clear it.
  • Required-once-set rule (ADR-006 / ADR-012): on an update of a client that has either field, the key must be sent (value keeps, null clears); omitting it is a 400 naming the field. Below 5.8 the stored value is kept instead.
  • samlClient (PUT response and /recipe/saml/clients/list) carries acsURLs and attributeMapping when set.

POST /appid-<appId>/<tenantId>/recipe/saml/user

  • sub / email are resolved through the client's mapping (defaults for fields it leaves out); firstName / lastName are returned when mapped and found.

Follow-ups

  • supertokens-node#1082 and supertokens-python#658 (SDK units of PLAN-M-016) generate their types from this spec; the node generator fetches refs/heads/5.8/api_spec.yaml, so the 5.8 branch must exist on origin before they can run.

Stacked on 5.7; base branch 5.8 was cut from the tip of feat/5.7-users-count-ledger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SamDP1AS8ybBhCc1CWBWWk

@tamassoltesz tamassoltesz 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.

Checked against core #1506 (base: 12.4 tip), plugin-interface #239, and core 12.3.1 / 12.4 for acsURLs. Nothing blocking; the only v5_8 use in #1506 src/main is the required-once-set flag, so nothing gated is missing. Inline comments are precision fixes. As the PR body says, worth re-checking against #1506 once it merges.

Verified correct:

  • attributeMapping keys are exactly userId, email, firstName, lastName; unknown keys, wrong counts (1-10), non-strings, empty, padded and > 512 names are all 400s naming the key (SAMLAttributeMapping.java:82-114); null / {} mean no mapping; names match exactly and case-sensitively.
  • The default lists match DEFAULT_USER_ID / DEFAULT_EMAIL (:53-56), including NameID counting for email only with an @; fields a mapping leaves out use the defaults.
  • acsURLs: when set, the login acsURL must be a member (INVALID_CLIENT_ERROR); IdP-initiated responses are pinned to it; required when allowIDPInitiatedLogin is true (400 "acsURLs must contain at least one URL when allowIDPInitiatedLogin is true"); IdP-initiated login without a list is refused. null / [] clearing it is correct for 5.8 (on 12.4 acsURLs: null was a 400).
  • Required-once-set: gated exactly on SemVer.v5_8 (CreateOrUpdateSamlClientAPI.java:117); the 400 names the field; below 5.8 the stored value is kept; new clients (including generated ids) are exempt; JSON null counts as sent.
  • samlClient carries acsURLs / attributeMapping only when set (PI #239 toJson), on both PUT and /clients/list.
  • /recipe/saml/user: mapping read at call time; sub / email always present and nullable; firstName / lastName only when mapped and found.
  • info.version 5.8.0, the cdi-version example, the CHANGELOG entry; #1506 declares 5.8 and returns it as the latest version.

Comment thread api_spec.yaml Outdated
Exchange the single-use `code` returned by `/recipe/saml/callback` (passed as `accessToken`) for the authenticated user's SAML claims.
The code is removed on first use; a second call returns `INVALID_TOKEN_ERROR`.

For a client with an `attributeMapping`, `sub`, `email`, `firstName` and `lastName` are resolved through it at the time of this call (using the client's current mapping, with the default lists for fields it leaves out). A client without a mapping uses the built-in rules described on each field.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The built-in rules for clients without a mapping aren't the default lists, and the sub / email descriptions below now only describe the lists. Without a mapping, the first claim present wins and its value is taken as is (SAML.java:989-994 @ #1506: if (claims.has("NameID")) { sub = …get(0).getAsString(); }, same pattern for emailaddress / mail / email at :1008-1016). With a mapping, blank values are skipped (SAMLAttributeMapping.java:134).

So a blank NameID gives sub: "" without a mapping and falls through with one (core's CHANGELOG states this difference). Suggest describing the built-in chain separately on sub / email, or noting the blank-value difference here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6ff90b4: sub / email now describe the built-in chain (first present claim, taken as is, so a blank NameID gives an empty sub) separately from the mapping resolution (blank values fall through); the schema description and CHANGELOG note the difference.

Comment thread api_spec.yaml Outdated
type: object
nullable: true
description: |
Which assertion claims supply the user's id, email, first name and last name, as ordered fallback lists of claim names (attribute `Name`s; `NameID` is the Subject NameID). For each field the first listed claim with a non-blank value wins. For `email`, `NameID` only counts if it contains an `@`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only each claim's first value is considered: SAMLAttributeMapping.java:189-193 takes array.get(0) and skips the claim if that is blank, even if a later value isn't. Suggest "the first listed claim whose first value is non-blank wins" (matches the core CHANGELOG wording).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6ff90b4: "first listed claim whose first value is non-blank wins (only a claim's first value is considered)".

Comment thread api_spec.yaml Outdated
description: |
Optional allow-list of Assertion Consumer Service URLs. When set, the `acsURL` given to `/recipe/saml/login` must be a member, and the `Destination` / `SubjectConfirmationData.Recipient` of an IdP-initiated response must be a member.
Required (at least one URL) when `allowIDPInitiatedLogin` is true; IdP-initiated logins for a client without one are refused.
Required once set (see above). `null` and `[]` clear it. Entries must be non-empty strings.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core also rejects whitespace-only and non-string entries (SAML.java:500: !a.isJsonPrimitive() || !…isString() || a.getAsString().trim().isEmpty(), 400 "acsURLs must only contain non-empty strings"). Suggest "Entries must be strings that are not empty or whitespace-only".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6ff90b4: "strings that are not empty or whitespace-only".

Comment thread api_spec.yaml Outdated
type: array
nullable: true
description: |
Optional allow-list of Assertion Consumer Service URLs. When set, the `acsURL` given to `/recipe/saml/login` must be a member, and the `Destination` / `SubjectConfirmationData.Recipient` of an IdP-initiated response must be a member.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Destination and SubjectConfirmationData.Recipient are only checked when present (SAML.java:548-549, :562-563: if (destination != null && !allowedAcs.contains(destination))). Suggest "…must be a member when present".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6ff90b4: "must be a member when present".

Comment thread api_spec.yaml Outdated
- `email`: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`, `mail`, `email`, `NameID`
- `firstName`, `lastName`: none

Each list has 1 to 10 names; each name is a non-empty string of at most 512 characters with no leading or trailing whitespace (names match claims exactly and case-sensitively). Unknown keys are rejected. Invalid mappings return a 400 naming the key.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: the limit is s.length() > 512, i.e. UTF-16 code units, and the whitespace check is String.trim(), which only strips characters up to U+0020 (SAMLAttributeMapping.java:107-111). Only matters for non-BMP or Unicode-whitespace claim names; mention it only if you want the spec exact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Took the UTF-16 code units part in 6ff90b4; left trim() semantics out to keep it readable.

porcellus pushed a commit that referenced this pull request Oct 28, 2026
- built-in rules (no mapping) take the first present claim as is; a mapping
  skips blank values - described separately on sub/email
- only a claim's first value is considered
- acsURLs entries: strings, not empty or whitespace-only; Destination/Recipient
  checked when present
- 512 is UTF-16 code units

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SamDP1AS8ybBhCc1CWBWWk
@porcellus

Copy link
Copy Markdown
Collaborator Author

Round 1 applied in 6ff90b4 (also carries the #100–#102 wording fixes). Still draft pending core#1506.

- built-in rules (no mapping) take the first present claim as is; a mapping
  skips blank values - described separately on sub/email
- only a claim's first value is considered
- acsURLs entries: strings, not empty or whitespace-only; Destination/Recipient
  checked when present
- 512 is UTF-16 code units
- CHANGELOG: acsURLs itself is documented in 5.4.1; 5.8 adds null-clearing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SamDP1AS8ybBhCc1CWBWWk
@porcellus

Copy link
Copy Markdown
Collaborator Author

Restructured: this branch now carries only its own version's changes (the earlier push had duplicated the 5.4.1 / 5.5 wording fixes as extra commits). Those arrive by merging the base branches forward — #105 → 5.4 → 5.5 (#104) → 5.6 (#101) → 5.7 (#102) → 5.8 (#103) — merging each base into the next feature branch as it lands. Expected conflicts: the CHANGELOG (adjacent sections) on each step, the users/count text in #102, and the SAML client / userinfo text in #103; the intended merged result per branch is in the PR author's notes.

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