Identity
IdP failure modes, MFA & debugger
A sign-in that works proves only the happy path. The mock identity providers can also send the responses a careful app must refuse, ask for a second factor, and validate their own answers in a built-in debugger:
- Failure modes make the IdP send expired, misaddressed, incomplete or failing responses, so you can check that your app rejects them.
- MFA and step-up add a simulated one-time-code step, with
amr,acr,auth_timeandmax_agein OIDC andAuthnContextClassRefin SAML. - The debugger signs in the way a strict relying party would and lists every check it made.
These work on every plan. They apply to generic OIDC and SAML IdPs; the social login presets have their own failure modes.
Failure modes
A fault only changes ordinary fields (claims, times, audience, destination, status) before the normal signing step, so responses stay signed by the IdP's own key. The one exception is the SAML unsigned fault, which leaves out every signature. The IdP never forges or tampers with a signature.
A fault is chosen for each sign-in, in this order:
- The Sign in with select next to each user on the login screen (
normalor a fault). ?fault=<id>on the authorize URL (OIDC) or the SSO URL (SAML), for automated tests.?fault=noneturns the default off for one sign-in. An unknown ID is aninvalid_requesterror (OIDC) or an error page (SAML).- The IdP's default failure mode, on the Setup tab or with
PATCH /api/idps/:idandfaults. A default that exists for only one protocol leaves the other protocol's sign-ins normal.
With auto-login there is no login screen, so only 2 and 3 apply. The fault travels with the authorization code and refresh token, so token-endpoint and userinfo faults hit the requests that follow the sign-in. Every activity-log event of a faulted sign-in has details.fault and a fault badge.
# Every sign-in of this IdP gets an expired ID token until you switch it off
curl -X PATCH https://app.testlie.com/api/idps/$IDP_ID \
-H "Authorization: Bearer $TESTLIE_API_KEY" -H "Content-Type: application/json" \
-d '{ "faults": { "mode": "expired_id_token" } }'
# Back to normal
curl -X PATCH https://app.testlie.com/api/idps/$IDP_ID \
-H "Authorization: Bearer $TESTLIE_API_KEY" -H "Content-Type: application/json" \
-d '{ "faults": { "mode": null } }'Two faults take a parameter, set with faults: skewSeconds (default 300, at most 86,400) is how far not_yet_valid moves times into the future, and delayMs (default 10,000, at most 30,000) is how long slow_token waits.
OIDC
| Fault | What the IdP does | A correct app |
|---|---|---|
expired_id_token |
ID token exp 5 minutes in the past |
Rejects the ID token |
not_yet_valid |
iat and nbf skewSeconds in the future |
Rejects it once the skew exceeds its clock leeway |
wrong_audience |
aud (and azp) is urn:testlie:wrong-audience |
Rejects it: aud does not contain its client ID |
wrong_issuer |
iss has another host (https://wrong-issuer.invalid/...) |
Rejects it: iss is not the configured issuer |
wrong_nonce |
nonce differs from the one sent |
Rejects it (replay protection) |
missing_nonce |
No nonce claim |
Rejects it when it sent a nonce |
state_mismatch |
Redirects back with a different state |
Rejects the callback before redeeming the code (CSRF) |
missing_email |
No email or email_verified |
Does not create or link an account; shows an error |
email_unverified |
email_verified: false |
Does not link to an existing account by email |
token_error_invalid_grant |
The token endpoint answers 400 invalid_grant |
Shows a sign-in error and lets the user retry |
token_error_server |
The token endpoint answers 500 server_error |
Handles the error gracefully |
slow_token |
The token endpoint waits delayMs, then answers normally |
Times out instead of hanging |
userinfo_error |
Userinfo answers 500 server_error |
Fails the sign-in or falls back to ID token claims, without crashing |
userinfo_sub_mismatch |
Userinfo sub differs from the ID token's |
Rejects the userinfo response (OIDC Core 5.3.2) |
SAML
| Fault | What the IdP does | A correct app |
|---|---|---|
expired_assertion |
Conditions and SubjectConfirmationData NotOnOrAfter 5 minutes in the past |
Rejects the assertion |
not_yet_valid |
NotBefore and IssueInstant skewSeconds in the future |
Rejects it once the skew exceeds its clock leeway |
wrong_audience |
Audience is urn:testlie:wrong-audience |
Rejects it: the audience is not its SP entity ID |
wrong_destination |
Destination and Recipient are another URL (the response is still posted to your ACS) |
Rejects the response |
wrong_in_response_to |
InResponseTo names a request that was never sent |
Rejects it (also for IdP-initiated responses, which must have none) |
wrong_issuer |
The response and assertion Issuer have another host |
Rejects it: not the configured IdP entity ID |
missing_name_id |
The Subject has no NameID |
Rejects the assertion |
unsigned |
Neither the response nor the assertion is signed | Rejects it: signatures are required |
status_authn_failed |
Responder / AuthnFailed status, no assertion |
Shows a sign-in error and signs nobody in |
status_responder |
Top-level Responder status, no assertion |
Shows a sign-in error and signs nobody in |
In an end-to-end test
Set the IdP's default failure mode before the test, run your sign-in flow, assert that your app shows its error and created no session, then clear the default. If your test controls the authorize URL, add &fault=<id> to it instead, so tests with different faults can run in parallel.
const setFault = (mode) =>
fetch(`${TESTLIE}/api/idps/${IDP_ID}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${TESTLIE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ faults: { mode } }),
});
test("rejects an ID token for another audience", async () => {
await setFault("wrong_audience");
try {
// ... sign in through your app and expect its error page ...
} finally {
await setFault(null);
}
});MFA and step-up
The Multi-factor step setting (Setup tab, or mfa in PATCH /api/idps/:id) decides when the IdP asks for a one-time code after the user is picked:
| Setting | When the code screen is shown |
|---|---|
off (default) |
Never |
always |
After every user pick |
on_request |
When OIDC acr_values or the SAML RequestedAuthnContext asks for MFA |
Values that ask for MFA: mfa, otp, phr, phrh, http://schemas.openid.net/pape/policies/2007/06/multi-factor, urn:okta:loa:2fa:any, urn:okta:loa:2fa:any:ifpossible, urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactor, urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract, urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken and http://schemas.microsoft.com/claims/multipleauthn.
On the code screen any six digits pass. 000000 simulates a failed challenge: OIDC returns error=access_denied with a description, and SAML posts a Responder / AuthnFailed status. Auto-login completes the step on its own and still reports MFA.
What the IdP reports:
| Claim or element | Value |
|---|---|
amr |
["pwd", "otp"] after MFA, ["pwd"] otherwise. Always present in ID tokens |
acr |
After MFA, the first requested MFA value (or the first requested value if none asks for MFA). Without MFA, the first requested value that does not ask for MFA. With nothing requested, or nothing it can satisfy (for example MFA requested while the setting is off): mfa or pwd |
SAML AuthnContextClassRef |
The same rule over the requested class refs. With nothing requested: urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactor after MFA, urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport without it |
auth_time |
The moment the user was picked or auto-logged in |
A correct app compares acr with what it asked for: when the setting is off, a request for mfa comes back as acr: "pwd", and the app must refuse the step-up.
max_age (any value) and prompt=login force a fresh sign-in, so the login screen is shown even with an auto-login user; SAML ForceAuthn="true" does the same. prompt=none with max_age=0 returns login_required.
The debugger
Test OIDC and Test SAML on the IdP's page (and Try in OIDC debugger and Try in SAML debugger in the Failure modes card) start a sign-in from https://app.testlie.com/idp/<slug>/debug/start. Like a real app, the debugger first stores state and nonce (OIDC) or the AuthnRequest ID (SAML) in a session tied to a cookie, then validates the answer completely and lists each check as PASS or FAIL. It allows 60 seconds of clock leeway.
| Protocol | Checks |
|---|---|
| OIDC | state; the token response (HTTP 200 within 5 seconds, access_token, token_type, id_token); the ID token signature and kid, iss, aud (and azp with several audiences), exp, iat, nbf, sub, nonce, email and email_verified (when the email scope was requested), auth_time against max_age, acr against acr_values, and amr when MFA was requested; the userinfo status and sub |
| SAML | Status, response and assertion Issuer, Destination, InResponseTo (response and SubjectConfirmationData), exactly one assertion, signed (response or assertion, each signature referencing its own element) and every signature valid, IssueInstant, NameID, bearer SubjectConfirmation with NotOnOrAfter and Recipient, Conditions NotBefore and NotOnOrAfter, Audience, AuthnStatement, and AuthnContextClassRef when MFA was requested |
Start parameters:
| Parameter | Protocol | Notes |
|---|---|---|
protocol |
Both | oidc (default) or saml |
fault |
Both | A failure mode for this sign-in |
scope |
OIDC | Default openid profile email groups offline_access |
acr_values, max_age, prompt |
OIDC | Passed on to the authorize request |
mfa=1 |
SAML | Adds a RequestedAuthnContext for MultiFactor |
force=1 |
SAML | Sets ForceAuthn="true" |
The debugger is always an allowed redirect URI and ACS URL, so it needs no setup. Each check on the result page has a stable ID (data-check="<id>" and data-ok="true" or "false"), so a browser test can assert on it.
Use the debugger to see what a fault looks like before you test your app with it: a fault that makes a debugger check fail is a response your app must refuse too.