From d76497ec138684c091fcecba79d062c27b48771b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 15:13:25 +0200 Subject: [PATCH] test(auth): pin the SSO callback before moving its sign-in resolution --- .../oidc-callback.01-disabled.json.snap | 26 ++ .../oidc-callback.02-idp-denied.json.snap | 29 ++ ...lback.03-idp-denied-unknown-code.json.snap | 29 ++ .../oidc-callback.04-no-code.json.snap | 26 ++ .../oidc-callback.05-no-state-blob.json.snap | 26 ++ .../oidc-callback.06-forged-state.json.snap | 28 ++ ...oidc-callback.07-exchange-failed.json.snap | 31 ++ .../oidc-callback.08-no-id-token.json.snap | 26 ++ ...oidc-callback.09-provision-first.json.snap | 66 +++ ...idc-callback.10-provision-second.json.snap | 92 ++++ ...lback.11-returning-email-refresh.json.snap | 104 +++++ ...llback.12-duplicate-with-session.json.snap | 101 +++++ .../oidc-callback.13-no-email.json.snap | 101 +++++ ...idc-callback.14-email-unverified.json.snap | 101 +++++ ...c-callback.15-userinfo-provision.json.snap | 136 ++++++ .../oidc-callback.16-link-conflict.json.snap | 141 +++++++ ...idc-callback.17-link-pending-web.json.snap | 156 +++++++ ...-callback.18-link-pending-native.json.snap | 161 +++++++ ...-callback.19-registration-closed.json.snap | 161 +++++++ .../oidc-callback.20-mfa-web.json.snap | 168 ++++++++ .../oidc-callback.21-mfa-native.json.snap | 173 ++++++++ .../oidc-callback.22-native-handoff.json.snap | 177 ++++++++ ...oidc-callback.23-claim-signed-in.json.snap | 186 +++++++++ ...oidc-callback.24-claim-no-cookie.json.snap | 186 +++++++++ .../oidc-callback.25-claim-no-code.json.snap | 184 ++++++++ .../parity/oidc-callback.parity.test.ts | 395 ++++++++++++++++++ 26 files changed, 3010 insertions(+) create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.01-disabled.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.02-idp-denied.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.03-idp-denied-unknown-code.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.04-no-code.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.05-no-state-blob.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.06-forged-state.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.07-exchange-failed.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.08-no-id-token.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.09-provision-first.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.10-provision-second.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.11-returning-email-refresh.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.12-duplicate-with-session.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.13-no-email.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.14-email-unverified.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.15-userinfo-provision.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.16-link-conflict.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.17-link-pending-web.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.18-link-pending-native.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.19-registration-closed.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.20-mfa-web.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.21-mfa-native.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.22-native-handoff.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.23-claim-signed-in.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.24-claim-no-cookie.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/oidc-callback.25-claim-no-code.json.snap create mode 100644 tests/integration/routes/parity/oidc-callback.parity.test.ts diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.01-disabled.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.01-disabled.json.snap new file mode 100644 index 000000000..1a50e2ca5 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.01-disabled.json.snap @@ -0,0 +1,26 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_disabled", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.02-idp-denied.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.02-idp-denied.json.snap new file mode 100644 index 000000000..68266cf05 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.02-idp-denied.json.snap @@ -0,0 +1,29 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_denied", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "idp_denied", + "idpError": "access_denied" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.03-idp-denied-unknown-code.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.03-idp-denied-unknown-code.json.snap new file mode 100644 index 000000000..786057ee1 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.03-idp-denied-unknown-code.json.snap @@ -0,0 +1,29 @@ +{ + "status": 307, + "location": "healthlog://oidc-callback?error=oidc_denied", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "idp_denied", + "idpError": "other" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.04-no-code.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.04-no-code.json.snap new file mode 100644 index 000000000..9740fe43d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.04-no-code.json.snap @@ -0,0 +1,26 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_state", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.05-no-state-blob.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.05-no-state-blob.json.snap new file mode 100644 index 000000000..9740fe43d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.05-no-state-blob.json.snap @@ -0,0 +1,26 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_state", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.06-forged-state.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.06-forged-state.json.snap new file mode 100644 index 000000000..442de779d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.06-forged-state.json.snap @@ -0,0 +1,28 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_state", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "csrf" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.07-exchange-failed.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.07-exchange-failed.json.snap new file mode 100644 index 000000000..d8cdaf88e --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.07-exchange-failed.json.snap @@ -0,0 +1,31 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_failed", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "exchange_or_verify_failed" + }, + "warnings": null, + "error": { + "type": "Error", + "message": "invalid_grant" + } + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.08-no-id-token.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.08-no-id-token.json.snap new file mode 100644 index 000000000..68261b197 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.08-no-id-token.json.snap @@ -0,0 +1,26 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_failed", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [], + "sessions": [], + "audit": [], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.09-provision-first.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.09-provision-first.json.snap new file mode 100644 index 000000000..b5bd1bda6 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.09-provision-first.json.snap @@ -0,0 +1,66 @@ +{ + "status": 307, + "location": "http://localhost:3000/insights", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "recordSignInDevice", + "args": [ + { + "userId": "", + "ip": "unknown", + "userAgent": null + } + ] + } + ], + "stored": { + "users": [ + { + "username": "First", + "email": "first@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.10-provision-second.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.10-provision-second.json.snap new file mode 100644 index 000000000..c0b84257d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.10-provision-second.json.snap @@ -0,0 +1,92 @@ +{ + "status": 307, + "location": "http://localhost:3000/", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "recordSignInDevice", + "args": [ + { + "userId": "", + "ip": "unknown", + "userAgent": null + } + ] + } + ], + "stored": { + "users": [ + { + "username": "First", + "email": "first@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.11-returning-email-refresh.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.11-returning-email-refresh.json.snap new file mode 100644 index 000000000..ac180d4b5 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.11-returning-email-refresh.json.snap @@ -0,0 +1,104 @@ +{ + "status": 307, + "location": "http://localhost:3000/", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "recordSignInDevice", + "args": [ + { + "userId": "", + "ip": "unknown", + "userAgent": null + } + ] + } + ], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.12-duplicate-with-session.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.12-duplicate-with-session.json.snap new file mode 100644 index 000000000..24d839ad7 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.12-duplicate-with-session.json.snap @@ -0,0 +1,101 @@ +{ + "status": 307, + "location": "http://localhost:3000/", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "duplicate_callback_authenticated" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.13-no-email.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.13-no-email.json.snap new file mode 100644 index 000000000..08674b8a7 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.13-no-email.json.snap @@ -0,0 +1,101 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_no_email", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "no_email" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.14-email-unverified.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.14-email-unverified.json.snap new file mode 100644 index 000000000..5d380be3a --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.14-email-unverified.json.snap @@ -0,0 +1,101 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_email_unverified", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "email_unverified" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.15-userinfo-provision.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.15-userinfo-provision.json.snap new file mode 100644 index 000000000..f0c4ffb58 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.15-userinfo-provision.json.snap @@ -0,0 +1,136 @@ +{ + "status": 307, + "location": "http://localhost:3000/", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "recordSignInDevice", + "args": [ + { + "userId": "", + "ip": "unknown", + "userAgent": null + } + ] + } + ], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.16-link-conflict.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.16-link-conflict.json.snap new file mode 100644 index 000000000..ddbf29429 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.16-link-conflict.json.snap @@ -0,0 +1,141 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_identity_conflict", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "identity_conflict" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.17-link-pending-web.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.17-link-pending-web.json.snap new file mode 100644 index 000000000..e00f75079 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.17-link-pending-web.json.snap @@ -0,0 +1,156 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_link_required", + "setCookies": [ + "hl_oidc_link=; Path=/api/auth; Expires=; Max-Age=600; HttpOnly; SameSite=lax", + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "link_confirmation_required" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.18-link-pending-native.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.18-link-pending-native.json.snap new file mode 100644 index 000000000..5aa88c3a7 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.18-link-pending-native.json.snap @@ -0,0 +1,161 @@ +{ + "status": 307, + "location": "healthlog://oidc-callback?error=oidc_link_required", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "link_confirmation_required" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.19-registration-closed.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.19-registration-closed.json.snap new file mode 100644 index 000000000..9914dceb7 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.19-registration-closed.json.snap @@ -0,0 +1,161 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?error=oidc_registration_disabled", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "registration_disabled" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 0, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.20-mfa-web.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.20-mfa-web.json.snap new file mode 100644 index 000000000..539539bda --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.20-mfa-web.json.snap @@ -0,0 +1,168 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/login?next=%2Fmedications", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=", + "oidc_mfa_handoff=; Path=/auth/login; Expires=; Max-Age=300; SameSite=lax" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.mfa.challenge" + }, + "meta": { + "mfa_required": true + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 1, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.21-mfa-native.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.21-mfa-native.json.snap new file mode 100644 index 000000000..39d982593 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.21-mfa-native.json.snap @@ -0,0 +1,173 @@ +{ + "status": 307, + "location": "healthlog://oidc-callback?mfa_ticket=&methods=totp%2Crecovery", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.mfa.challenge" + }, + "meta": { + "mfa_required": true + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + } + ], + "challenges": 2, + "handoffs": 0 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.22-native-handoff.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.22-native-handoff.json.snap new file mode 100644 index 000000000..bf9726436 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.22-native-handoff.json.snap @@ -0,0 +1,177 @@ +{ + "status": 307, + "location": "healthlog://oidc-callback?code=", + "setCookies": [ + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.native.handoff_minted" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.native.handoff_minted", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 2, + "handoffs": 1 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.23-claim-signed-in.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.23-claim-signed-in.json.snap new file mode 100644 index 000000000..bde6ddcce --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.23-claim-signed-in.json.snap @@ -0,0 +1,186 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/claim?error=claim_signed_in", + "setCookies": [ + "hl_claim=; Path=/api/auth/oidc; Expires=", + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "claim_signed_in" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.native.handoff_minted", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 2, + "handoffs": 1 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.24-claim-no-cookie.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.24-claim-no-cookie.json.snap new file mode 100644 index 000000000..013e26ef7 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.24-claim-no-cookie.json.snap @@ -0,0 +1,186 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/claim?error=claim_expired", + "setCookies": [ + "hl_claim=; Path=/api/auth/oidc; Expires=", + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": { + "reason": "claim_expired" + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.native.handoff_minted", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 2, + "handoffs": 1 + } +} diff --git a/tests/integration/routes/parity/__snapshots__/oidc-callback.25-claim-no-code.json.snap b/tests/integration/routes/parity/__snapshots__/oidc-callback.25-claim-no-code.json.snap new file mode 100644 index 000000000..80dbd0772 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/oidc-callback.25-claim-no-code.json.snap @@ -0,0 +1,184 @@ +{ + "status": 307, + "location": "http://localhost:3000/auth/claim?error=oidc_state", + "setCookies": [ + "hl_claim=; Path=/api/auth/oidc; Expires=", + "oidc_auth_state=; Path=/api/auth/oidc; Expires=" + ], + "events": [ + { + "status": 307, + "action": { + "name": "auth.oidc.callback" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "users": [ + { + "username": "First", + "email": "first.new@example.test", + "role": "ADMIN", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-first" + }, + { + "username": "linked-elsewhere", + "email": "conflict@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://other.example.test", + "oidcSub": "other-sub" + }, + { + "username": "local-only", + "email": "local@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": null, + "oidcSub": null + }, + { + "username": "second", + "email": "second@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-second" + }, + { + "username": "userinfo", + "email": "userinfo@example.test", + "role": "USER", + "timezone": "Europe/Berlin", + "oidcIssuer": "https://id.example.test", + "oidcSub": "sub-userinfo" + } + ], + "sessions": [ + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "First" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "second" + }, + "mfaVerifiedAt": null + }, + { + "user": { + "username": "userinfo" + }, + "mfaVerifiedAt": null + } + ], + "audit": [ + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.mfa.challenge", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.link_conflict", + "user": { + "username": "linked-elsewhere" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.link_pending", + "user": { + "username": "local-only" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.login", + "user": { + "username": "First" + } + }, + { + "action": "auth.oidc.native.handoff_minted", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "userinfo" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "second" + } + }, + { + "action": "auth.oidc.provisioned", + "user": { + "username": "First" + } + } + ], + "challenges": 2, + "handoffs": 1 + } +} diff --git a/tests/integration/routes/parity/oidc-callback.parity.test.ts b/tests/integration/routes/parity/oidc-callback.parity.test.ts new file mode 100644 index 000000000..3f4145bbf --- /dev/null +++ b/tests/integration/routes/parity/oidc-callback.parity.test.ts @@ -0,0 +1,395 @@ +/** + * Parity for `GET /api/auth/oidc/callback` while its sign-in resolution moves + * into `src/lib/auth/`. + * + * The provider is replaced at the four calls that would leave the process + * (discovery, code exchange, ID-token verification, userinfo); everything + * else is real: the sealed state cookie, sessions, MFA challenges, native + * handoffs, the pending-link cookie, the audit ledger. The walk covers each + * outcome the route tells apart: SSO switched off, a provider refusal, a + * broken round trip, a missing state blob with and without a live session, + * a forged state, a failed exchange, an exchange with no ID token, + * first-login provisioning (the first account becomes admin), a returning + * identity whose verified email changed, the userinfo fallback, the email + * refusals, a link conflict, a pending link on the web and on the phone, + * closed registration, the second factor on the web and on the phone, the + * native handoff, and the two claim refusals. After each request the + * snapshot holds the redirect, the cookies it set, the wide event, the + * recorded sign-in device tail and the accounts, sessions and ledger rows. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { GET } from "@/app/api/auth/oidc/callback/route"; +import { encrypt } from "@/lib/crypto"; +import { OIDC_STATE_COOKIE } from "@/lib/auth/oidc-cookie"; +import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; + +import { anonymous, callRoute } from "../../../kit/call-route"; +import { makeSession, makeUser } from "../../../kit/factories"; +import { + effectRecorder, + eventFacts, + expectParity, + stable, + wideEventRecorder, +} from "../../../kit/parity"; +import { getPrismaClient, truncateAllTables } from "../../setup"; + +vi.mock("@/lib/logging/transports", async (importOriginal) => { + const actual = + await importOriginal(); + const { wideEventRecorder: recorder } = await import("../../../kit/parity"); + return { + ...actual, + emitIfSampled: (event: (typeof recorder.events)[number]) => { + recorder.events.push(event); + }, + }; +}); +vi.mock("@/lib/auth/login-alert", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + recordSignInDevice: async (...args: unknown[]) => { + effectRecorder.record("recordSignInDevice", args); + }, + }; +}); + +/** What the fake provider answers for the next callback. */ +const idp = vi.hoisted(() => ({ + exchange: "ok" as "ok" | "no_id_token" | "throws", + accessToken: false, + identity: { + sub: "sub-1", + email: null as string | null, + emailVerified: undefined as boolean | undefined, + }, + userinfo: { + email: null as string | null, + emailVerified: undefined as boolean | undefined, + }, +})); +const ISSUER = "https://id.example.test"; +vi.mock("@/lib/auth/oidc", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverOidcMetadata: async () => ({ + issuer: "https://id.example.test", + authorization_endpoint: "https://id.example.test/authorize", + token_endpoint: "https://id.example.test/token", + jwks_uri: "https://id.example.test/jwks", + }), + exchangeCodeForTokens: async () => { + if (idp.exchange === "throws") throw new Error("invalid_grant"); + return { + ...(idp.exchange === "ok" ? { id_token: "id-token" } : {}), + ...(idp.accessToken ? { access_token: "access-token" } : {}), + token_type: "Bearer", + }; + }, + verifyIdToken: async () => ({ ...idp.identity, name: null }), + fetchUserinfoEmail: async () => ({ ...idp.userinfo }), + }; +}); + +const prisma = getPrismaClient(); +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const APP = "http://localhost:3000"; +const SAVED_ENV = { ...process.env }; + +interface State { + native?: true; + appCodeChallenge?: string; + intent?: "claim"; + claimBinding?: string; + next?: string; + /** Send a different `state` in the query than the blob carries. */ + forged?: true; +} + +function stateCookie(state: State): string { + return encrypt( + JSON.stringify({ + state: "state-1", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: state.next ?? "/", + ...(state.native + ? { native: true, appCodeChallenge: state.appCodeChallenge } + : {}), + ...(state.intent + ? { intent: state.intent, claimBinding: state.claimBinding } + : {}), + }), + ); +} + +/** The redirect target with its one-time values replaced. */ +function location(headers: Headers): string | null { + const raw = headers.get("location"); + if (!raw) return null; + return raw.replace( + /([?&](?:code|mfa_ticket)=)[^&]+/g, + (_, key: string) => `${key}`, + ); +} + +/** The cookies the response set or cleared, by name and attributes. */ +function setCookies(headers: Headers): string[] { + return headers + .getSetCookie() + .map((c) => + c.replace( + /^([^=]+)=([^;]*)/, + (_, n: string, v: string) => `${n}=${v ? "" : ""}`, + ), + ) + .map((c) => c.replace(/Expires=[^;]+/i, "Expires=")) + .sort(); +} + +async function stored() { + const [users, sessions, audit, challenges, handoffs] = await Promise.all([ + prisma.user.findMany({ + orderBy: { username: "asc" }, + select: { + username: true, + email: true, + role: true, + timezone: true, + oidcIssuer: true, + oidcSub: true, + }, + }), + prisma.session.findMany({ + orderBy: [{ createdAt: "asc" }, { userId: "asc" }], + select: { + user: { select: { username: true } }, + mfaVerifiedAt: true, + }, + }), + prisma.auditLog.findMany({ + orderBy: [{ createdAt: "asc" }, { action: "asc" }], + select: { action: true, user: { select: { username: true } } }, + }), + prisma.mfaChallenge.count(), + prisma.oidcNativeHandoff.count(), + ]); + return { users, sessions, audit, challenges, handoffs }; +} + +async function step( + name: string, + options: { + query?: Record; + state?: State | null; + cookies?: Record; + }, +): Promise { + await prisma.rateLimit.deleteMany({}); + wideEventRecorder.take(); + effectRecorder.take(); + const query = + options.query ?? + ({ + code: "code-1", + state: options.state?.forged ? "state-x" : "state-1", + } as Record); + const cookies: Record = { + ...(options.state + ? { [OIDC_STATE_COOKIE]: stateCookie(options.state) } + : {}), + ...options.cookies, + }; + const res = await callRoute(GET, { + method: "GET", + route: "/api/auth/oidc/callback", + query, + headers: { + cookie: Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "), + }, + auth: anonymous, + contract: { + skip: "a browser redirect target, not part of the published API", + }, + }); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + await expectParity( + `oidc-callback.${name}`, + stable( + { + status: res.status, + location: location(res.headers), + setCookies: setCookies(res.headers), + events: events.map(eventFacts), + effects: effectRecorder.take(), + stored: await stored(), + }, + { since: NOW }, + ), + ); +} + +function identity( + sub: string, + email: string | null, + emailVerified: boolean | undefined, +): void { + idp.exchange = "ok"; + idp.accessToken = false; + idp.identity = { sub, email, emailVerified }; +} + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + await truncateAllTables(prisma); + process.env.APP_URL = APP; + process.env.NEXT_PUBLIC_APP_URL = APP; + delete process.env.OIDC_ISSUER_URL; + delete process.env.OIDC_ONLY; +}); + +afterAll(() => { + vi.useRealTimers(); + for (const key of [ + "APP_URL", + "NEXT_PUBLIC_APP_URL", + "OIDC_ISSUER_URL", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + ]) { + if (SAVED_ENV[key] === undefined) delete process.env[key]; + else process.env[key] = SAVED_ENV[key]; + } +}); + +describe("GET /api/auth/oidc/callback parity", () => { + it("01 SSO switched off", async () => { + await step("01-disabled", { state: {} }); + process.env.OIDC_ISSUER_URL = ISSUER; + process.env.OIDC_CLIENT_ID = "healthlog-test"; + process.env.OIDC_CLIENT_SECRET = "client-secret"; + }); + + it("02 the round-trip refusals", async () => { + await step("02-idp-denied", { + query: { error: "access_denied", state: "state-1" }, + state: {}, + }); + await step("03-idp-denied-unknown-code", { + query: { error: "something_else" }, + state: { native: true, appCodeChallenge: "challenge-1" }, + }); + await step("04-no-code", { query: { state: "state-1" }, state: {} }); + await step("05-no-state-blob", { state: null }); + await step("06-forged-state", { state: { forged: true } }); + idp.exchange = "throws"; + await step("07-exchange-failed", { state: {} }); + idp.exchange = "no_id_token"; + await step("08-no-id-token", { state: {} }); + }); + + it("09 the first login provisions an admin", async () => { + identity("sub-first", "First@Example.test", true); + await step("09-provision-first", { state: { next: "/insights" } }); + identity("sub-second", "second@example.test", true); + await step("10-provision-second", { state: {} }); + }); + + it("11 a returning identity, and a duplicate callback", async () => { + identity("sub-first", "first.new@example.test", true); + await step("11-returning-email-refresh", { state: {} }); + const first = await prisma.user.findFirstOrThrow({ + where: { oidcSub: "sub-first" }, + }); + const session = await makeSession(first); + await step("12-duplicate-with-session", { + state: null, + cookies: { [SESSION_COOKIE_NAME]: session.cookie }, + }); + }); + + it("13 the email refusals and the userinfo fallback", async () => { + identity("sub-noemail", null, undefined); + await step("13-no-email", { state: {} }); + identity("sub-unverified", "unverified@example.test", false); + await step("14-email-unverified", { state: {} }); + identity("sub-userinfo", null, undefined); + idp.accessToken = true; + idp.userinfo = { email: "userinfo@example.test", emailVerified: true }; + await step("15-userinfo-provision", { state: {} }); + }); + + it("16 an account already holding the email", async () => { + await makeUser({ + username: "linked-elsewhere", + email: "conflict@example.test", + oidcIssuer: "https://other.example.test", + oidcSub: "other-sub", + }); + identity("sub-conflict", "conflict@example.test", true); + await step("16-link-conflict", { state: {} }); + await makeUser({ username: "local-only", email: "local@example.test" }); + identity("sub-local", "local@example.test", true); + await step("17-link-pending-web", { state: {} }); + await step("18-link-pending-native", { + state: { native: true, appCodeChallenge: "challenge-1" }, + }); + }); + + it("19 closed registration", async () => { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", registrationEnabled: false }, + update: { registrationEnabled: false }, + }); + identity("sub-late", "late@example.test", true); + await step("19-registration-closed", { state: {} }); + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { registrationEnabled: true }, + }); + }); + + it("20 the second factor and the native handoff", async () => { + await prisma.user.updateMany({ + where: { oidcSub: "sub-second" }, + data: { totpConfirmedAt: new Date("2026-09-01T00:00:00.000Z") }, + }); + identity("sub-second", "second@example.test", true); + await step("20-mfa-web", { state: { next: "/medications" } }); + await step("21-mfa-native", { + state: { native: true, appCodeChallenge: "challenge-1" }, + }); + identity("sub-userinfo", "userinfo@example.test", true); + await step("22-native-handoff", { + state: { native: true, appCodeChallenge: "challenge-2" }, + }); + }); + + it("23 the claim refusals", async () => { + const someone = await prisma.user.findFirstOrThrow({ + where: { oidcSub: "sub-first" }, + }); + const session = await makeSession(someone); + identity("sub-claimer", "claimer@example.test", true); + await step("23-claim-signed-in", { + state: { intent: "claim", claimBinding: "binding-1" }, + cookies: { [SESSION_COOKIE_NAME]: session.cookie }, + }); + await step("24-claim-no-cookie", { + state: { intent: "claim", claimBinding: "binding-1" }, + }); + await step("25-claim-no-code", { + query: { state: "state-1" }, + state: { intent: "claim", claimBinding: "binding-1" }, + }); + }); +});