Skip to content

fix: stop requiring claims SAML 2.0 does not require - #21

Open
dougcain wants to merge 2 commits into
coldbox-modules:developmentfrom
dougcain:fix/saml-required-claims
Open

fix: stop requiring claims SAML 2.0 does not require#21
dougcain wants to merge 2 commits into
coldbox-modules:developmentfrom
dougcain:fix/saml-required-claims

Conversation

@dougcain

@dougcain dougcain commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Stacked on #20. Branched off feat/expose-claims-and-nameid, because it needs the claim map and the
NameID that PR exposes. Base is development since a cross-fork PR cannot target another fork's branch,
so the diff here shows #20's commit too — review or merge #20 first and this reduces to its own commit
(fix: stop requiring claims SAML 2.0 does not require). Happy to rebase it however suits you.

The problem

SAMLParsingService treats a missing givenname, surname or objectidentifier claim as fatal and fails
the whole response. SAML 2.0 requires none of them:

  • Core §2.3.3<Subject> is [Optional] on <Assertion>, and <AttributeStatement> is optional
    throughout.
  • Core §2.4.1 — a <Subject>, if present, needs either an identifier (BaseID/NameID/
    EncryptedID) or a <SubjectConfirmation>. Either one satisfies it.
  • Profiles §4.1.4.2 (Web Browser SSO) — tightens that to a <Subject> carrying a bearer
    <SubjectConfirmation> with Recipient/NotOnOrAfter, plus an <AudienceRestriction> and an
    <AuthnStatement>. Still nothing requiring a NameID, let alone an attribute.
  • The only place NameID becomes obligatory is <NameIDPolicy> on the AuthnRequest: ask for a Format the
    IdP cannot produce and it must fail with …status:InvalidNameIDPolicy rather than substitute one. It is
    the SP's request that makes it mandatory, not the protocol.

And all three names the module insists on are WS-Federation or Microsoft URIs — Entra's dialect, not the
protocol's. So a conformant assertion from ADFS, Shibboleth or Okta is refused over a display name, while
the identifier the profile actually points at goes unread. That is the wrong way round.

The change

  • The display-name claims are optional. firstName and lastName yield "" when the IdP asserts no
    givenname/surname, rather than failing the login. getName() already copes with a missing first name.
  • The subject is identified by objectidentifier where the IdP asserts one — preferred because it is
    stable across app registrations — and by the Subject's NameID where it does not. That is the only
    identifier a minimally conformant assertion carries.
  • A transient NameID is not accepted as an identifier. The specification defines it as valid for a
    single session, so keying identity to one enrols the same person again on every login — worse than
    refusing. An assertion carrying nothing else fails with SAMLParsingService.NoSubjectIdentifier and a
    message naming which of the two was missing, so the admin reading the log knows what to map in the IdP.
  • Whether the identifier it does return is portable stays the caller's judgement, from nameIdFormat
    (feat: expose the IdP's claim set and the Subject NameID #20). Entra's persistent NameID is pairwise, scoped to one app registration.
  • Email gains one fallback for the same reason: a NameID whose Format is
    urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress, which is what an IdP federating on email rather
    than on attributes sends. An empty email is not fatal — it never was, and it is a claim like any other.

Net effect: the only thing that now fails a signature-valid, Success-status assertion is having nothing
in it that identifies the subject.

Breaking

firstName and lastName may be empty on a successful response, where before they were either populated
or the response failed. A consumer that assumed a successful response carries a first name will now see
"". Noted as BREAKING in the changelog.

Deliberately not changed

  • BaseID and EncryptedID are still not read. EncryptedID needs the SP's decryption key, which the
    module has no configuration for, so honouring it is a feature rather than a fix. BaseID is abstract and
    has no in-the-wild use I can point at.
  • No fallback from the display-name claims to …/identity/claims/displayname or …/claims/name.
    Splitting a single display name into a first and last name is guesswork that gets Chinese and Spanish
    naming orders wrong, and the whole name is reachable via getClaim() now. Worth doing properly one day
    as a Name on the response, not smuggled into this.
  • NameIDPolicy is not sent on the AuthnRequest. Requesting persistent explicitly would be the
    matching change on the request side, but it alters what IdPs return for existing installs.

Testing

33 specs pass on both CI engines — BoxLang 1.17.0-snapshot and Lucee 5.4.8.2 — with format:check clean.
New coverage: an assertion with no display-name claims succeeds; the NameID identifies the subject when no
objectidentifier is asserted; a transient NameID with no objectidentifier is refused; an assertion whose
Subject holds only its bearer SubjectConfirmation is refused and still reports its claim set; and email
is read from an emailAddress-format NameID. The spec that pinned the old fatal-claim behaviour is
replaced rather than deleted.

The typed getters on ISSOAuthorizationResponse are the intersection of what four providers have in
common - email, a name, an id. Anything else an IdP asserts has nowhere to go: an Entra group or role
claim, a Google `hd`, a customer's employee-number claim. A consumer wanting one had to re-parse
getRawResponseData() itself, which means re-implementing the namespace handling this module already
does.

Adding a getter per claim would grow the interface without end, so this adds a map instead:
getClaims() keyed by the name the IdP used, and getClaim( name, default ) for the single-value case.
Closed to further growth, and it reads the same way for a SAML attribute as for an oAuth id token
claim, which is what makes it worth putting on the shared interface rather than on one provider.

A claim always holds an array. A SAML attribute may carry several AttributeValues - Entra's
authnmethodsreferences does, and its group claims do - and an IdP may split one claim across repeated
Attribute elements. The existing extraction took the first value of the first element, so the rest
were unreachable and nothing said so. Values that are not simple, a nested object in an id token,
are left out of the map and stay on getRawResponseData().

NameID gets its own pair of getters rather than a slot in the map, because it is not an attribute and
its Format changes what the value means: Entra's default is a pairwise identifier scoped to one app
registration, so the same person arrives under a different NameID at a second registration in the
same tenant. A caller that cannot see the Format cannot tell a portable identifier from a scoped one.
The response could not reach NameID at all before this.

MicrosoftSAMLProvider sets the claims on the success path only. An assertion whose signature did not
verify has asserted nothing, so a consumer reading a claim off a failed response would be trusting
whoever sent it rather than the IdP.

Deriving the typed fields from the claim set also fixes them for prefixed assertions. The old
extractors matched //Attribute[@name='...'], which resolves only when the assertion carries the SAML
namespace as its default - extractUserInfo() strips default namespace declarations and nothing else.
An IdP that prefixes its elements, as ADFS and Shibboleth do and Entra can be configured to, yielded
no first name, surname or object identifier, and the response came back as "Failed to extract user
information". Covered by a new prefixed fixture.

A missing givenname, surname or objectidentifier claim still fails the whole response, as it did
before. That looks wrong - a missing display-name claim is a poor reason to refuse a login - but it
is a question about required claims, not about reaching them, so it is left alone here.

29 specs pass on both CI engines: BoxLang 1.17.0-snapshot and Lucee 5.4.8.2.
SAMLParsingService treated a missing givenname, surname or objectidentifier claim as fatal and failed the
whole response. SAML 2.0 requires none of them. An AttributeStatement is optional throughout Core, and the
Web Browser SSO profile asks only for a Subject carrying a bearer SubjectConfirmation (Profiles 4.1.4.2) -
even a NameID is not mandatory there, only implied when the SP sends a NameIDPolicy the IdP must honour or
fail with InvalidNameIDPolicy. All three names are WS-Federation or Microsoft URIs, which is to say they
are Entra's dialect rather than the protocol's.

So the module refused a conformant assertion from ADFS, Shibboleth or Okta over a display name, while
ignoring the identifier the profile does point at. That is the wrong way round: the display names are the
optional part and the subject identifier is not.

The display-name claims are now optional and yield empty strings. The subject is identified by the
objectidentifier claim where the IdP asserts one, since it is stable across app registrations, and by the
Subject's NameID where it does not - which is the only value a minimally conformant assertion carries.

A transient NameID is not accepted. The specification defines it as valid for a single session, so keying
identity to one enrols the same person again on every login, which is worse than refusing the login. An
assertion carrying nothing else fails with SAMLParsingService.NoSubjectIdentifier and a message naming
which of the two was missing. Whether the identifier that is returned is portable stays the caller's to
judge from nameIdFormat: Entra's persistent NameID is pairwise, scoped to one app registration.

Email gains one fallback for the same reason - a NameID whose Format is the emailAddress format, which is
what an IdP that federates on email rather than on attributes sends. An empty email is not fatal; it never
was, and it is a claim like any other.

Breaking for a consumer that assumed a successful response carries a first name: firstName and lastName
may now be empty, where before they were populated or the response failed.

33 specs pass on both CI engines: BoxLang 1.17.0-snapshot and Lucee 5.4.8.2.
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.

1 participant