@guestcraft/identity-provider-simulator
MIT, stateful test oracle of the identity provider side of single sign-on: an OpenID Connect provider and a SAML 2.0 identity provider over one set of users. It is written from the specifications, not from GuestCraft’s, Better Auth’s or samlify’s code; every behavior cites its specification section in a comment next to it. Its runtime imports no GuestCraft package and no SSO, SAML or JOSE library of the code it tests (a dependency-cruiser rule enforces it), and it signs ID tokens with node:crypto directly. It is a test oracle so that CI never talks to a live identity provider, not a production IdP, and passing its tests proves nothing about a particular vendor’s IdP.
Entry points
Section titled “Entry points”createIdentityProviderSimulator({ baseUrl?, now?, randomBytes?, clients?, users?, serviceProviders? })
returns a simulator. baseUrl (default https://idp.example.com) is the OIDC issuer and the base of
every endpoint; now and randomBytes make codes, tokens, XML IDs, times and the certificate
repeatable. RSA-2048 keys and the self-signed certificate are generated at runtime, on first use;
nothing secret is committed.
addClient({ clientId, clientSecret, redirectUris, tokenEndpointAuthMethods? }): an OIDC client; bothclient_secret_basicandclient_secret_postunless restricted.addUser({ subject, email?, emailVerified?, name?, givenName?, familyName?, acr?, amr?, authnContextClassRef? }): absent attributes are left out of every token, assertion and UserInfo response.addServiceProvider({ entityId, assertionConsumerServiceUrls, nameIdFormat? }): a SAML SP;nameIdFormatisemailAddress(default, the user’s e-mail) orpersistent(the subject).signInAs(subject): the user every following authorization signs in. There is no login form: the authorization and SSO endpoints answer at once for this user (the first user by default).injectFault(fault)/clearFault(): see Faults.fetch(input, init?): answers any endpoint in process, shaped like the globalfetch.oidc.discoveryUrl;saml.entityId,saml.ssoUrl,saml.metadataUrl,saml.certificate(PEM),saml.metadata(),saml.parseRedirectRequest(url)andsaml.respond(authnRequest), which returns{ acsUrl, samlResponse, relayState }for the HTTP-POST binding.
@guestcraft/identity-provider-simulator/server exports
startIdentityProviderServer(simulator, { port?, host? }?) for relying parties in other processes
and browsers. It listens on 127.0.0.1 and a free port unless given (the SSO track’s fixed ports
are 7160–7169), makes its address the simulator’s base URL, so that the issuer, the metadata and
every endpoint name the server, and returns { baseUrl, close() }.
| Path under the base URL | Behavior | Specification |
|---|---|---|
/.well-known/openid-configuration |
Issuer, endpoints, code only, public subjects, RS256, both client authentication methods, scopes openid email profile, PKCE S256 |
Discovery §3, §4; RFC 8414 §2 for code_challenge_methods_supported |
/oidc/authorize (GET, POST) |
Validates client_id and the exact redirect_uri (refused with 400, never redirected), then response_type=code, the openid scope, a S256 code_challenge and repeated parameters, redirecting errors with state; issues a single-use code valid for 60 s, keeping state and nonce |
Core §3.1.2.1; RFC 6749 §3.1, §4.1.1, §4.1.2, §4.1.2.1; RFC 7636 §4.3, §4.4.1 |
/oidc/token (POST form) |
Client authentication by Basic (form-encoded credentials) or body, never both; authorization_code only; the code is burned on first use and a replay revokes its tokens; identical redirect_uri; code_verifier checked against the challenge; errors as JSON with no-store, invalid_client as 401 with a Basic challenge when Basic was used |
RFC 6749 §2.3, §2.3.1, §4.1.2, §4.1.3, §5.1, §5.2, §10.5; RFC 7636 §4.1, §4.6 |
/oidc/jwks |
One RSA key, kid = RFC 7638 thumbprint |
RFC 7517 §5; RFC 7518 §6.3.1 |
/oidc/userinfo (GET, POST) |
Bearer token in the Authorization header; sub plus the scope’s claims; 401 with a Bearer challenge (error="invalid_token" for an unknown, expired or revoked token) |
Core §5.3; RFC 6750 §2.1, §3.1 |
/saml/metadata |
EntityDescriptor with an IDPSSODescriptor: signing KeyDescriptor, NameIDFormats, SingleSignOnService for HTTP-Redirect and HTTP-POST; application/samlmetadata+xml |
Metadata §2.3.2, §2.4.1.1, §2.4.3, §4.1 |
/saml/sso (GET Redirect, POST) |
Decodes the AuthnRequest, checks the SP, its ACS URL and a present Destination, and answers an HTML page that auto-posts SAMLResponse and RelayState to the ACS URL, not cached |
Bindings §3.4.4.1, §3.4.5.1, §3.5.4, §3.5.5.1; Core §3.2.1; Profiles §4.1.4.1 |
The ID token carries iss, sub, aud (the client ID), exp (10 minutes), iat, auth_time,
nonce when the request sent one, the user’s acr/amr, and the claims of the requested scopes
(email, email_verified; name, given_name, family_name) as many IdPs do (Core §2, §5.4).
Access tokens last an hour.
The SAML Response (Core §3.2.2, Profiles §4.1.4.2) has Destination, InResponseTo, Issuer and
a Success status, and one assertion with Issuer, an enveloped signature right after it, a
NameID, a bearer SubjectConfirmation (Recipient, NotOnOrAfter, InResponseTo),
Conditions (NotBefore, NotOnOrAfter, AudienceRestriction with the SP’s entity ID),
an AuthnStatement (AuthnInstant, SessionIndex, AuthnContextClassRef, PasswordProtectedTransport
unless the user names another) and the email, givenName and surname attributes. Assertions are
valid for five minutes. The signature (Core §5.4, W3C XML Signature) references the assertion’s ID,
with exclusive canonicalization, RSA-SHA256 and a SHA-256 digest, and carries the certificate in
KeyInfo; xml-crypto computes it over XML the simulator builds itself.
Faults
Section titled “Faults”One fault at a time: injectFault throws while another is armed. A fault stays armed until the
step it affects runs, which uses it up.
| Fault | Effect |
|---|---|
oidc-access-denied |
The next authorization redirects back with error=access_denied and the state |
oidc-token-error |
The next token request is answered invalid_grant (the code is burned) |
oidc-wrong-issuer, oidc-wrong-audience |
The next ID token has another iss / aud, correctly signed |
oidc-expired-id-token |
The next ID token expired an hour ago |
oidc-unknown-signing-key |
The next ID token names the JWKS kid but is signed by another key |
oidc-alg-none |
The next ID token is an Unsecured JWT (alg: none, empty signature; RFC 7519 §6.1) |
oidc-missing-email |
The next sign-in’s ID token and UserInfo leave out email and email_verified |
oidc-userinfo-subject-mismatch |
The next UserInfo response has another sub (Core §5.3.2) |
saml-unsigned-assertion |
The assertion carries no signature |
saml-foreign-signature |
Signed by a key that is not the metadata’s, its own certificate in KeyInfo |
saml-wrong-audience, saml-wrong-recipient |
Another SP’s Audience / another bearer Recipient |
saml-expired-conditions |
Conditions ended five minutes ago |
saml-tampered |
The NameID is changed after signing |
saml-two-assertions |
Two separately signed assertions |
saml-unknown-in-response-to |
InResponseTo names a request the SP never sent |
saml-status-failure |
Status Responder/AuthnFailed (Core §3.2.2.2) and no assertion |
saml-no-destination |
The Response leaves out its optional Destination (Core §3.2.2; Bindings §3.5.5.2 requires it only of a signed Response) |
Not modeled
Section titled “Not modeled”Refresh tokens, the implicit and hybrid flows, prompt, max_age, claims and request objects,
pairwise subjects, encrypted ID tokens, private_key_jwt, the iss authorization response
parameter, dynamic registration, sessions and logout (OIDC and SAML Single Logout), IdP-initiated
SAML, signature verification of AuthnRequests (the metadata says WantAuthnRequestsSigned="false"),
AssertionConsumerServiceIndex, ForceAuthn/IsPassive, encrypted assertions, signed Responses
and the Artifact binding. The 80-byte RelayState limit (Bindings §3.4.3) binds the sender and is
not enforced. AuthnRequests with a DOCTYPE, any XML parser warning or an inflated size above
64 KiB are refused: a simulator choice for safe parsing.
pnpm --filter @guestcraft/identity-provider-simulator testpnpm --filter @guestcraft/identity-provider-simulator typecheckThe tests verify ID tokens with node:crypto against the JWKS and SAML signatures with xml-crypto
against the certificate, compute PKCE challenges and DEFLATE-encode AuthnRequests independently,
and use no network beyond 127.0.0.1.