@mcp-abap-adt/auth-mocks
v0.1.1
Published
Protocol-faithful mock authorization servers (UAA/OAuth2, OIDC, SAML IdP) for testing @mcp-abap-adt packages
Maintainers
Readme
@mcp-abap-adt/auth-mocks
Protocol-faithful mock authorization servers (UAA/OAuth2, OIDC, SAML IdP) for
testing @mcp-abap-adt packages.
Installation
npm install --save-dev @mcp-abap-adt/auth-mocksOverview
This package is a standalone developer tool: it speaks HTTP, OAuth2 and SAML,
and imports nothing from @mcp-abap-adt/*. It exists so that other packages in
the family can test their authorization flows against a mock UAA/OAuth2
server, a mock OIDC provider and a mock SAML identity provider —
deterministically, without a live tenant.
Every mock starts and stops inside a test; nothing here is meant to run in
production. Each start* function returns a handle with .url, .port,
.requests (every request the mock received, oldest first, for assertions)
and close().
These mocks do not replace live testing. They prove the mock's own behaviour is internally consistent and, for SAML, that an independent verifier accepts what it should and rejects what it checks — they do not prove the mock's wire format matches a real UAA, a real OIDC provider or a real identity provider. See "What signature verification here does and does not prove" below for exactly what is and is not established, and keep running the real thing against a live tenant.
Quick start: visit and the openUrl seam
None of the mocks post a SAML assertion to the ACS themselves, and none of
them are reached without someone following a redirect. That someone is
normally a browser. visit() is a fake one, reduced to exactly what an
authorization flow needs: it follows HTTP redirects and, when it lands on an
auto-submitting HTML form (how the SAML POST binding works), it submits that
form too.
visit exists to be handed to whatever seam a consumer already uses to open
a browser. In @mcp-abap-adt/auth-providers, that seam is openUrl on the
default strategies (browserCallbackStrategy, oidcCallbackStrategy,
samlCallbackStrategy) — the provider builds an authorization URL and calls
openUrl with it instead of launching Chrome:
import { startMockUaa, visit } from "@mcp-abap-adt/auth-mocks";
import {
AuthorizationCodeProvider,
browserCallbackStrategy,
} from "@mcp-abap-adt/auth-providers";
const uaa = await startMockUaa();
try {
const provider = new AuthorizationCodeProvider({
uaaUrl: uaa.url,
clientId: "mock-client",
clientSecret: "mock-secret",
authorization: browserCallbackStrategy({
// openUrl's signature also carries a browser name and the bound
// redirect URI, neither of which visit() needs — it only wants the
// URL to fetch.
openUrl: async (url) => {
await visit(url);
},
}),
});
const { authorizationToken } = await provider.getTokens();
} finally {
await uaa.close();
}visit(url) itself needs nothing beyond the URL — no browser, no consumer
package, no shared state. That is why src/browser.ts imports nothing from
the rest of this package: it is a browser, not a mock-aware helper, and the
wiring above is what turns it into one for the duration of a login.
Mocks are strict by default
Every mock refuses what a real server would refuse, so a client's mistake shows up as the mock's answer instead of an assertion written by whoever wrote the mistake:
- An unregistered
client_idis refused directly at/authorize(never redirected — an unregistered client'sredirect_uricannot be trusted either). - An unregistered
redirect_uriis refused the same way, directly and never redirected, even for aclient_idthe mock does know. AUaaClientcarriesredirectUris?: string[], defaulting to['http://localhost:61001/callback']— the callback@mcp-abap-adt/auth-providersuses by default — and the match is exact, byte-for-byte string comparison (RFC 6749 §3.1.2.3), never a prefix or origin match. Without this a registeredclient_idwould carry anyredirect_urithrough, including an attacker's — an open redirect — and a provider misconfigured with the wrong callback would pass silently instead of being refused. response_typemust be exactlycode. Missing or set to anything else (token, for instance),/authorizerefuses it. Unlike the two checks above, this falls after the trust boundary —client_idandredirect_uriare already valid — so per RFC 6749 §4.1.2.1 it is reported at the callback: a302carryingerror(invalid_requestwhen absent,unsupported_response_typewhen present but wrong),error_description, and the mirroredstate.- A wrong or missing client secret at the token endpoint is refused as
invalid_client, with a401+WWW-Authenticatewhen the credentials arrived via theAuthorizationheader and a400when they arrived in the body, matching RFC 6749 §5.2 exactly rather than always answering one or the other. - Presenting two client authentication methods in one request is refused
as
invalid_clienttoo (RFC 6749 §2.3: "The client MUST NOT use more than one authentication method in each request"): anAuthorization: Basicheader together with a bodyclient_secret, or a bodyclient_idthat disagrees with the one Basic carries — refused even when every value agrees, because a bodyclient_secretis itself a credential and presenting it alongside Basic is two credentials regardless of whether they match. A bare, agreeing bodyclient_idalongside Basic is permitted: RFC 6749 §3.2.1 lets a client "use theclient_idrequest parameter to identify itself when sending requests to the token endpoint", and identification is not authentication — an agreeingclient_idtells the server nothing an attacker could not already read off the Basic header. This is also the shape@mcp-abap-adt/auth-providers' own OIDC client sends on every confidential-client token request (client_idalways in the body, Basic added whenever a secret exists), so a mock that refused it would refuse its own family's real traffic. Implemented once insrc/clientAuth.ts, shared by both mocks. - A malformed
Authorization: Basicheader is refused, never silently ignored. A payload that is not valid base64, or that decodes to a value with no:separator, used to leaveusedAuthorizationHeaderlookingfalse— as if Basic had never been attempted — so a caller who botched Basic could still get in on valid body credentials, and a malformed attempt against a client requiring Basic was answeredinvalid_clientas a plain unknown-client 400 rather than the 401 RFC 6749 §5.2 requires once Basic was attempted.readClientAuthnow tracks the header's presence (usedAuthorizationHeader, true the moment a request carries theBasicauth-scheme) separately from whether it could be parsed (malformedBasic);authenticateClientrefuses onmalformedBasicwith its owninvalid_clientmessage, before any fallback to body credentials and before the duplicate-method check above — a malformed attempt is its own mistake, not license to try the body instead. BecauseBuffer.from(…, 'base64')is lenient (it silently strips characters outside the alphabet rather than failing), the payload's shape is checked explicitly first, against RFC 4648 §4's standard base64 grammar. - The
Basicauth-scheme is matched case-insensitively, and tolerates more than one space before its payload: RFC 7235 §2.1's grammar isauth-scheme 1*SP token68, andauth-schemeis atoken— a case-insensitive keyword — sobasic,BASICandBaSiCall name the same scheme, and one-or-more spaces (not exactly one) separate it from the payload.Authorization: basic …used to look like no Basic header at all, sousedAuthorizationHeaderstayedfalseand the request fell through to body credentials — the same hole the malformed-Basic fix above closed, left open for every casing but the exact stringBasic. A bareBasicwith no payload at all is treated as an attempted-but-malformed Basic header, not as "no Basic was attempted", for the same reason: the alternative would let a client send the bare scheme plus valid body credentials and authenticate via the body. - A code or a refresh token is bound to the client it was issued to.
Register two clients and try to redeem the first client's code, or its
refresh token, while authenticated as the second, and the mock answers
invalid_grant— "the code/refresh token was issued to a different client" — even though the credential itself is real and unexpired. This rule is implemented once, insrc/clients.ts, and shared by both the UAA and OIDC mocks so they cannot disagree about what "issued to" means. - A used or expired authorization code is refused, as is a
redirect_urithat does not match the one used to request the code. - The OIDC mock demands PKCE.
/authorizerefuses a request with nocode_challenge, nocode_challenge_method, or a method other thanS256. Bothcode_challenge(at/authorize, per RFC 7636 §4.2) andcode_verifier(at/token, per §4.1) are also checked against RFC 7636's43*128unreservedshape before anything is hashed or compared —43to128characters fromALPHA / DIGIT / "-" / "." / "_" / "~". The two refusals a malformed or non-deriving verifier can get are different and mean different things: a value that does not fit the shape is refused asinvalid_request— a malformed request, refused before the hash is even computed — while a well-formed value that simply does not derive the stored challenge is refused asinvalid_grant, the existing proof-of-possession failure./tokenthen verifies the presentedcode_verifierderives the challenge.stateis mirrored back unchanged by default, never judged — validatingstateis the client's job, and a mock that checked it would hide whether the client does.startMockOidc({ state: 'wrongState' })and{ state: 'missingState' }exist to test that the client notices when a server does not behave. - The OIDC mock demands
scope=openid. OIDC Core §3.1.2.1 makesopenida required member ofscope— without it a request is a plain OAuth request, not an OIDC one, and a consumer that stopped sending it would otherwise pass silently against a mock that advertises OIDC discovery. Checked after the trust boundary, so — likeresponse_typeand PKCE — refused at the callback asinvalid_scope: RFC 6749 §4.1.2.1 definesinvalid_scopeas "the requested scope is invalid, unknown, or malformed," which is exactly this failure, and reusinginvalid_requestwould make a scope problem indistinguishable from a structurally malformed request.scopeis first checked against RFC 6749 §3.3's wholescope = scope-token *( SP scope-token )grammar — one or more space-separated tokens, no leading or trailing space, no doubled space — and only a value that passes is then tokenised and matched as whole tokens:scope=openidxis refused as not containingopenid, not accepted by a substring check that would wrongly seeopenidinside it, andscope=openid%20%20profile(a doubled space) or a leading/trailing space is refused as malformed beforeopenidmembership is even checked — with its owninvalid_scopemessage, since a malformed scope and a well-formed scope missingopenidare different mistakes. The ASCII character-class the grammar restricts each token to (RFC 6749 §3.3's%x21 / %x23-5B / %x5D-7E) is enforced; no other corner of the production is relaxed. - Refresh tokens rotate by default (
rotateRefreshTokens: true): each refresh exchange invalidates the presented token and issues a new one, and presenting an already-superseded token is refused as reuse — configurable tofalsefor servers that hand back the same refresh token indefinitely, since both behaviours exist among real UAAs. - The SAML bearer grant
(
urn:ietf:params:oauth:grant-type:saml2-bearer) can be'strict'(the default),'lenient', or'off'— see RFC 7522 andsamlBearer: 'strict'below. - The SAML IdP trusts a registered ACS, never whatever the request
names.
SamlOptions.acsUrlsis the SAML twin ofUaaClient.redirectUrisabove, checked the same way: anAuthnRequest'sAssertionConsumerServiceURLmust match one ofacsUrlsexactly, byte-for-byte — never by origin or prefix. There is no fixed default, because every test's ACS runs on an ephemeral port; omittingacsUrlsentirely refuses everyAuthnRequestwith a400, the faithful model for an IdP with no service-provider metadata at all. Without this check a forgedAuthnRequestcould aim a signed assertion at an attacker-controlled URL. See Mock SAML IdP below. - An
AuthnRequestmust carryID,Version="2.0"and a validIssueInstant. SAML Core §3.2.1 makes all three required onRequestAbstractType;/ssonow refuses a request missing any of them with a400naming which one, rather than silently treating a missingIDas an emptyInResponseTo.IDmust additionally be a well-formedxs:ID(NCName) — no leading digit, no spaces or colons — refusing a non-empty but malformed value such asID="123"orID="contains spaces"that previously flowed straight intoInResponseTo; only the ASCII subset ofNCNameis checked. AndIssueInstantis validated as an actual calendar date, not merely a parseable one:Date.parsenormalises rather than rejecting, soIssueInstant="2026-02-30T00:00:00Z"used to silently become 2 March and pass — the check now round-trips the captured year/month/day/etc. throughDate.UTCand refuses anything that does not survive unchanged, while still accepting a genuine leap day (2028-02-29T00:00:00Z). ItsZ/±HH:MMoffset, if present, is range-checked too:xsd:dateTime(XML Schema Part 2 §3.2.7) caps it at ±14:00 — hours00–14, minutes00–59, and when the hour is14the minute must be00— soIssueInstant="…+99:99"or"…+14:01"are refused even though both have the right shape;+14:00and-14:00(the legal extremes) are still accepted. - An
AuthnRequest'sDestination, when present, must name this IdP's own/ssoendpoint (SAML Core §3.2.1). A request delivered here carryingDestination="https://other-idp.example/sso"is refused with a400— otherwise a request built for one IdP could be replayed at another that happens to trust the same relying party.Destinationis optional onRequestAbstractType, and anAuthnRequestthat omits it is accepted by deliberate choice, not oversight: this family's ownAuthnRequestbuilder never sets it, and there is nothing to compare against when it is missing.
Options reference
Every option is optional; the defaults are what a test gets by passing nothing.
startMockUaa(options?: UaaOptions) and startMockOidc(options?: OidcOptions)
OidcOptions extends UaaOptions, so both accept everything below. Fields
inherited but inert for OIDC are marked.
| Option | Default | Meaning |
| ---------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| clients | one client, see below | Registered clients: UaaClient[], each { clientId, clientSecret, redirectUris? }. |
| clientId / clientSecret | mock-client / mock-secret | Shorthand for a single registered client. Ignored when clients is given. |
| redirectUris (per client) | [DEFAULT_REDIRECT_URI] | Permitted redirect URIs, compared byte-for-byte. A registered list replaces the default, it does not extend it. |
| codeLifetimeMs | 2000 | How long an authorization code stays redeemable. Short so an expiry test need not wait. |
| accessTokenLifetimeSeconds | 3600 | exp − iat on the minted JWT. |
| authorize | 'allow' | 'deny' redirects to the callback with error=access_denied. Inert for OIDC. |
| requireClientSecret | true | false skips the secret comparison; the registry lookup still refuses an unregistered client. |
| rotateRefreshTokens | true | Issue a new refresh token per refresh and refuse the superseded one. Inert for OIDC. |
| failRefresh | false | Refuse every refresh with invalid_grant. Inert for OIDC. |
| samlBearer | 'strict' | 'strict' enforces RFC 7522 §2.1, 'lenient' accepts what the family sends today, 'off' disables the grant. Inert for OIDC. |
| state (OIDC only) | 'mirror' | 'wrongState' returns a different value, 'missingState' omits it. The mock never validates state — that is the client's duty. |
DEFAULT_REDIRECT_URI is exported, so a test registering an extra URI
alongside the default need not retype the literal.
startMockSamlIdp(options?: SamlOptions)
| Option | Default | Meaning |
| ---------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| acsUrls | none | Permitted AssertionConsumerServiceURL values, compared byte-for-byte. With none registered the IdP refuses every AuthnRequest — no service-provider metadata, no single sign-on. |
| variant | 'valid' | Which single field of the response to corrupt; see the variants table. |
| issuer | 'mock-idp' | The Issuer on both the Response and the Assertion. |
| audience | 'mock-sp' | The Audience inside AudienceRestriction. |
SamlVariant is exported for typing a variant table of your own.
Handles and results
startServer, startMockUaa, startMockOidc and startMockSamlIdp all
resolve to a MockHandle: url, port, requests — every
RecordedRequest the mock received, oldest first, carrying method, path,
query, headers, body and raw — and close(), which resolves only
once the port is actually free. MockUaa adds
mintExpiredAccessWithValidRefresh(); MockSamlIdp adds certificatePem,
setVariant(), lastAssertionId() and repeatLastAssertion().
visit(url) resolves to a VisitResult: finalUrl, status, body.
Mock UAA (startMockUaa)
Authorization code grant at GET /oauth/authorize / POST /oauth/token,
refresh at the same token endpoint, and the SAML 2.0 bearer grant. Access
tokens are syntactically valid JWTs (mintJwt) carrying exp/iat; nothing
in the family verifies their signature, only the shape and expiry.
mintExpiredAccessWithValidRefresh() returns an already-expired access token
paired with a still-valid refresh token, for a refresh test that would
otherwise need to hand-craft a JWT or run a code flow and wait.
Mock OIDC (startMockOidc)
Discovery at GET /.well-known/openid-configuration, PKCE demanded at
/authorize and verified at /token, scope required to include openid,
state mirrored (or deliberately corrupted, see above). Client and
redirect_uri binding, client authentication, and the shape of a
callback-reported error are the same functions the UAA mock uses, from
src/clients.ts — not a second, independently-written copy that could
quietly disagree.
What this does and does not prove
The token exchange returns no id_token, though OIDC Core §3.1.3.3 requires
one for an openid-scoped authorization code exchange — a consumer whose
provider never validates an id_token passes silently against this mock.
The discovery document is a deliberate minimum subset — issuer,
authorization_endpoint, token_endpoint,
code_challenge_methods_supported, and response_types_supported — and
omits jwks_uri, subject_types_supported, and
id_token_signing_alg_values_supported, all three required by OIDC
Discovery §3, so a client using a conformant discovery library would refuse
it. A consumer's id_token handling therefore has no judge in this suite;
live testing against a real OIDC provider remains necessary and is not
made obsolete by these tests passing.
Mock SAML IdP (startMockSamlIdp)
GET /sso turns an HTTP-Redirect-bound AuthnRequest into an
auto-submitting HTML form carrying a samlp:Response, signed with a fresh,
per-instance, in-memory key pair and certificate (generateKeyMaterial,
signXml, certificatePem on the handle). It never posts to the ACS
itself — only a browser, or visit() standing in for one, does that; see
Quick start above.
Strict by default here too: the inflated request's document element must be
AuthnRequest in the urn:oasis:names:tc:SAML:2.0:protocol namespace — both
the local name and the namespace, not either alone — or /sso refuses it
with a 400. The same reasoning the RFC 7522 assertion check already
applies to the SAML bearer grant (uaa.ts's rejectNonAssertion): what
matters is the document element, not whether the attributes this handler
reads happen to decode somewhere in the document. A samlp:LogoutRequest
correctly namespaced but wrongly named, or a document in the right shape but
the wrong namespace, are both refused.
Once the document element checks out, four more rules apply, in order:
ID,VersionandIssueInstantare required (SAML Core §3.2.1,RequestAbstractType).IDmust be non-empty,Versionmust be exactly"2.0", andIssueInstantmust parse as anxsd:dateTime. A request missing any of these previously received a full signed response — a missingIDsilently became an emptyInResponseTorather than being refused.IDis further required to be a well-formedxs:ID(NCName, XML Namespaces 1.0 §3): a leading letter or underscore, then letters, digits,.,-or_— no leading digit, no spaces or colons. Only the ASCII subset ofNCNameis implemented; the full production's non-ASCIINameStartChar/NameCharranges are not. A non-empty but malformedIDsuch as"123"or"contains spaces"was previously accepted and forwarded intoInResponseTounchanged.IssueInstant's calendar is validated too, not just its lexical shape:Date.parsenormalises an impossible date rather than rejecting it —2026-02-30T00:00:00Zused to silently become 2 March and pass. The check now captures the year, month, day, hour, minute and second from the pattern, rebuilds the corresponding UTC instant withDate.UTC, and refuses unless every field survives unchanged; a genuine leap day (2028-02-29T00:00:00Z) still passes. Deliberately not implemented: thexsd:dateTimeend-of-day form (24:00:00), leap seconds, and negative (BCE) years.IssueInstant's offset (Zor±HH:MM) is bounds-checked separately from the calendar round-trip above:xsd:dateTime(XML Schema Part 2 §3.2.7) caps it at ±14:00 — hours00–14, minutes00–59, and when the hour is exactly14the minute must be00. The old shape regex matched any two digits on either side of the offset's colon and the round-trip never read it, so+99:99and+14:01both passed as long as the calendar portion was valid;+14:00and-14:00, the legal extremes, are still accepted, as is a plainZ.Destination, if present, must name this IdP's own/ssoendpoint (SAML Core §3.2.1). A recipient that receives a message carrying aDestinationmust check it names the endpoint the message actually arrived at, or a request built for one IdP could be replayed at another that happens to trust the same relying party.Destinationis optional onRequestAbstractType, and its absence is accepted — deliberately, not by oversight: this family's ownAuthnRequestbuilder never sets it, and there is nothing to compare against when it is missing.AssertionConsumerServiceURLmust be present, as before.AssertionConsumerServiceURLmust be registered.SamlOptions.acsUrlsis the service-provider metadata a real IdP consults before trusting a redirect target — the SAML twin ofUaaClient.redirectUrisinsrc/clients.ts, checked the same way: exact, byte-for-byte string comparison, never origin or prefix matching. There is no default:const acs = await startServer({ "POST /callback": (req, res) => { /* ... */ }, }); const idp = await startMockSamlIdp({ acsUrls: [`${acs.url}/callback`], });Omitting
acsUrlsis not a permissive default — it means this IdP has no relying party registered at all, so/ssorefuses everyAuthnRequestwith a400naming the missing registration, which is the faithful model: a real IdP with no service-provider metadata has nowhere it is willing to deliver an assertion to either.
Corruption variants
startMockSamlIdp({ variant }) and idp.setVariant(v) select one of twelve
shapes. Every variant changes exactly one field of an otherwise-valid
response, so a rejection is attributable to that field rather than to an
accumulation of mistakes. The Verified by column names what actually
proved each row: @node-saml/[email protected]'s validatePostResponseAsync
where it inspects that field, or "structural (canary)" where it does not —
read directly from the installed library's source
(src/__tests__/samlVerification.test.ts), not assumed from its docs.
| Variant | What changes | Verified by |
| ------------------- | ---------------------------------------------------------- | ----------------------------------------- |
| valid | (nothing — the baseline) | node-saml: accepted |
| unsigned | no <Signature> at all | node-saml: rejected (Invalid signature) |
| wrongKey | signed with an unrelated key pair | node-saml: rejected (Invalid signature) |
| tamperedAfterSign | signed content mutated after signing | node-saml: rejected (Invalid signature) |
| expired | NotOnOrAfter in the past | node-saml: rejected |
| notYetValid | NotBefore in the future | node-saml: rejected |
| wrongAudience | Audience does not match | node-saml: rejected |
| wrongInResponseTo | InResponseTo names no live request | node-saml: rejected |
| statusFailure | <samlp:Status> reports failure | structural (canary) — see below |
| wrongIssuer | Issuer does not match | structural (canary) — see below |
| wrongDestination | Response@Destination does not match the ACS | structural (canary) — see below |
| wrongRecipient | SubjectConfirmationData@Recipient does not match the ACS | structural (canary) — see below |
Four of the twelve rows have no independent judge here, for two distinct
reasons, both confirmed by reading node_modules/@node-saml/node-saml's
source rather than assumed from its documentation:
wrongDestinationandwrongRecipient— node-saml's response validation never readsDestinationorRecipientat all. Its source showsRecipientdoes not occur outside test fixtures, andDestinationoccurs only in the code that builds an outgoing request, never in the code that validates an incoming response.statusFailureandwrongIssuer— node-saml only reads the top-level<samlp:Status>inside the branch guarded byif (!("Assertion" in response))— that is, whenever anyAssertionelement is present in the response at all, signed or not, not specifically because it is validly signed — and only comparesidpIssueragainst the message on the logout path (verifyIssuer, called fromverifyLogoutRequest/verifyLogoutResponse), never fromvalidatePostResponseAsync. A Responder-failure status or a forged issuer riding alongside an Assertion is accepted outright on this path.
All four are asserted structurally instead (the corrupted field is
present and differs from valid, and the verifier resolves rather than
rejects) and pinned as canaries: if a future version of node-saml starts
checking one of them, the corresponding test in
src/__tests__/samlVerification.test.ts will start failing — signalling
"this field is judged now," not a defect in the mock. Until then, a mock IdP
that gets any of these four fields wrong has no automated judge in this
suite at all; only a live test against a relying party that enforces its
own checks on these fields would catch it.
Replay: a sequence, not a variant
A replayed SAML assertion is, in isolation, perfectly valid — that is
exactly why replay is dangerous, and exactly why no off-the-shelf verifier
rejects one on sight. Remembering which assertion IDs have already been
consumed is the relying party's job, not the identity provider's, so this
package cannot offer "the replay variant" the way it offers wrongAudience
or expired. What it offers instead is the two-step shape that makes
replay dangerous:
const idp = await startMockSamlIdp({ acsUrls: [`${acs.url}/callback`] });
// ... deliver an assertion once ...
idp.repeatLastAssertion(); // one-shot: reuses the previous assertion's ID next time
// ... deliver again ...Both deliveries are individually valid and share one assertion ID. A verifier that has never seen the first one accepts the second exactly as readily — which is the point: nothing in the second message is malformed, so only a relying party that remembers assertion IDs it has already consumed can catch the replay. This package proves the two deliveries are indistinguishable to a naive verifier rather than pretending to detect the replay itself — that detection is exactly what a consuming package's own validation strategy is responsible for building and is what this mock exists to let it be tested against.
There is a trap immediately next door that looks like replay detection but
is not: node-saml's request-ID cache is one-shot (removeAsync on
InResponseTo after a successful validation), so a second, freshly-minted
assertion answering the same, already-consumed AuthnRequest is rejected
too — for a reason that has nothing to do with the assertion ID repeating.
src/__tests__/samlVerification.test.ts covers this with a fresh second
assertion specifically so the rejection cannot be mistaken for replay
detection.
RFC 7522 and samlBearer: 'strict'
startMockUaa({ samlBearer: 'strict' }) (the default) enforces RFC 7522
§2.1: the assertion parameter of the SAML 2.0 bearer grant must be a
base64url-encoded (no +, /, or padding) <saml:Assertion> as the
document's own root element — not a <samlp:Response> wrapping one, and not
base64 with the standard alphabet.
This surfaced a real finding about a consumer rather than about the mock:
exchangeSamlAssertion in @mcp-abap-adt/auth-providers currently sends a
base64 samlp:Response, not a base64url Assertion. Against
samlBearer: 'strict', that request is refused. samlBearer: 'lenient'
accepts whatever a real, permissive UAA might accept without enforcing the
RFC's document-shape requirement, and samlBearer: 'off' refuses the grant
outright (unsupported_grant_type), for testing a server that never enabled
it. Use 'strict' to hold a client to the RFC; use 'lenient' or 'off'
only to reproduce a specific real server's looser or absent behaviour —
reaching for 'lenient' just to make a non-conformant client's test pass
would hide the same finding this package exists to surface.
What signature verification here does and does not prove
src/signing.ts generates a fresh, in-memory, self-signed certificate per
mock instance and signs SAML assertions with XML-DSig via xml-crypto. Its
own test suite (src/__tests__/signing.test.ts) exists to prove the
signature is real — bound to the content and to the private key — not to
prove the mock IdP is a faithful stand-in for a real one. Concretely:
- The package's own tests verify a signature with
xml-crypto— the same library that produced it. A canonicalisation or signing bug shared between producing and checking the signature would not be caught here. @node-saml/node-saml, used elsewhere in this family's test suites, independently checks signature validity,Conditionstimestamps andAudienceat the SAML-profile level — but it sharesxml-cryptounderneath for the actual cryptography, so it is not an independent implementation of XML-DSig either.- As detailed in Corruption variants above, it
checks none of
Destination,SubjectConfirmationData@Recipient, the top-level<samlp:Status>, or the Assertion'sIssueron the response path this suite exercises, so a mock IdP that gets any of those four wrong has no judge in this suite. - It has no assertion-ID replay cache — replaying a captured assertion is
not something this package or
@node-saml/node-samlwill flag; replay detection is the relying party's responsibility (see Replay: a sequence, not a variant above). - None of the above proves canonicalisation matches what a real identity provider produces. A green suite here is evidence the mock's own signature round-trips and that an independent library accepts what it should and rejects what it checks, not evidence the mock's SAML profile matches a live IdP byte-for-byte. Live testing against a real identity provider remains necessary and is not made obsolete by these tests passing.
