diff --git a/.gitignore b/.gitignore index 7b2d662ac..2f4820729 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ test.db .yalc yalc.lock certs/ +# Test-only e2e-playground fixtures are intentionally checked in (docs/superpowers/plans/2026-07-20-playground-e2e.md Global Constraints). +!e2e-playground/fixtures/certs/ +!e2e-playground/mocks/mock-saml-idp/certs/ *-shm *-wal .idea diff --git a/Makefile b/Makefile index f5c6c40cd..08ccd627b 100644 --- a/Makefile +++ b/Makefile @@ -297,3 +297,25 @@ perf-k6-validate: .PHONY: perf-k6-check perf-k6-check: k6 run perf/k6/fga_check.js + +.PHONY: e2e-playground +e2e-playground: ## Run the live-playground e2e suite (OIDC/SAML/SCIM/SSO/OAuth/MFA) against an ephemeral docker-compose stack + docker compose -f e2e-playground/docker-compose.yml up -d --wait authorizer authorizer-sso mock-oauth mock-saml-idp mailpit sms-sink; \ + status=$$?; \ + if [ $$status -eq 0 ]; then \ + docker compose -f e2e-playground/docker-compose.yml run --rm playwright npx playwright test; \ + status=$$?; \ + fi; \ + docker compose -f e2e-playground/docker-compose.yml down -v; \ + exit $$status + +.PHONY: e2e-playground-sdk +e2e-playground-sdk: ## Run the SDK-driven Go suite (drives authorizer-go over the enterprise/MFA surface) against an ephemeral docker-compose stack + docker compose -f e2e-playground/docker-compose.yml up -d --wait --build authorizer authorizer-webauthn authorizer-mfa-enforced authorizer-mfa-magic-link mock-oauth mock-saml-idp mailpit sms-sink webhook-sink; \ + status=$$?; \ + if [ $$status -eq 0 ]; then \ + docker compose -f e2e-playground/docker-compose.yml run --rm --build go-sdk-tests; \ + status=$$?; \ + fi; \ + docker compose -f e2e-playground/docker-compose.yml down -v; \ + exit $$status diff --git a/e2e-playground/.dockerignore b/e2e-playground/.dockerignore new file mode 100644 index 000000000..169a2afd8 --- /dev/null +++ b/e2e-playground/.dockerignore @@ -0,0 +1,3 @@ +node_modules +playwright-report +test-results diff --git a/e2e-playground/.gitignore b/e2e-playground/.gitignore new file mode 100644 index 000000000..945fcd0d8 --- /dev/null +++ b/e2e-playground/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e-playground/Dockerfile b/e2e-playground/Dockerfile new file mode 100644 index 000000000..38f9c48ce --- /dev/null +++ b/e2e-playground/Dockerfile @@ -0,0 +1,7 @@ +# e2e-playground/Dockerfile +FROM mcr.microsoft.com/playwright:v1.61.1-jammy +WORKDIR /e2e +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +CMD ["npx", "playwright", "test"] diff --git a/e2e-playground/README.md b/e2e-playground/README.md new file mode 100644 index 000000000..4c99c6446 --- /dev/null +++ b/e2e-playground/README.md @@ -0,0 +1,84 @@ +# Live-Playground E2E Suite + +A real, live-server end-to-end test suite for Authorizer. Every spec drives a real `authorizer` binary (built from this repo's source) plus a real browser (Playwright) — no unit-level mocking of Authorizer itself. Third-party services (Google/GitHub/Discord/etc. OAuth, SMS delivery, an external SAML IdP) are stood in for by small local mock servers under `mocks/`, wired in via `authorizer`'s own test-only mechanism (`--env=e2e`) — every behavior it enables is a documented no-op unless that exact env value is set, so none of this affects production behavior. + +## Coverage + +- OIDC provider (signup/login/PKCE/token issuance) and OIDC SSO relying-party (home-realm discovery) +- SAML SP and SAML IdP +- SCIM (CRUD, filter operators, webhooks — including real webhook delivery with HMAC verification) +- 10 social OAuth providers (Google, GitHub, Facebook, LinkedIn, Apple, Discord, Twitter, Microsoft, Twitch, Roblox) +- WebAuthn/passkeys (via Playwright's CDP-backed virtual authenticator) +- TOTP, SMS-OTP, WebOTP auto-fill, magic-link +- MFA enforcement routing matrix +- OTP brute-force lockout +- Dashboard verified-domains UI + +## Prerequisites + +- Docker + Docker Compose (everything else — Node, Playwright browsers, the `authorizer` binary — is built into containers; nothing needs to be installed on the host). + +## Run the full suite + +From the repository root: + +```bash +make e2e-playground +``` + +This builds and starts the full docker-compose stack (six `authorizer` instances configured for different scenarios, the mock OAuth/SAML/SMS/webhook servers, Mailpit for email), runs the entire Playwright suite in a containerized runner, and **always** tears the stack down afterward (`docker compose down -v`) — including on failure or if the stack fails to start. + +Equivalent to, run manually: + +```bash +docker compose -f e2e-playground/docker-compose.yml up -d --wait authorizer authorizer-sso mock-oauth mock-saml-idp mailpit sms-sink +docker compose -f e2e-playground/docker-compose.yml run --rm playwright npx playwright test +docker compose -f e2e-playground/docker-compose.yml down -v +``` + +(The `playwright` service's own `depends_on` brings up the remaining instances — `authorizer-webauthn`, `authorizer-magic-link`, `authorizer-mfa-enforced`, `authorizer-mfa-magic-link`, `webhook-sink` — automatically; they don't need to be named in the `up` step.) + +## Run a subset + +Any Playwright CLI filter works. From `e2e-playground/`, after starting the stack: + +```bash +# One spec file +docker compose run --rm --build playwright npx playwright test totp.spec.ts + +# By name pattern +docker compose run --rm --build playwright npx playwright test -g "SCIM" + +# A whole directory +docker compose run --rm --build playwright npx playwright test social/ +``` + +**`--build` is required whenever a spec file (or anything else under `e2e-playground/`) has changed** — the `playwright` service's Dockerfile bakes test files into the image at build time rather than mounting them live; without `--build` you'll run stale tests, or Playwright will report "No tests found" if a new file was added since the image was last built. + +Don't forget to tear down afterward: `docker compose -f e2e-playground/docker-compose.yml down -v`. + +## Verify results after a run + +Playwright's HTML report is written to `e2e-playground/playwright-report/` on the host (mounted, survives container teardown). To view it: + +```bash +npx playwright show-report e2e-playground/playwright-report +``` + +or open `e2e-playground/playwright-report/index.html` directly in a browser. It shows every test's pass/fail status, timing, and — for failures — a full trace (screenshots, network log, action-by-action replay). + +Raw output (traces, videos on retry, etc.) is under `e2e-playground/test-results/`, also host-mounted. + +A clean run's terminal output ends with a summary line, e.g.: + +``` +XX passed (NNs) +``` + +Any skip is intentional and self-documenting — read the `test.skip(...)` call's message in the relevant spec file for the reason. As of this writing there are no skips tied to real product gaps. + +## Notes + +- Every spec seeds its own state (org, users, connections) — specs are independently runnable and safe to run in parallel or in any order. +- `authorizer-sso` runs a second, separately-configured instance (port 8081) specifically because `--enable-org-discovery=true` is a global login-UX toggle that would otherwise change behavior for every other spec sharing the default instance. +- Test-only secrets (SAML certs, JWT signing keys) live under `fixtures/certs/`, are checked in, and sign nothing real — the same pattern `make dev`'s embedded dev RSA keys already use. diff --git a/e2e-playground/docker-compose.yml b/e2e-playground/docker-compose.yml new file mode 100644 index 000000000..64fa89f20 --- /dev/null +++ b/e2e-playground/docker-compose.yml @@ -0,0 +1,492 @@ +# e2e-playground/docker-compose.yml +services: + authorizer: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8080:8080" + command: + - "--http-port=8080" + - "--url=http://authorizer:8080" + - "--database-type=sqlite" + - "--database-url=/authorizer/e2e-test.db" + - "--admin-secret=e2e-admin-secret" + - "--jwt-type=HS256" + - "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod" + - "--client-id=e2e-client-id" + - "--client-secret=e2e-client-secret" + - "--enable-signup=true" + - "--app-cookie-secure=false" + - "--admin-cookie-secure=false" + - "--app-cookie-same-site=lax" + - "--allowed-origins=http://localhost:8080,http://authorizer:8080" + - "--google-client-id=mock-client-id" + - "--google-client-secret=mock-client-secret" + - "--github-client-id=mock-client-id" + - "--github-client-secret=mock-client-secret" + - "--facebook-client-id=mock-client-id" + - "--facebook-client-secret=mock-client-secret" + - "--linkedin-client-id=mock-client-id" + - "--linkedin-client-secret=mock-client-secret" + - "--apple-client-id=mock-client-id" + - "--apple-client-secret=mock-client-secret" + - "--twitter-client-id=mock-client-id" + - "--twitter-client-secret=mock-client-secret" + - "--discord-client-id=mock-client-id" + - "--discord-client-secret=mock-client-secret" + - "--microsoft-client-id=mock-client-id" + - "--microsoft-client-secret=mock-client-secret" + - "--twitch-client-id=mock-client-id" + - "--twitch-client-secret=mock-client-secret" + - "--roblox-client-id=mock-client-id" + - "--roblox-client-secret=mock-client-secret" + # Enables every e2e-playground-only behavior at once: routes the 10 + # social OAuth providers + SMS sending through mock-oauth/sms-sink + # (hardcoded docker-compose addresses, see internal/config/test_oauth_override.go + # and internal/sms/testwebhook), and relaxes the private-IP SSRF check + # for SSO discovery/token fetches and webhook registration+delivery so + # the docker-private mocks are reachable (scheme allow-list and + # DNS-rebinding pin stay enforced either way). A pure function of Env - + # no separate flag to configure or forget. Never set in production; see + # internal/constants/env.go's E2EEnv doc comment for why this is a + # distinct value from --env=test (internal/integration_tests' own Env). + - "--env=e2e" + - "--smtp-host=mailpit" + - "--smtp-port=1025" + # Ephemeral, purpose-built test stack - no real DoS concern, and the + # default (30rps/20burst) can trip under a full sequential test run's + # admin-API call volume, producing flaky 429s unrelated to product + # correctness. + - "--rate-limit-rps=1000" + - "--rate-limit-burst=1000" + depends_on: + mock-oauth: { condition: service_started } + mock-saml-idp: { condition: service_started } + mailpit: { condition: service_started } + sms-sink: { condition: service_started } + webhook-sink: { condition: service_started } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 2s + timeout: 2s + retries: 30 + + # authorizer-sso is identical to `authorizer` except --enable-org-discovery=true. + # That flag is a GLOBAL login-UX toggle (every /app login gets the email-first + # home-realm-discovery screen, not just org-scoped logins), so it cannot be + # turned on in the `authorizer` service above without breaking the plain PKCE + # flow in tests/oidc-provider.spec.ts (its password field would never appear + # behind the HRD screen). A second service on its own port keeps both test + # suites green. See playwright.config.ts's `sso-discovery` project. + authorizer-sso: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8081:8080" + command: + - "--http-port=8080" + - "--url=http://authorizer-sso:8080" + - "--database-type=sqlite" + - "--database-url=/authorizer/e2e-test-sso.db" + - "--admin-secret=e2e-admin-secret" + - "--jwt-type=HS256" + - "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod" + - "--client-id=e2e-client-id" + - "--client-secret=e2e-client-secret" + - "--enable-signup=true" + - "--app-cookie-secure=false" + - "--admin-cookie-secure=false" + - "--app-cookie-same-site=lax" + - "--enable-org-discovery=true" + - "--allowed-origins=http://localhost:8081,http://authorizer-sso:8080" + - "--google-client-id=mock-client-id" + - "--google-client-secret=mock-client-secret" + - "--github-client-id=mock-client-id" + - "--github-client-secret=mock-client-secret" + - "--facebook-client-id=mock-client-id" + - "--facebook-client-secret=mock-client-secret" + - "--linkedin-client-id=mock-client-id" + - "--linkedin-client-secret=mock-client-secret" + - "--apple-client-id=mock-client-id" + - "--apple-client-secret=mock-client-secret" + - "--twitter-client-id=mock-client-id" + - "--twitter-client-secret=mock-client-secret" + - "--discord-client-id=mock-client-id" + - "--discord-client-secret=mock-client-secret" + - "--microsoft-client-id=mock-client-id" + - "--microsoft-client-secret=mock-client-secret" + - "--twitch-client-id=mock-client-id" + - "--twitch-client-secret=mock-client-secret" + - "--roblox-client-id=mock-client-id" + - "--roblox-client-secret=mock-client-secret" + # See the `authorizer` service's --env=e2e comment above for what this + # enables (OAuth/SMS mock routing + private-host SSRF relaxation). + - "--env=e2e" + - "--smtp-host=mailpit" + - "--smtp-port=1025" + # Ephemeral, purpose-built test stack - no real DoS concern, and the + # default (30rps/20burst) can trip under a full sequential test run's + # admin-API call volume, producing flaky 429s unrelated to product + # correctness. + - "--rate-limit-rps=1000" + - "--rate-limit-burst=1000" + depends_on: + mock-oauth: { condition: service_started } + mock-saml-idp: { condition: service_started } + mailpit: { condition: service_started } + sms-sink: { condition: service_started } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 2s + timeout: 2s + retries: 30 + + # authorizer-webauthn is a third instance solely for tests/webauthn.spec.ts. + # go-webauthn's RPID validation (internal/authenticators/webauthn/webauthn.go + # newRP) rejects any hostname without a dot other than exactly "localhost" - + # "field 'RPID' is not a valid domain string" - and meta.HostURL (which + # supplies that hostname) is ALWAYS derived from the static --url flag once + # it's set (internal/parsers/url.go GetHostFromRequest: --url is "the ONLY + # source", request headers are ignored), never from whatever hostname the + # browser actually navigated with. So the `authorizer`/`authorizer-sso` + # services' single-label compose hostnames can never pass WebAuthn + # registration, no matter what the browser's Host header says - a + # dedicated instance with a dotted --url (via a network alias, since + # compose service names themselves can't contain dots) is the only fix. + authorizer-webauthn: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8082:8080" + networks: + default: + aliases: + - webauthn.e2e-playground.test + command: + - "--http-port=8080" + - "--url=http://webauthn.e2e-playground.test:8080" + - "--database-type=sqlite" + - "--database-url=/authorizer/e2e-test-webauthn.db" + - "--admin-secret=e2e-admin-secret" + - "--jwt-type=HS256" + - "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod" + - "--client-id=e2e-client-id" + - "--client-secret=e2e-client-secret" + - "--enable-signup=true" + - "--app-cookie-secure=false" + - "--admin-cookie-secure=false" + - "--app-cookie-same-site=lax" + - "--allowed-origins=http://webauthn.e2e-playground.test:8080" + - "--smtp-host=mailpit" + - "--smtp-port=1025" + # Ephemeral, purpose-built test stack - no real DoS concern, and the + # default (30rps/20burst) can trip under a full sequential test run's + # admin-API call volume, producing flaky 429s unrelated to product + # correctness. + - "--rate-limit-rps=1000" + - "--rate-limit-burst=1000" + depends_on: + mailpit: { condition: service_started } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 2s + timeout: 2s + retries: 30 + + # authorizer-magic-link is a fourth instance solely for tests/magic-link.spec.ts. + # Magic-link login (--enable-magic-link-login) only ever sends mail when + # --enable-email-verification is also on (internal/service/magic_link_login.go + # gates the whole verification-request+SendEmail block on + # p.Config.EnableEmailVerification), and turning that flag on globally would + # break every other spec that signs up and immediately logs in in the same + # test on the shared `authorizer` service - login.go refuses password login + # for a user whose EmailVerifiedAt is still nil once verification is + # required. A dedicated instance keeps both worlds green, same reasoning as + # authorizer-sso/authorizer-webauthn above. + # --disable-mfa=true keeps this a clean primary-login-method test: without + # it, a brand-new user's first click hits the withheld-token MFA-setup + # offer screen (internal/service/mfa_gate.go) and the verification request + # row is only deleted on the FIRST non-withheld pass through + # VerifyEmailHandler (internal/http_handlers/verify_email.go) - so clicking + # the same link a second time would actually complete a second real login + # instead of failing, defeating the single-use assertion this spec needs. + authorizer-magic-link: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8083:8080" + command: + - "--http-port=8080" + - "--url=http://authorizer-magic-link:8080" + - "--database-type=sqlite" + - "--database-url=/authorizer/e2e-test-magic-link.db" + - "--admin-secret=e2e-admin-secret" + - "--jwt-type=HS256" + - "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod" + - "--client-id=e2e-client-id" + - "--client-secret=e2e-client-secret" + - "--enable-signup=true" + - "--enable-magic-link-login=true" + - "--enable-email-verification=true" + - "--disable-mfa=true" + - "--app-cookie-secure=false" + - "--admin-cookie-secure=false" + - "--app-cookie-same-site=lax" + - "--allowed-origins=http://localhost:8083,http://authorizer-magic-link:8080" + - "--smtp-host=mailpit" + - "--smtp-port=1025" + # Ephemeral, purpose-built test stack - no real DoS concern, and the + # default (30rps/20burst) can trip under a full sequential test run's + # admin-API call volume, producing flaky 429s unrelated to product + # correctness. + - "--rate-limit-rps=1000" + - "--rate-limit-burst=1000" + - "--smtp-sender-email=e2e@authorizer.test" + depends_on: + mailpit: { condition: service_started } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 2s + timeout: 2s + retries: 30 + + # authorizer-mfa-enforced is a fifth instance solely for + # tests/mfa-routing-matrix.spec.ts (the `mfa-on` project). EnforceMFA + # turned out NOT to be a runtime-toggleable admin setting - Task 28 + # verified live that the `_update_env` mutation fixtures/adminClient.ts's + # setEnforceMFA calls is a stub that always errors + # ("deprecated. please configure env via cli args", + # internal/graph/schema.resolvers.go UpdateEnv), matching this repo's v2 + # CLI-flags-only config model (no runtime env mutation exists at all, for + # any setting). --enforce-mfa is a static CLI flag like the + # magic-link/webauthn/sso instances' flags, so it needs its own instance + # rather than toggling the shared `authorizer` service - turning it on + # there would break every other MFA spec (totp/sms-otp/webauthn) that + # relies on the default mfaGateOfferAll (optional, skippable) behavior. + authorizer-mfa-enforced: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8084:8080" + command: + - "--http-port=8080" + - "--url=http://authorizer-mfa-enforced:8080" + - "--database-type=sqlite" + - "--database-url=/authorizer/e2e-test-mfa-enforced.db" + - "--admin-secret=e2e-admin-secret" + - "--jwt-type=HS256" + - "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod" + - "--client-id=e2e-client-id" + - "--client-secret=e2e-client-secret" + - "--enable-signup=true" + - "--enforce-mfa=true" + - "--app-cookie-secure=false" + - "--admin-cookie-secure=false" + - "--app-cookie-same-site=lax" + - "--allowed-origins=http://localhost:8084,http://authorizer-mfa-enforced:8080" + - "--smtp-host=mailpit" + - "--smtp-port=1025" + # Ephemeral, purpose-built test stack - no real DoS concern, and the + # default (30rps/20burst) can trip under a full sequential test run's + # admin-API call volume, producing flaky 429s unrelated to product + # correctness. + - "--rate-limit-rps=1000" + - "--rate-limit-burst=1000" + depends_on: + mailpit: { condition: service_started } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 2s + timeout: 2s + retries: 30 + + # authorizer-mfa-magic-link is a sixth instance, also solely for + # tests/mfa-routing-matrix.spec.ts: the one test in that spec needing + # BOTH --enforce-mfa=true AND magic-link login. Can't reuse + # authorizer-magic-link above - that instance is pinned to + # --disable-mfa=true (which Finalize forces EnforceMFA off regardless, + # config.go's DisableMFA kill switch), needed for its own single-use-link + # replay test. Can't reuse authorizer-mfa-enforced above either - it has + # no email service verification path wired for magic-link + # (--enable-magic-link-login/--enable-email-verification), and turning + # those on there would break that instance's plain password-login tests + # (an unverified user's password login diverts into an email-OTP + # verification-pending flow before ever reaching the MFA gate - see + # internal/service/login.go's EmailVerifiedAt==nil branch). + authorizer-mfa-magic-link: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8085:8080" + command: + - "--http-port=8080" + - "--url=http://authorizer-mfa-magic-link:8080" + - "--database-type=sqlite" + - "--database-url=/authorizer/e2e-test-mfa-magic-link.db" + - "--admin-secret=e2e-admin-secret" + - "--jwt-type=HS256" + - "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod" + - "--client-id=e2e-client-id" + - "--client-secret=e2e-client-secret" + - "--enable-signup=true" + - "--enable-magic-link-login=true" + - "--enable-email-verification=true" + - "--enforce-mfa=true" + - "--app-cookie-secure=false" + - "--admin-cookie-secure=false" + - "--app-cookie-same-site=lax" + - "--allowed-origins=http://localhost:8085,http://authorizer-mfa-magic-link:8080" + - "--smtp-host=mailpit" + - "--smtp-port=1025" + # Ephemeral, purpose-built test stack - no real DoS concern, and the + # default (30rps/20burst) can trip under a full sequential test run's + # admin-API call volume, producing flaky 429s unrelated to product + # correctness. + - "--rate-limit-rps=1000" + - "--rate-limit-burst=1000" + - "--smtp-sender-email=e2e@authorizer.test" + depends_on: + mailpit: { condition: service_started } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 2s + timeout: 2s + retries: 30 + + mock-oauth: + build: ./mocks/mock-oauth + ports: ["4000:4000"] + + mock-saml-idp: + build: ./mocks/mock-saml-idp + ports: ["4001:4001"] + + sms-sink: + build: ./mocks/sms-sink + ports: ["4100:4100"] + + webhook-sink: + build: ./mocks/webhook-sink + ports: ["4200:4200"] + + mailpit: + image: axllent/mailpit:latest + ports: + - "1025:1025" + - "8025:8025" + + # Runs the Playwright test process itself inside the compose network so + # browser-driven redirects to mock-oauth/mock-saml-idp resolve the same + # docker-internal hostnames as server-side fetches (see Dockerfile). + # Never started by `up`; invoked via `docker compose run --rm playwright ...`. + playwright: + build: + context: . + dockerfile: Dockerfile + environment: + AUTHORIZER_BASE_URL: http://authorizer:8080 + AUTHORIZER_SSO_BASE_URL: http://authorizer-sso:8080 + AUTHORIZER_WEBAUTHN_BASE_URL: http://webauthn.e2e-playground.test:8080 + AUTHORIZER_MAGIC_LINK_BASE_URL: http://authorizer-magic-link:8080 + AUTHORIZER_MFA_ENFORCED_BASE_URL: http://authorizer-mfa-enforced:8080 + AUTHORIZER_MFA_MAGIC_LINK_BASE_URL: http://authorizer-mfa-magic-link:8080 + MOCK_OAUTH_BASE_URL: http://mock-oauth:4000 + # https, not http: mock-saml-idp terminates TLS (see its server.ts) so + # Authorizer's idp_sso_url validation (https-only, no test bypass) + # accepts the stored SSO URL. Callers must ignore the self-signed cert + # (tests/saml-sp.spec.ts sets ignoreHTTPSErrors for this reason). + MOCK_SAML_BASE_URL: https://mock-saml-idp:4001 + SMS_SINK_BASE_URL: http://sms-sink:4100 + WEBHOOK_SINK_BASE_URL: http://webhook-sink:4200 + MAILPIT_BASE_URL: http://mailpit:8025 + volumes: + - ./playwright-report:/e2e/playwright-report + - ./test-results:/e2e/test-results + depends_on: + authorizer: { condition: service_healthy } + authorizer-sso: { condition: service_healthy } + authorizer-webauthn: { condition: service_healthy } + authorizer-magic-link: { condition: service_healthy } + authorizer-mfa-enforced: { condition: service_healthy } + authorizer-mfa-magic-link: { condition: service_healthy } + mock-oauth: { condition: service_started } + mock-saml-idp: { condition: service_started } + mailpit: { condition: service_started } + sms-sink: { condition: service_started } + webhook-sink: { condition: service_started } + + + # go-sdk-tests runs the SDK-driven Go suite (sdk-tests/go) inside the compose + # network, exactly like the `playwright` service above and for the same reason: + # the docker-internal hostnames the tests target (and the WebAuthn instance's + # pinned origin webauthn.e2e-playground.test) must resolve and match each + # server's --url/--allowed-origins. Never started by `up`; invoked via + # `docker compose run --rm go-sdk-tests`. Additive — it changes no existing + # service. It drives the ACTUALLY-PUBLISHED authorizer-go SDK + # (v2.2.0-rc.4) over the enterprise/MFA feature set; see sdk-tests/go/README.md + # for exactly what is SDK-driven vs. inherently browser-bound. + go-sdk-tests: + build: + context: ./sdk-tests/go + dockerfile: Dockerfile + environment: + AUTHORIZER_BASE_URL: http://authorizer:8080 + AUTHORIZER_WEBAUTHN_BASE_URL: http://webauthn.e2e-playground.test:8080 + AUTHORIZER_MFA_ENFORCED_BASE_URL: http://authorizer-mfa-enforced:8080 + AUTHORIZER_MFA_MAGIC_LINK_BASE_URL: http://authorizer-mfa-magic-link:8080 + AUTHORIZER_ADMIN_SECRET: e2e-admin-secret + AUTHORIZER_CLIENT_ID: e2e-client-id + AUTHORIZER_CLIENT_SECRET: e2e-client-secret + SMS_SINK_BASE_URL: http://sms-sink:4100 + WEBHOOK_SINK_BASE_URL: http://webhook-sink:4200 + MAILPIT_BASE_URL: http://mailpit:8025 + depends_on: + authorizer: { condition: service_healthy } + authorizer-webauthn: { condition: service_healthy } + authorizer-mfa-enforced: { condition: service_healthy } + authorizer-mfa-magic-link: { condition: service_healthy } + mock-oauth: { condition: service_started } + mock-saml-idp: { condition: service_started } + mailpit: { condition: service_started } + sms-sink: { condition: service_started } + webhook-sink: { condition: service_started } + + # Runs the Python SDK e2e suite (sdk-tests/python) inside the compose network, + # for the SAME reason the `playwright` service above does: the social-OAuth + # redirect chain (mock-oauth) and the WebAuthn RP origin + # (webauthn.e2e-playground.test:8080) only resolve/validate from within the + # docker-internal network. It depends only on the published authorizer-py SDK + # (installed from PyPI in its Dockerfile) — no source of this repo is copied + # into it. Never started by `up`; invoked via + # `docker compose run --rm python-sdk`. + python-sdk: + build: + context: ./sdk-tests/python + dockerfile: Dockerfile + environment: + AUTHORIZER_BASE_URL: http://authorizer:8080 + AUTHORIZER_WEBAUTHN_BASE_URL: http://webauthn.e2e-playground.test:8080 + AUTHORIZER_MFA_ENFORCED_BASE_URL: http://authorizer-mfa-enforced:8080 + AUTHORIZER_MFA_MAGIC_LINK_BASE_URL: http://authorizer-mfa-magic-link:8080 + AUTHORIZER_ADMIN_SECRET: e2e-admin-secret + AUTHORIZER_CLIENT_ID: e2e-client-id + AUTHORIZER_CLIENT_SECRET: e2e-client-secret + MOCK_OAUTH_BASE_URL: http://mock-oauth:4000 + SMS_SINK_BASE_URL: http://sms-sink:4100 + WEBHOOK_SINK_BASE_URL: http://webhook-sink:4200 + MAILPIT_BASE_URL: http://mailpit:8025 + depends_on: + authorizer: { condition: service_healthy } + authorizer-webauthn: { condition: service_healthy } + authorizer-mfa-enforced: { condition: service_healthy } + authorizer-mfa-magic-link: { condition: service_healthy } + mock-oauth: { condition: service_started } + mailpit: { condition: service_started } + sms-sink: { condition: service_started } + webhook-sink: { condition: service_started } diff --git a/e2e-playground/fixtures/adminClient.smoke.spec.ts b/e2e-playground/fixtures/adminClient.smoke.spec.ts new file mode 100644 index 000000000..3050f36e3 --- /dev/null +++ b/e2e-playground/fixtures/adminClient.smoke.spec.ts @@ -0,0 +1,9 @@ +// e2e-playground/fixtures/adminClient.smoke.spec.ts +import { test, expect } from '@playwright/test'; +import { createOrg, addVerifiedDomain } from './adminClient'; + +test('admin seed client can create an org and verify a domain', async () => { + const org = await createOrg(`e2e-smoke-${Date.now()}`); + expect(org.id).toBeTruthy(); + await expect(addVerifiedDomain(org.id, `${org.id}.example.com`)).resolves.not.toThrow(); +}); diff --git a/e2e-playground/fixtures/adminClient.ts b/e2e-playground/fixtures/adminClient.ts new file mode 100644 index 000000000..c170331e1 --- /dev/null +++ b/e2e-playground/fixtures/adminClient.ts @@ -0,0 +1,332 @@ +// e2e-playground/fixtures/adminClient.ts +import { GraphQLClient, gql } from 'graphql-request'; + +const BASE_URL = process.env.AUTHORIZER_BASE_URL || 'http://localhost:8080'; +const ADMIN_SECRET = process.env.AUTHORIZER_ADMIN_SECRET || 'e2e-admin-secret'; + +// Admin auth is the x-authorizer-admin-secret header (see +// internal/token/admin_token.go IsSuperAdmin / internal/e2e/smoke_test.go), +// not a Bearer token. Origin is required too: the CSRF middleware rejects +// state-changing requests with no Origin/Referer (internal/graph/schema.graphqls +// comment on AllowedOrigins; must match the server's --allowed-origins). +const client = new GraphQLClient(`${BASE_URL}/graphql`, { + headers: { 'x-authorizer-admin-secret': ADMIN_SECRET, Origin: BASE_URL }, +}); + +// getClient returns the default (AUTHORIZER_BASE_URL / :8080) admin client, +// or a one-off client scoped to baseUrl when given. Needed because this +// module's BASE_URL is a single process-wide constant, independent of +// Playwright's per-project `use.baseURL` — the `sso-discovery` project points +// page navigation at :8081 (authorizer-sso) via its own baseURL, but without +// this, admin calls below would still silently hit :8080's `authorizer` +// service instead, so the org/connection the JIT test creates would exist on +// the wrong server. +function getClient(baseUrl?: string): GraphQLClient { + if (!baseUrl || baseUrl === BASE_URL) return client; + return new GraphQLClient(`${baseUrl}/graphql`, { + headers: { 'x-authorizer-admin-secret': ADMIN_SECRET, Origin: baseUrl }, + }); +} + +export async function createOrg(name: string, baseUrl?: string): Promise<{ id: string; name: string }> { + const query = gql` + mutation ($params: CreateOrganizationRequest!) { + _create_organization(params: $params) { id name } + } + `; + const res = await getClient(baseUrl).request<{ _create_organization: { id: string; name: string } }>(query, { + params: { name, display_name: name }, + }); + return res._create_organization; +} + +export async function createOIDCConnection( + orgId: string, + opts: { name: string; issuerUrl: string; clientId: string; clientSecret: string }, + baseUrl?: string +): Promise<{ id: string }> { + const query = gql` + mutation ($params: CreateOrgOIDCConnectionRequest!) { + _create_org_oidc_connection(params: $params) { id } + } + `; + const res = await getClient(baseUrl).request<{ _create_org_oidc_connection: { id: string } }>(query, { + params: { + org_id: orgId, + name: opts.name, + issuer_url: opts.issuerUrl, + client_id: opts.clientId, + client_secret: opts.clientSecret, + }, + }); + return res._create_org_oidc_connection; +} + +export async function createSAMLConnection( + orgId: string, + opts: { name: string; idpEntityId: string; idpSsoUrl: string; idpCertificate: string; allowIdpInitiated?: boolean }, + baseUrl?: string +): Promise<{ id: string }> { + const query = gql` + mutation ($params: CreateOrgSAMLConnectionRequest!) { + _create_org_saml_connection(params: $params) { id } + } + `; + const res = await getClient(baseUrl).request<{ _create_org_saml_connection: { id: string } }>(query, { + params: { + org_id: orgId, + name: opts.name, + idp_entity_id: opts.idpEntityId, + idp_sso_url: opts.idpSsoUrl, + idp_certificate: opts.idpCertificate, + allow_idp_initiated: opts.allowIdpInitiated ?? false, + }, + }); + return res._create_org_saml_connection; +} + +// deleteSAMLConnectionByEntityID removes any existing OrgSAMLConnection whose +// idp_entity_id matches (a no-op if none exists). idp_entity_id is enforced +// globally-unique across all orgs (internal/service/admin_org_saml.go +// GetTrustedIssuerByIssuerURL check), so a test that must use a fixed entity +// ID (matching a mock IdP's own hardcoded entityID) needs to clean up any +// stale row left by a prior unclean run before creating a fresh connection. +// There's no admin query keyed on idp_entity_id directly, so this lists +// trusted issuers (the shared backing table for SAML/OIDC connections and +// machine-identity issuers, exposed via issuer_url on TrustedIssuer) and +// deletes the generic way (_delete_trusted_issuer works on any issuer kind). +export async function deleteSAMLConnectionByEntityID(idpEntityId: string): Promise { + const listQuery = gql` + query ($params: ListTrustedIssuersRequest) { + _trusted_issuers(params: $params) { + trusted_issuers { id issuer_url } + } + } + `; + const res = await client.request<{ + _trusted_issuers: { trusted_issuers: { id: string; issuer_url: string }[] }; + }>(listQuery, { params: { pagination: { limit: 1000 } } }); + const stale = res._trusted_issuers.trusted_issuers.find((t) => t.issuer_url === idpEntityId); + if (!stale) return; + + const deleteQuery = gql` + mutation ($params: TrustedIssuerRequest!) { + _delete_trusted_issuer(params: $params) { message } + } + `; + await client.request(deleteQuery, { params: { id: stale.id } }); +} + +// signupUser drives the public `signup` mutation (not an admin op — the +// x-authorizer-admin-secret header on `client` is simply ignored by it). +export async function signupUser(email: string, password: string): Promise { + const query = gql` + mutation ($params: SignUpRequest!) { + signup(params: $params) { message } + } + `; + await client.request(query, { params: { email, password, confirm_password: password } }); +} + +// getUserIdByEmail resolves a user's id via the admin `_users` search query +// (query is a substring filter over email/given_name/family_name/nickname — +// see ListUsersRequest doc comment — so the exact-match find() below guards +// against a substring false-positive). +export async function getUserIdByEmail(email: string): Promise { + const query = gql` + query ($params: ListUsersRequest) { + _users(params: $params) { users { id email } } + } + `; + const res = await client.request<{ _users: { users: { id: string; email: string | null }[] } }>(query, { + params: { query: email }, + }); + const user = res._users.users.find((u) => u.email === email); + if (!user) throw new Error(`user not found for email ${email}`); + return user.id; +} + +// getUserByEmail resolves a user's profile fields (given_name/family_name/ +// signup_methods) via the same admin `_users` search query getUserIdByEmail +// uses. Needed by social-login specs (tests/social/*.spec.ts) to verify a +// provider's profile claims were actually mapped onto the stored user, not +// just that a session was established. +export async function getUserByEmail( + email: string +): Promise<{ id: string; email: string | null; given_name: string | null; family_name: string | null; signup_methods: string }> { + const query = gql` + query ($params: ListUsersRequest) { + _users(params: $params) { users { id email given_name family_name signup_methods } } + } + `; + const res = await client.request<{ + _users: { + users: { id: string; email: string | null; given_name: string | null; family_name: string | null; signup_methods: string }[]; + }; + }>(query, { params: { query: email } }); + const user = res._users.users.find((u) => u.email === email); + if (!user) throw new Error(`user not found for email ${email}`); + return user; +} + +// getUserByNickname mirrors getUserByEmail for providers that never return +// an email address at all - currently only Twitter/X (processTwitterUserInfo, +// internal/http_handlers/oauth_callback.go, sets Nickname from the profile's +// `username` but leaves Email nil; real Twitter's API doesn't expose email, +// this isn't a mock artifact). tests/social/twitter.spec.ts uses this in +// place of getUserByEmail to look up the account it just created. +export async function getUserByNickname( + nickname: string +): Promise<{ id: string; given_name: string | null; family_name: string | null; nickname: string | null; signup_methods: string }> { + const query = gql` + query ($params: ListUsersRequest) { + _users(params: $params) { users { id given_name family_name nickname signup_methods } } + } + `; + const res = await client.request<{ + _users: { + users: { id: string; given_name: string | null; family_name: string | null; nickname: string | null; signup_methods: string }[]; + }; + }>(query, { params: { query: nickname } }); + const user = res._users.users.find((u) => u.nickname === nickname); + if (!user) throw new Error(`user not found for nickname ${nickname}`); + return user; +} + +// verifyUserEmail force-verifies a user's email (and sets given/family name) +// via the admin `_update_user` mutation, standing in for clicking the real +// verification link — needed because SAML IdP-side issuance +// (internal/http_handlers/saml_idp.go authorizeSAMLIssuance) refuses to +// assert an unverified email as the Subject NameID. +// +// given_name/family_name are required here, not optional convenience: this +// mutation's "at least one param" gate (internal/service/admin_users.go +// UpdateUser) checks GivenName/FamilyName/etc. but NOT EmailVerified, so a +// call with only email_verified set is rejected with "please enter atleast +// one param to update" even though EmailVerified is applied further down +// when present — a real, pre-existing gap in that gate, unrelated to SAML. +// Supplying a name works around it (and doubles as the SAML firstName/ +// lastName attribute source for tests/saml-idp.spec.ts). +export async function verifyUserEmail( + userId: string, + opts: { givenName: string; familyName: string } +): Promise { + const query = gql` + mutation ($params: UpdateUserRequest!) { + _update_user(params: $params) { id } + } + `; + await client.request(query, { + params: { id: userId, email_verified: true, given_name: opts.givenName, family_name: opts.familyName }, + }); +} + +// addOrgMember adds an existing user to an org. SAML IdP-side issuance +// requires org membership (authorizeSAMLIssuance) — any authenticated +// Authorizer user could otherwise obtain an assertion for any org's SP. +export async function addOrgMember(orgId: string, userId: string): Promise { + const query = gql` + mutation ($params: AddOrgMemberRequest!) { + _add_org_member(params: $params) { user_id } + } + `; + await client.request(query, { params: { org_id: orgId, user_id: userId } }); +} + +// createSAMLServiceProvider registers a downstream SP on the IdP side (the +// inverse of createSAMLConnection, which registers an upstream IdP on the SP +// side) via `_create_saml_service_provider`. +export async function createSAMLServiceProvider( + orgId: string, + opts: { name: string; entityId: string; acsUrl: string } +): Promise<{ id: string; entity_id: string }> { + const query = gql` + mutation ($params: CreateSAMLServiceProviderRequest!) { + _create_saml_service_provider(params: $params) { id entity_id } + } + `; + const res = await client.request<{ _create_saml_service_provider: { id: string; entity_id: string } }>(query, { + params: { org_id: orgId, name: opts.name, entity_id: opts.entityId, acs_url: opts.acsUrl }, + }); + return res._create_saml_service_provider; +} + +export async function addVerifiedDomain(orgId: string, domain: string, baseUrl?: string): Promise { + const query = gql` + mutation ($params: AddVerifiedOrgDomainRequest!) { + _add_verified_org_domain(params: $params) { domain } + } + `; + await getClient(baseUrl).request(query, { params: { org_id: orgId, domain } }); +} + +export async function createSCIMEndpoint(orgId: string): Promise<{ token: string; endpoint: string }> { + const query = gql` + mutation ($params: CreateScimEndpointRequest!) { + _create_scim_endpoint(params: $params) { + token + scim_endpoint { id } + } + } + `; + const res = await client.request<{ _create_scim_endpoint: { token: string; scim_endpoint: { id: string } } }>( + query, + { params: { org_id: orgId } } + ); + return { token: res._create_scim_endpoint.token, endpoint: `/scim/v2` }; +} + +// getUserPhoneNumberByEmail resolves a user's stored phone_number via the +// admin `_users` search query. Needed by tests/scim.spec.ts's full-attribute +// PATCH coverage: the SCIM PATCH response never echoes phoneNumbers back +// (scimUserResource, internal/http_handlers/scim/users.go, has no phone field), +// so persistence of a `phoneNumbers` PATCH can only be confirmed out-of-band. +export async function getUserPhoneNumberByEmail(email: string): Promise { + const query = gql` + query ($params: ListUsersRequest) { + _users(params: $params) { users { id email phone_number } } + } + `; + const res = await client.request<{ _users: { users: { id: string; email: string | null; phone_number: string | null }[] } }>( + query, + { params: { query: email } } + ); + const user = res._users.users.find((u) => u.email === email); + if (!user) throw new Error(`user not found for email ${email}`); + return user.phone_number; +} + +// addWebhook registers a webhook via the admin `_add_webhook` mutation. Webhooks +// are global (not org-scoped): GetWebhookByEventName matches by event_name prefix +// across all orgs, so one webhook per event_name fires for every matching event. +// Endpoint may be a docker-private host only because the target `authorizer` +// instance runs with --env=e2e (see docker-compose). +export async function addWebhook(opts: { + eventName: string; + endpoint: string; + enabled?: boolean; + headers?: Record; +}): Promise { + const query = gql` + mutation ($params: AddWebhookRequest!) { + _add_webhook(params: $params) { message } + } + `; + await client.request(query, { + params: { + event_name: opts.eventName, + endpoint: opts.endpoint, + enabled: opts.enabled ?? true, + headers: opts.headers ?? {}, + }, + }); +} + +export async function setEnforceMFA(enabled: boolean): Promise { + const query = gql` + mutation ($params: UpdateEnvRequest!) { + _update_env(params: $params) { message } + } + `; + await client.request(query, { params: { ENFORCE_MULTI_FACTOR_AUTHENTICATION: enabled } }); +} diff --git a/e2e-playground/fixtures/certs/gen-certs.sh b/e2e-playground/fixtures/certs/gen-certs.sh new file mode 100755 index 000000000..987d8ea55 --- /dev/null +++ b/e2e-playground/fixtures/certs/gen-certs.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +openssl req -x509 -newkey rsa:2048 -keyout idp-key.pem -out idp-cert.pem \ + -days 3650 -nodes -subj "/CN=mock-saml-idp.e2e-playground.local" +cp idp-cert.pem idp-key.pem ../../mocks/mock-saml-idp/certs/ +echo "Generated test-only SAML IdP cert/key (not used for anything real)." diff --git a/e2e-playground/fixtures/certs/idp-cert.pem b/e2e-playground/fixtures/certs/idp-cert.pem new file mode 100644 index 000000000..8625b2bd6 --- /dev/null +++ b/e2e-playground/fixtures/certs/idp-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOzCCAiOgAwIBAgIUEzReCg9zKNAJllQcrba+TiM3ibkwDQYJKoZIhvcNAQEL +BQAwLTErMCkGA1UEAwwibW9jay1zYW1sLWlkcC5lMmUtcGxheWdyb3VuZC5sb2Nh +bDAeFw0yNjA3MjAxMjAzMzBaFw0zNjA3MTcxMjAzMzBaMC0xKzApBgNVBAMMIm1v +Y2stc2FtbC1pZHAuZTJlLXBsYXlncm91bmQubG9jYWwwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCfVSYARVlvp42jwuyQN76TYuqPd2s76nIv0Ws2WMLb +Q51RfG7F37akGWFX5RJP6Y9G0Rx28Apr41Oz8AXs22yzkI1f6rzVXesRUK4ictPJ +oVdoXNDPJyLHDoEyWBgzl0ca4I0EQn4ff3RstPQDZcLHd99PJPSQuDb0C2Cvl6oW +Cgx9oSRlQC7acNkcfbtE1fQVmbq2lPCadHuwnyEb3WbUsCxtw1E/lczLPfTv5E1E +5kGK0kSGGiNzi8RdANeZd43QljKDTentfp5EvnCr5fKFC95NX78YxcaTy0MovDFm +kCAwE8BTwgaKHp7iO4gvUIZwgfmdehLCAWf7B2yc0NlHAgMBAAGjUzBRMB0GA1Ud +DgQWBBS1ZxVMxDDcKi8ksIH/5r2jnbf74zAfBgNVHSMEGDAWgBS1ZxVMxDDcKi8k +sIH/5r2jnbf74zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBz +5i7VlC/SC9tM2Km+1R+n2DVP4aA75ShlgeBAgBUr8dVzjjksiN4hHp44p2J6bpf6 +GopbqKc0SJKy5dfwfzi7nzJQX3TvdqGY2D2LqYKqdH5GcrzOXKgCzjkgLCCQsAs0 +T5qst8dZWoxUW1BkP338Z2SqE1v6Y9SmBzXapXttRs8GhMLemiWbf6ni8UCIj94T +v/VD/mVCvCBfYMVVUaFDlIBjK+2O2mBRV31+acFoMcnCHh3u8FXAjmrosmhu/6JW +7c3qCDcXHsO+sA4gOW0Pw7gixUsO2VpH/ERXjZrpxxZvK2cZ2uuCRwPEhO2bIZwr +0GWE5nn4ZOPgeyqUHgfB +-----END CERTIFICATE----- diff --git a/e2e-playground/fixtures/certs/idp-key.pem b/e2e-playground/fixtures/certs/idp-key.pem new file mode 100644 index 000000000..33ae94518 --- /dev/null +++ b/e2e-playground/fixtures/certs/idp-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCfVSYARVlvp42j +wuyQN76TYuqPd2s76nIv0Ws2WMLbQ51RfG7F37akGWFX5RJP6Y9G0Rx28Apr41Oz +8AXs22yzkI1f6rzVXesRUK4ictPJoVdoXNDPJyLHDoEyWBgzl0ca4I0EQn4ff3Rs +tPQDZcLHd99PJPSQuDb0C2Cvl6oWCgx9oSRlQC7acNkcfbtE1fQVmbq2lPCadHuw +nyEb3WbUsCxtw1E/lczLPfTv5E1E5kGK0kSGGiNzi8RdANeZd43QljKDTentfp5E +vnCr5fKFC95NX78YxcaTy0MovDFmkCAwE8BTwgaKHp7iO4gvUIZwgfmdehLCAWf7 +B2yc0NlHAgMBAAECggEAMHBegS6UJhG2Sdb0vFN2KLClxr/zZd+8nzT3dyo88xP3 +O9KsipOcnv7oTSRjENBcspbYJICNaodN5yJNati6j0ye7k4a4nMPB9CTX/2wzVez +jxLImHG5bPLH7FpD4UtYXp9tv6HHXiQNbQ8GMBI7yRB0X0dI4sZeTos29asSFmi0 +ZW9yugGJ/9RhLc36g85uS4aJLR6wDF9O1BjQ9BsuSD32alQpGleyaZQ9FA5lgyDR +lKc/q8oalKJJtRmnm3xmq2+xg5tPN2sH/tNZYuOJqJFyiEzQ8Z0atBoo3SUdMdLn +BeXFJkvQbLXwStQTap1LGzsz2qiEIq/TMpgTnwLBYQKBgQDcfVO85zZYl1C+IKjn +IKTbJYzKZEXjgYb8FCM2UdBDAVgzioDheFaZ7w28ercZlxaYZ2KJtSLxIeboh/Ui +Sgg1vTRVuc3BHO3rHXm4topGICSzAxZ80wjav3B2N41fcIIBoMbfaYiu6/JQaoPn +4MqWqwC94sMMB8SF2Pzd70y9cwKBgQC4/lnxUNvJZVL5Sq69m3fXfIDTfkDnBolW +3Mi9WA4IzI5qxqaP3kL+AmR0liCxUiS19+TbAIjsVbFrpOWDL2ph5PEj7V0somIk +qWjl7bA2LwA1zcODJ0kfntdRp1qUMpj6hXk+teQlMTxuh0kLyofrPfIb8XI5NTov +wpwHasY/3QKBgGxpsyLPDQnCXREfPe1nP6gBbpiVdUfICHcp76Zl0+EeaB/vmi9C +3FIUGMz0CdOrVpDZRLoxNl0aLk9nikCx5heGUJVWJrUtZE6Wz6LjHlocs+7RNd1q +ZpAoUUPPTNQAnevvAdoYKfzYRu0DcpgxD2vF6Td0qDLiHt8xMiRt5W3BAoGAflH4 +esaa/f+5U88CWSijAbrbgQ9SJC8bcvvZ+yj4lFuR2CmDrPO5TRe3HsEw28RamwMF +++F2neK5/uYfbp/fBa++VakMmaDcYWpo3bCbRbR8cUDrA1C9JuFg6DndqRqPyWmA +7Chp/FeNi2/HmkyW2TR4cUpCk/vbmqdJwerQKuUCgYAXXmhEhthpYRDE9LgOBhXa +Ajv8qQj4DZq9fzwwrru3Y+JRoHcDjkENeW/KTEsXkWdtINldk7I9uXrLpbuwzJwo +X29ZOTCjfFMRH7pThOlQBvYgv0ImGhH2G546dBLQ9AgiwhEWAiBNHmPq9GTXY4tw +xSTYrGCQvuDpmd5S4lPmPQ== +-----END PRIVATE KEY----- diff --git a/e2e-playground/fixtures/graphqlRequest.ts b/e2e-playground/fixtures/graphqlRequest.ts new file mode 100644 index 000000000..6f295344c --- /dev/null +++ b/e2e-playground/fixtures/graphqlRequest.ts @@ -0,0 +1,62 @@ +// e2e-playground/fixtures/graphqlRequest.ts +import { APIRequestContext } from '@playwright/test'; + +// Shared by any pure-GraphQL (no browser UI) MFA spec that goes through the +// mfa_session cookie flow: tests/totp.spec.ts (Task 24) hit this first, and +// tests/sms-otp.spec.ts (Task 25) needs the exact same thing, so it's +// extracted here rather than copy-pasted a second time. The MFA flow is +// cookie-based (mfa_session / mfa_session_domain, set by login's Set-Cookie +// header and read back by totp_mfa_setup/sms_otp_mfa_setup/verify_otp - see +// internal/cookie/mfa_session.go and internal/service/otp_mfa_setup.go's +// resolveOTPSetupCaller), and neither graphql-request nor Node's fetch +// maintain a cookie jar across separate client.request() calls. Playwright's +// own `request` fixture (APIRequestContext) does maintain a cookie jar +// automatically across calls made through it within one test - confirmed by +// totp.spec.ts - so login's Set-Cookie is replayed on the following +// totp_mfa_setup/sms_otp_mfa_setup/verify_otp calls with no manual Cookie +// passthrough needed. +export async function graphql( + request: APIRequestContext, + baseURL: string, + query: string, + variables: Record +): Promise { + const res = await request.post(`${baseURL}/graphql`, { + // Absolute URL, not a relative '/graphql' path: request.post resolves a + // relative path against the `request` fixture's own configured baseURL + // (the Playwright project's use.baseURL), silently ignoring this + // function's `baseURL` argument for anything but the Origin header + // below - a real bug found in Task 28 (mfa-routing-matrix.spec.ts), + // which needs to target a second instance + // (authorizer-mfa-magic-link) from within the `mfa-on` project + // (baseURL authorizer-mfa-enforced). Every caller up to that point + // happened to pass the same baseURL as their project's, so a relative + // path resolved to the right host by coincidence and this never + // surfaced. Worse, the wrong-host request was rejected by CSRF + // (Origin not in that host's --allowed-origins) with a non-GraphQL-shaped + // body ({error, error_description}, no `errors` array) that the check + // below didn't catch either - silently returning undefined instead of + // failing loudly. See the `body.error` check below for that half of the + // fix. + data: { query, variables }, + // CSRF middleware requires Origin/Referer on state-changing requests + // (internal/http_handlers/csrf.go) - same rationale as the GraphQLClient + // Origin header in tests/oidc-provider.spec.ts. Playwright's request + // fixture doesn't send Origin the way a real browser would, so it's set + // explicitly here. The other CSRF requirement (Content-Type: + // application/json) is satisfied automatically: passing a plain object + // as `data` makes Playwright JSON-encode the body and set that header. + headers: { Origin: baseURL }, + }); + const body = await res.json(); + if (body.error) { + // Non-GraphQL-shaped error (e.g. CSRF middleware's {error, + // error_description}, returned before the request ever reaches the + // GraphQL handler) - not caught by the body.errors check below. + throw new Error(`Request error: ${JSON.stringify(body)}`); + } + if (body.errors) { + throw new Error(`GraphQL error: ${JSON.stringify(body.errors)}`); + } + return body.data as T; +} diff --git a/e2e-playground/mocks/mock-oauth/Dockerfile b/e2e-playground/mocks/mock-oauth/Dockerfile new file mode 100644 index 000000000..0929e019a --- /dev/null +++ b/e2e-playground/mocks/mock-oauth/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY server.ts tsconfig.json ./ +EXPOSE 4000 +CMD ["npm", "start"] diff --git a/e2e-playground/mocks/mock-oauth/package-lock.json b/e2e-playground/mocks/mock-oauth/package-lock.json new file mode 100644 index 000000000..b3fda66b7 --- /dev/null +++ b/e2e-playground/mocks/mock-oauth/package-lock.json @@ -0,0 +1,1170 @@ +{ + "name": "mock-oauth", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mock-oauth", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.0", + "jose": "^5.9.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.7.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/e2e-playground/mocks/mock-oauth/package.json b/e2e-playground/mocks/mock-oauth/package.json new file mode 100644 index 000000000..f751722c9 --- /dev/null +++ b/e2e-playground/mocks/mock-oauth/package.json @@ -0,0 +1,16 @@ +{ + "name": "mock-oauth", + "private": true, + "version": "1.0.0", + "scripts": { "start": "ts-node server.ts", "test": "node --test -r ts-node/register server.test.ts" }, + "dependencies": { + "express": "^4.21.0", + "jose": "^5.9.0" + }, + "devDependencies": { + "ts-node": "^10.9.2", + "typescript": "^5.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.7.0" + } +} diff --git a/e2e-playground/mocks/mock-oauth/server.test.ts b/e2e-playground/mocks/mock-oauth/server.test.ts new file mode 100644 index 000000000..9a17c8507 --- /dev/null +++ b/e2e-playground/mocks/mock-oauth/server.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; +import { app } from './server'; +import type { Server } from 'node:http'; + +let server: Server; + +test.before(() => { + server = app.listen(0); +}); +test.after(() => server.close()); + +test('token endpoint issues an access token, userinfo returns configured profile', async () => { + const addr = server.address(); + const base = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`; + + await fetch(`${base}/github/__configure`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile: { name: 'Ada Lovelace', email: 'ada@example.com', avatar_url: 'https://example.com/a.png' } }), + }); + + const tokenRes = await fetch(`${base}/github/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=authorization_code&code=any-code', + }); + assert.strictEqual(tokenRes.status, 200); + const { access_token } = await tokenRes.json(); + assert.ok(access_token); + + const userRes = await fetch(`${base}/github/userinfo`, { headers: { authorization: `Bearer ${access_token}` } }); + assert.strictEqual(userRes.status, 200); + const profile = await userRes.json(); + assert.strictEqual(profile.name, 'Ada Lovelace'); + assert.strictEqual(profile.email, 'ada@example.com'); +}); diff --git a/e2e-playground/mocks/mock-oauth/server.ts b/e2e-playground/mocks/mock-oauth/server.ts new file mode 100644 index 000000000..54711cc8b --- /dev/null +++ b/e2e-playground/mocks/mock-oauth/server.ts @@ -0,0 +1,166 @@ +import express from 'express'; +import * as jose from 'jose'; +import crypto from 'node:crypto'; + +export const app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// In-memory per-provider state: current profile to return, and issued tokens. +const profiles: Record> = {}; +const issuedTokens = new Map(); + +let signingKey: jose.KeyLike; +let publicJwk: jose.JWK; +const keyReady = jose.generateKeyPair('RS256').then(async ({ privateKey, publicKey }) => { + signingKey = privateKey; + publicJwk = await jose.exportJWK(publicKey); + publicJwk.kid = 'mock-oauth-key-1'; + publicJwk.alg = 'RS256'; + publicJwk.use = 'sig'; +}); + +function defaultProfile(provider: string): Record { + const email = `mock-user@${provider}.example.com`; + switch (provider) { + case 'github': + return { name: 'Mock User', email, avatar_url: 'https://example.com/avatar.png' }; + case 'facebook': + return { first_name: 'Mock', last_name: 'User', email, picture: { data: { url: 'https://example.com/avatar.png' } } }; + case 'linkedin': + return { localizedFirstName: 'Mock', localizedLastName: 'User' }; + case 'discord': + // Flat shape matching Discord's real GET /users/@me response + // (processDiscordUserInfo, internal/http_handlers/oauth_callback.go, + // reads id/username/avatar/email directly - no "user" wrapper, that + // was /oauth2/@me's shape, which never includes email). + return { id: '123', username: 'mockuser', avatar: 'abc', email }; + case 'twitter': + return { data: { id: '123', name: 'Mock User', username: 'mockuser', profile_image_url: 'https://example.com/a.png' } }; + case 'roblox': + return { name: 'Mock User', nickname: 'mockuser', picture: 'https://example.com/a.png', email }; + default: + return { sub: `mock-${provider}-sub`, email, given_name: 'Mock', family_name: 'User' }; + } +} + +app.post('/:provider/__configure', (req, res) => { + profiles[req.params.provider] = req.body.profile; + res.sendStatus(204); +}); + +app.get('/:provider/.well-known/openid-configuration', (req, res) => { + const base = `${req.protocol}://${req.get('host')}/${req.params.provider}`; + res.json({ + issuer: base, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, + jwks_uri: `${base}/jwks`, + userinfo_endpoint: `${base}/userinfo`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }); +}); + +app.get('/:provider/jwks', async (_req, res) => { + await keyReady; + res.json({ keys: [publicJwk] }); +}); + +app.all('/:provider/authorize', (req, res) => { + const redirectUri = String(req.query.redirect_uri); + const state = String(req.query.state || ''); + const nonce = req.query.nonce ? String(req.query.nonce) : undefined; + const code = crypto.randomUUID(); + issuedTokens.set(code, { provider: req.params.provider, nonce }); + const url = new URL(redirectUri); + url.searchParams.set('code', code); + if (state) url.searchParams.set('state', state); + if (req.params.provider === 'apple') { + // Real Apple sends a `user` field (JSON: {"name":{"firstName","lastName"}}) + // alongside the code on first authorization only - it's constructed by + // Apple's own hosted consent page, not by Authorizer or its frontend, and + // isn't part of the id_token. Authorizer's OAuthCallbackHandler + // (processAppleUserInfo, internal/http_handlers/oauth_callback.go) reads + // it via ctx.Request.FormValue("user"), which Go resolves from either a + // POST body or - as here - the URL query string, so mirroring it as a + // query param on this redirect (rather than an auto-submitted form POST) + // reaches the same code path. + // + // Real Apple omits this field entirely on every login after the first + // (one-time grant, not re-sent). Tests exercising that returning-user + // path set `omit_user_field: true` on the configured profile so this + // mock matches - see __configure below. + const profile = (profiles['apple'] || defaultProfile('apple')) as { + given_name?: string; + family_name?: string; + omit_user_field?: boolean; + }; + if (!profile.omit_user_field) { + url.searchParams.set( + 'user', + JSON.stringify({ name: { firstName: profile.given_name || '', lastName: profile.family_name || '' } }) + ); + } + } + res.redirect(302, url.toString()); +}); + +app.post('/:provider/token', async (req, res) => { + const provider = req.params.provider; + // Recover the nonce captured at /authorize time (RFC-required round-trip + // through the id_token) — keyed by the authorization code presented here. + const nonce = req.body?.code ? issuedTokens.get(String(req.body.code))?.nonce : undefined; + const accessToken = crypto.randomUUID(); + issuedTokens.set(accessToken, { provider }); + + const body: Record = { + access_token: accessToken, + token_type: 'bearer', + expires_in: 3600, + }; + + // Always issue a signed id_token, not just for the 4 named OIDC-verified + // social providers: SSO/home-realm-discovery tests register a per-org OIDC + // connection against a synthetic realm name (e.g. sso-org-), which + // still needs a real id_token for Authorizer's SSO broker to complete the + // flow. Harmless for the REST-profile social providers too — their code + // path in oauth_callback.go never reads token.Extra("id_token"), so an + // extra field in the response is ignored. + { + await keyReady; + const base = `${req.protocol}://${req.get('host')}/${provider}`; + const profile = profiles[provider] || defaultProfile(provider); + const idToken = await new jose.SignJWT({ ...profile, ...(nonce ? { nonce } : {}) }) + .setProtectedHeader({ alg: 'RS256', kid: 'mock-oauth-key-1' }) + .setIssuer(base) + .setAudience('mock-client-id') + .setSubject(String((profile as Record).sub || `mock-${provider}-sub`)) + .setIssuedAt() + .setExpirationTime('10m') + .sign(signingKey); + body.id_token = idToken; + } + + res.json(body); +}); + +app.get(['/:provider/userinfo', '/:provider/user', '/:provider/@me', '/:provider/2/users/me'], (req, res) => { + const provider = req.params.provider; + res.json(profiles[provider] || defaultProfile(provider)); +}); + +app.get('/:provider/user/emails', (req, res) => { + const profile = (profiles[req.params.provider] || defaultProfile(req.params.provider)) as { email?: string }; + res.json([{ email: profile.email || 'mock-user@github.example.com', primary: true }]); +}); + +app.get('/:provider/emailAddress', (req, res) => { + const profile = (profiles[req.params.provider] || defaultProfile(req.params.provider)) as { email?: string }; + res.json({ elements: [{ 'handle~': { emailAddress: profile.email || 'mock-user@linkedin.example.com' } }] }); +}); + +if (require.main === module) { + app.listen(4000, () => console.log('mock-oauth listening on :4000')); +} diff --git a/e2e-playground/mocks/mock-oauth/tsconfig.json b/e2e-playground/mocks/mock-oauth/tsconfig.json new file mode 100644 index 000000000..82d61996e --- /dev/null +++ b/e2e-playground/mocks/mock-oauth/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/e2e-playground/mocks/mock-saml-idp/Dockerfile b/e2e-playground/mocks/mock-saml-idp/Dockerfile new file mode 100644 index 000000000..71345e665 --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY server.ts tsconfig.json ./ +COPY certs ./certs +EXPOSE 4001 +CMD ["npm", "start"] diff --git a/e2e-playground/mocks/mock-saml-idp/certs/idp-cert.pem b/e2e-playground/mocks/mock-saml-idp/certs/idp-cert.pem new file mode 100644 index 000000000..8625b2bd6 --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/certs/idp-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOzCCAiOgAwIBAgIUEzReCg9zKNAJllQcrba+TiM3ibkwDQYJKoZIhvcNAQEL +BQAwLTErMCkGA1UEAwwibW9jay1zYW1sLWlkcC5lMmUtcGxheWdyb3VuZC5sb2Nh +bDAeFw0yNjA3MjAxMjAzMzBaFw0zNjA3MTcxMjAzMzBaMC0xKzApBgNVBAMMIm1v +Y2stc2FtbC1pZHAuZTJlLXBsYXlncm91bmQubG9jYWwwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCfVSYARVlvp42jwuyQN76TYuqPd2s76nIv0Ws2WMLb +Q51RfG7F37akGWFX5RJP6Y9G0Rx28Apr41Oz8AXs22yzkI1f6rzVXesRUK4ictPJ +oVdoXNDPJyLHDoEyWBgzl0ca4I0EQn4ff3RstPQDZcLHd99PJPSQuDb0C2Cvl6oW +Cgx9oSRlQC7acNkcfbtE1fQVmbq2lPCadHuwnyEb3WbUsCxtw1E/lczLPfTv5E1E +5kGK0kSGGiNzi8RdANeZd43QljKDTentfp5EvnCr5fKFC95NX78YxcaTy0MovDFm +kCAwE8BTwgaKHp7iO4gvUIZwgfmdehLCAWf7B2yc0NlHAgMBAAGjUzBRMB0GA1Ud +DgQWBBS1ZxVMxDDcKi8ksIH/5r2jnbf74zAfBgNVHSMEGDAWgBS1ZxVMxDDcKi8k +sIH/5r2jnbf74zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBz +5i7VlC/SC9tM2Km+1R+n2DVP4aA75ShlgeBAgBUr8dVzjjksiN4hHp44p2J6bpf6 +GopbqKc0SJKy5dfwfzi7nzJQX3TvdqGY2D2LqYKqdH5GcrzOXKgCzjkgLCCQsAs0 +T5qst8dZWoxUW1BkP338Z2SqE1v6Y9SmBzXapXttRs8GhMLemiWbf6ni8UCIj94T +v/VD/mVCvCBfYMVVUaFDlIBjK+2O2mBRV31+acFoMcnCHh3u8FXAjmrosmhu/6JW +7c3qCDcXHsO+sA4gOW0Pw7gixUsO2VpH/ERXjZrpxxZvK2cZ2uuCRwPEhO2bIZwr +0GWE5nn4ZOPgeyqUHgfB +-----END CERTIFICATE----- diff --git a/e2e-playground/mocks/mock-saml-idp/certs/idp-key.pem b/e2e-playground/mocks/mock-saml-idp/certs/idp-key.pem new file mode 100644 index 000000000..33ae94518 --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/certs/idp-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCfVSYARVlvp42j +wuyQN76TYuqPd2s76nIv0Ws2WMLbQ51RfG7F37akGWFX5RJP6Y9G0Rx28Apr41Oz +8AXs22yzkI1f6rzVXesRUK4ictPJoVdoXNDPJyLHDoEyWBgzl0ca4I0EQn4ff3Rs +tPQDZcLHd99PJPSQuDb0C2Cvl6oWCgx9oSRlQC7acNkcfbtE1fQVmbq2lPCadHuw +nyEb3WbUsCxtw1E/lczLPfTv5E1E5kGK0kSGGiNzi8RdANeZd43QljKDTentfp5E +vnCr5fKFC95NX78YxcaTy0MovDFmkCAwE8BTwgaKHp7iO4gvUIZwgfmdehLCAWf7 +B2yc0NlHAgMBAAECggEAMHBegS6UJhG2Sdb0vFN2KLClxr/zZd+8nzT3dyo88xP3 +O9KsipOcnv7oTSRjENBcspbYJICNaodN5yJNati6j0ye7k4a4nMPB9CTX/2wzVez +jxLImHG5bPLH7FpD4UtYXp9tv6HHXiQNbQ8GMBI7yRB0X0dI4sZeTos29asSFmi0 +ZW9yugGJ/9RhLc36g85uS4aJLR6wDF9O1BjQ9BsuSD32alQpGleyaZQ9FA5lgyDR +lKc/q8oalKJJtRmnm3xmq2+xg5tPN2sH/tNZYuOJqJFyiEzQ8Z0atBoo3SUdMdLn +BeXFJkvQbLXwStQTap1LGzsz2qiEIq/TMpgTnwLBYQKBgQDcfVO85zZYl1C+IKjn +IKTbJYzKZEXjgYb8FCM2UdBDAVgzioDheFaZ7w28ercZlxaYZ2KJtSLxIeboh/Ui +Sgg1vTRVuc3BHO3rHXm4topGICSzAxZ80wjav3B2N41fcIIBoMbfaYiu6/JQaoPn +4MqWqwC94sMMB8SF2Pzd70y9cwKBgQC4/lnxUNvJZVL5Sq69m3fXfIDTfkDnBolW +3Mi9WA4IzI5qxqaP3kL+AmR0liCxUiS19+TbAIjsVbFrpOWDL2ph5PEj7V0somIk +qWjl7bA2LwA1zcODJ0kfntdRp1qUMpj6hXk+teQlMTxuh0kLyofrPfIb8XI5NTov +wpwHasY/3QKBgGxpsyLPDQnCXREfPe1nP6gBbpiVdUfICHcp76Zl0+EeaB/vmi9C +3FIUGMz0CdOrVpDZRLoxNl0aLk9nikCx5heGUJVWJrUtZE6Wz6LjHlocs+7RNd1q +ZpAoUUPPTNQAnevvAdoYKfzYRu0DcpgxD2vF6Td0qDLiHt8xMiRt5W3BAoGAflH4 +esaa/f+5U88CWSijAbrbgQ9SJC8bcvvZ+yj4lFuR2CmDrPO5TRe3HsEw28RamwMF +++F2neK5/uYfbp/fBa++VakMmaDcYWpo3bCbRbR8cUDrA1C9JuFg6DndqRqPyWmA +7Chp/FeNi2/HmkyW2TR4cUpCk/vbmqdJwerQKuUCgYAXXmhEhthpYRDE9LgOBhXa +Ajv8qQj4DZq9fzwwrru3Y+JRoHcDjkENeW/KTEsXkWdtINldk7I9uXrLpbuwzJwo +X29ZOTCjfFMRH7pThOlQBvYgv0ImGhH2G546dBLQ9AgiwhEWAiBNHmPq9GTXY4tw +xSTYrGCQvuDpmd5S4lPmPQ== +-----END PRIVATE KEY----- diff --git a/e2e-playground/mocks/mock-saml-idp/package-lock.json b/e2e-playground/mocks/mock-saml-idp/package-lock.json new file mode 100644 index 000000000..3c8ae42b4 --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/package-lock.json @@ -0,0 +1,1279 @@ +{ + "name": "mock-saml-idp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mock-saml-idp", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.0", + "samlify": "^2.9.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.7.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.0" + } + }, + "node_modules/@authenio/xml-encryption": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@authenio/xml-encryption/-/xml-encryption-2.0.2.tgz", + "integrity": "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@authenio/xml-encryption/node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "license": "MIT", + "dependencies": { + "asn1": "^0.2.4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/samlify": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/samlify/-/samlify-2.13.1.tgz", + "integrity": "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g==", + "license": "MIT", + "dependencies": { + "@authenio/xml-encryption": "^2.0.2", + "@xmldom/xmldom": "^0.8.11", + "node-rsa": "^1.1.1", + "xml": "^1.0.1", + "xml-crypto": "^6.1.2", + "xml-escape": "^1.1.0", + "xpath": "^0.0.34" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml-escape": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", + "integrity": "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==", + "license": "MIT License" + }, + "node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/e2e-playground/mocks/mock-saml-idp/package.json b/e2e-playground/mocks/mock-saml-idp/package.json new file mode 100644 index 000000000..519703616 --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/package.json @@ -0,0 +1,16 @@ +{ + "name": "mock-saml-idp", + "private": true, + "version": "1.0.0", + "scripts": { "start": "ts-node server.ts", "test": "node --test -r ts-node/register server.test.ts" }, + "dependencies": { + "express": "^4.21.0", + "samlify": "^2.9.1" + }, + "devDependencies": { + "ts-node": "^10.9.2", + "typescript": "^5.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.7.0" + } +} diff --git a/e2e-playground/mocks/mock-saml-idp/server.test.ts b/e2e-playground/mocks/mock-saml-idp/server.test.ts new file mode 100644 index 000000000..3bd757a8c --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/server.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; +import { app } from './server'; +import type { Server } from 'node:http'; + +let server: Server; + +test.before(() => { + server = app.listen(0); +}); +test.after(() => server.close()); + +test('/sso responds 400 (not a crash) on a malformed SAMLRequest', async () => { + const addr = server.address(); + const base = `http://127.0.0.1:${typeof addr === 'object' && addr ? addr.port : 0}`; + + const res = await fetch(`${base}/sso?SAMLRequest=not-valid-base64-deflated-xml&RelayState=test`); + assert.strictEqual(res.status, 400); + + // Process (and this same server instance) must still be alive and serving + // requests afterwards — a crash would make this fetch fail outright. + const metadataRes = await fetch(`${base}/metadata`); + assert.strictEqual(metadataRes.status, 200); +}); diff --git a/e2e-playground/mocks/mock-saml-idp/server.ts b/e2e-playground/mocks/mock-saml-idp/server.ts new file mode 100644 index 000000000..4d005dc20 --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/server.ts @@ -0,0 +1,257 @@ +import express from 'express'; +import * as samlify from 'samlify'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; + +export const app = express(); +app.use(express.urlencoded({ extended: true })); + +// ponytail: mock IdP only ever builds its own responses (never validates +// inbound XML against an XSD), so a real schema validator dependency isn't +// worth adding — skip validation. samlify throws on any parse call without +// one configured, so this is required, not optional. +samlify.setSchemaValidator({ validate: () => Promise.resolve('skipped') }); + +const CERT_DIR = path.join(__dirname, 'certs'); +const idpCert = fs.readFileSync(path.join(CERT_DIR, 'idp-cert.pem'), 'utf8'); +const idpKey = fs.readFileSync(path.join(CERT_DIR, 'idp-key.pem'), 'utf8'); + +const IDP_ENTITY_ID = 'http://mock-saml-idp:4001/metadata'; +const SSO_URL = 'http://mock-saml-idp:4001/sso'; + +// SAML attribute names must match Authorizer's default SP attribute mapping +// (internal/http_handlers/saml_sp.go: samlDefaultAttributeMapping) so the +// JIT-provisioned profile picks up email/given_name/family_name. +const idp = samlify.IdentityProvider({ + entityID: IDP_ENTITY_ID, + privateKey: idpKey, + isAssertionEncrypted: false, + signingCert: idpCert, + wantAuthnRequestsSigned: false, + singleSignOnService: [{ Binding: samlify.Constants.namespace.binding.redirect, Location: SSO_URL }], + loginResponseTemplate: { + context: samlify.SamlLib.defaultLoginResponseTemplate.context, + attributes: [ + { name: 'email', valueTag: 'email', nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', valueXsiType: 'xs:string' }, + { name: 'firstName', valueTag: 'firstName', nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', valueXsiType: 'xs:string' }, + { name: 'lastName', valueTag: 'lastName', nameFormat: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', valueXsiType: 'xs:string' }, + ], + }, +}); + +// Test-configurable subject for the next issued assertion. +let nextUser = { email: 'mock-saml-user@example.com', givenName: 'Mock', familyName: 'User' }; + +app.post('/__configure', express.json(), (req, res) => { + nextUser = req.body; + res.sendStatus(204); +}); + +app.get('/metadata', (_req, res) => { + res.type('application/samlmetadata+xml').send(idp.getMetadata()); +}); + +// SP-initiated: Authorizer's crewjam/saml SP redirects here with a +// base64+deflated AuthnRequest (HTTP-Redirect binding) plus RelayState. +// The real ACS URL and SP entity ID are parsed out of the AuthnRequest +// itself (samlify's parseLoginRequest) rather than trusted from a query +// param — Authorizer never sends one, and the assertion's Audience must +// match the SP's real entityID or the SP-side validation rejects it. +app.get('/sso', async (req, res) => { + // ponytail: Express 4's async-handler rejections don't reach the default + // error handler (that's Express 5 only) and become unhandled promise + // rejections, which crash this long-lived shared process. Guard the whole + // body so a malformed SAMLRequest 400s instead of taking down every other + // in-flight SAML spec. + try { + const relayState = String(req.query.RelayState || ''); + const placeholderSp = samlify.ServiceProvider({ entityID: 'unused-during-parse' }); + // samlify's own `RequestInfo` and `FlowResult` types aren't exported from + // the package, so the boundary between parseLoginRequest's return value + // and createLoginResponse's expected input is untyped here. + const requestInfo: any = await idp.parseLoginRequest(placeholderSp, 'redirect', { query: req.query }); + const acsUrl = String((requestInfo.extract.request as { assertionConsumerServiceUrl?: string }).assertionConsumerServiceUrl); + const spEntityId = String(requestInfo.extract.issuer); + + const sp = samlify.ServiceProvider({ + entityID: spEntityId, + assertionConsumerService: [{ Binding: samlify.Constants.namespace.binding.post, Location: acsUrl }], + }); + + // samlify's default (non-custom) response builder always renders an empty + // AttributeStatement — the `loginResponseTemplate.attributes` config above + // only takes effect when a customTagReplacement callback fills the baked + // `{attrX}` placeholders itself. We replicate the same tag values samlify + // computes internally (see samlify/build/src/binding-post.js) plus ours. + const customTagReplacement = (template: string) => { + const now = new Date(); + const notOnOrAfter = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); + const tvalue: Record = { + ID: `_${crypto.randomUUID()}`, + AssertionID: `_${crypto.randomUUID()}`, + Destination: acsUrl, + Audience: spEntityId, + EntityID: spEntityId, + SubjectRecipient: acsUrl, + Issuer: IDP_ENTITY_ID, + IssueInstant: now.toISOString(), + AssertionConsumerServiceURL: acsUrl, + StatusCode: samlify.Constants.StatusCode.Success, + ConditionsNotBefore: now.toISOString(), + ConditionsNotOnOrAfter: notOnOrAfter, + SubjectConfirmationDataNotOnOrAfter: notOnOrAfter, + NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress', + NameID: nextUser.email, + InResponseTo: (requestInfo.extract.request as { id?: string }).id ?? '', + AuthnStatement: '', + attrEmail: nextUser.email, + attrFirstName: nextUser.givenName, + attrLastName: nextUser.familyName, + }; + return { id: tvalue.ID as string, context: samlify.SamlLib.replaceTagsByValue(template, tvalue) }; + }; + + const { context } = await idp.createLoginResponse(sp, requestInfo, 'post', nextUser, customTagReplacement); + + res.send(` + +
+ + +
+ + `); + } catch (err) { + console.error('mock-saml-idp /sso failed:', err); + res.sendStatus(400); + } +}); + +// --- Fake SP role (Task 10: SAML IdP-side conformance) --- +// samlify supports both IdP and SP roles from the same package — reuse it +// here as a stand-in external SP so these tests drive a REAL SP-initiated +// flow against Authorizer's actual IdP-side routes +// (internal/http_handlers/saml_idp.go: SAMLIDPSSOHandler expects a genuine +// AuthnRequest built via crewjam's saml.NewIdpAuthnRequest, not a hand-rolled +// query param), mirroring how this file already stands in for a real IdP on +// the SP-side spec (Task 9). +// +// Per-flow state is keyed by the caller-supplied `relay_state` (never a +// single shared slot) so concurrent test runs against this same long-lived +// container don't clobber each other. +const pendingFakeSPFlows = new Map< + string, + { authorizerBase: string; org: string; entityId: string; acsUrl: string } +>(); +const completedFakeSPFlows = new Map< + string, + { nameID: string; issuer: string; audience: string; attributes: Record } +>(); + +// buildAuthorizerIdp fetches Authorizer's REAL, per-org IdP metadata +// (entity id + signing cert are unique per org) and hydrates a samlify IdP +// entity from it, rather than hardcoding anything — the assertion's Issuer +// and signature must match this exactly (samlify's postFlow rejects on +// ERR_UNMATCH_ISSUER / FAILED_TO_VERIFY_SIGNATURE otherwise). +async function buildAuthorizerIdp(authorizerBase: string, org: string) { + const res = await fetch(`${authorizerBase}/saml/idp/${encodeURIComponent(org)}/metadata`); + if (!res.ok) throw new Error(`failed to fetch Authorizer IdP metadata: ${res.status}`); + const metadata = await res.text(); + return samlify.IdentityProvider({ metadata }); +} + +function fakeServiceProvider(entityId: string, acsUrl: string) { + return samlify.ServiceProvider({ + entityID: entityId, + assertionConsumerService: [{ Binding: samlify.Constants.namespace.binding.post, Location: acsUrl }], + }); +} + +// GET /fake-sp/start: builds a real AuthnRequest (HTTP-Redirect binding, +// unsigned) and redirects the browser to Authorizer's SP-initiated SSO +// endpoint. Unsigned is deliberate, not a shortcut: crewjam/saml has no +// support for signed AuthnRequests at all ("TODO(ross): support signed authn +// requests" in identity_provider.go Validate()), and Authorizer's IdP +// metadata never sets WantAuthnRequestsSigned, so samlify's SP/IdP +// signed-flag agreement check (both default false) passes cleanly. +app.get('/fake-sp/start', async (req, res) => { + try { + const authorizerBase = String(req.query.authorizer_base || ''); + const org = String(req.query.org || ''); + const entityId = String(req.query.entity_id || ''); + const acsUrl = String(req.query.acs_url || ''); + const relayState = String(req.query.relay_state || ''); + if (!authorizerBase || !org || !entityId || !acsUrl || !relayState) { + res.status(400).send('missing required query param(s): authorizer_base, org, entity_id, acs_url, relay_state'); + return; + } + pendingFakeSPFlows.set(relayState, { authorizerBase, org, entityId, acsUrl }); + + const idp = await buildAuthorizerIdp(authorizerBase, org); + const sp = fakeServiceProvider(entityId, acsUrl); + // samlify's own binding-context types aren't discriminated on the + // `binding` string argument, so `.context` (the redirect URL) needs a + // narrowing cast here — same untyped-boundary situation as /sso above. + const { context } = sp.createLoginRequest(idp, 'redirect', { relayState }) as { context: string }; + res.redirect(context); + } catch (err) { + console.error('fake-sp /start failed:', err); + res.sendStatus(400); + } +}); + +// POST /fake-sp/acs: the fake SP's Assertion Consumer Service. Validates the +// signed assertion Authorizer posts here — samlify verifies the XML-DSIG +// signature against the cert published in Authorizer's own IdP metadata — +// and stores the parsed result for the test to read back via GET +// /fake-sp/last. A response that fails validation (bad signature, wrong +// audience/issuer, expired) throws and 400s here rather than being recorded. +app.post('/fake-sp/acs', async (req, res) => { + try { + const relayState = String(req.body.RelayState || ''); + const pending = pendingFakeSPFlows.get(relayState); + if (!pending) { + res.status(400).send('unknown or expired relay state'); + return; + } + const idp = await buildAuthorizerIdp(pending.authorizerBase, pending.org); + const sp = fakeServiceProvider(pending.entityId, pending.acsUrl); + const result = await sp.parseLoginResponse(idp, 'post', { body: req.body }); + pendingFakeSPFlows.delete(relayState); + completedFakeSPFlows.set(relayState, { + nameID: String(result.extract.nameID || ''), + issuer: String(result.extract.issuer || ''), + audience: String(result.extract.audience || ''), + attributes: (result.extract.attributes as Record) || {}, + }); + res.status(200).send('ok'); + } catch (err) { + console.error('fake-sp /acs failed:', err); + res.sendStatus(400); + } +}); + +// GET /fake-sp/last?relay_state=...: test-facing readback of a completed flow. +app.get('/fake-sp/last', (req, res) => { + const relayState = String(req.query.relay_state || ''); + const result = completedFakeSPFlows.get(relayState); + if (!result) { + res.sendStatus(404); + return; + } + res.json(result); +}); + +if (require.main === module) { + // Authorizer's org-SAML-connection admin API rejects a non-https + // idp_sso_url outright (internal/service/admin_org_saml.go + // validateSAMLHTTPSURL) with no dev/test bypass — unlike the OIDC broker, + // which relaxes this under --env=e2e. Real IdPs are https-only in + // practice too, so serve TLS here (reusing this same signing cert/key as + // the server cert) rather than weakening that production check. + // Callers must trust/ignore this self-signed cert (see saml-sp.spec.ts's + // `ignoreHTTPSErrors`). + https.createServer({ cert: idpCert, key: idpKey }, app).listen(4001, () => console.log('mock-saml-idp listening on :4001 (https)')); +} diff --git a/e2e-playground/mocks/mock-saml-idp/tsconfig.json b/e2e-playground/mocks/mock-saml-idp/tsconfig.json new file mode 100644 index 000000000..82d61996e --- /dev/null +++ b/e2e-playground/mocks/mock-saml-idp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/e2e-playground/mocks/sms-sink/Dockerfile b/e2e-playground/mocks/sms-sink/Dockerfile new file mode 100644 index 000000000..a4de2354c --- /dev/null +++ b/e2e-playground/mocks/sms-sink/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY server.ts tsconfig.json ./ +EXPOSE 4100 +CMD ["npm", "start"] diff --git a/e2e-playground/mocks/sms-sink/package-lock.json b/e2e-playground/mocks/sms-sink/package-lock.json new file mode 100644 index 000000000..0e2148817 --- /dev/null +++ b/e2e-playground/mocks/sms-sink/package-lock.json @@ -0,0 +1,1160 @@ +{ + "name": "sms-sink", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sms-sink", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.7.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/e2e-playground/mocks/sms-sink/package.json b/e2e-playground/mocks/sms-sink/package.json new file mode 100644 index 000000000..b480e243a --- /dev/null +++ b/e2e-playground/mocks/sms-sink/package.json @@ -0,0 +1,8 @@ +{ + "name": "sms-sink", + "private": true, + "version": "1.0.0", + "scripts": { "start": "ts-node server.ts" }, + "dependencies": { "express": "^4.21.0" }, + "devDependencies": { "ts-node": "^10.9.2", "typescript": "^5.6.0", "@types/express": "^4.17.21", "@types/node": "^22.7.0" } +} diff --git a/e2e-playground/mocks/sms-sink/server.ts b/e2e-playground/mocks/sms-sink/server.ts new file mode 100644 index 000000000..63b0d2f69 --- /dev/null +++ b/e2e-playground/mocks/sms-sink/server.ts @@ -0,0 +1,23 @@ +import express from 'express'; + +const app = express(); +app.use(express.json()); + +const latestByPhone = new Map(); + +app.post('/sms', (req, res) => { + const { phone, message } = req.body; + latestByPhone.set(phone, { phone, message }); + res.sendStatus(204); +}); + +app.get('/sms/:phone', (req, res) => { + const entry = latestByPhone.get(req.params.phone); + if (!entry) { + res.sendStatus(404); + return; + } + res.json(entry); +}); + +app.listen(4100, () => console.log('sms-sink listening on :4100')); diff --git a/e2e-playground/mocks/sms-sink/tsconfig.json b/e2e-playground/mocks/sms-sink/tsconfig.json new file mode 100644 index 000000000..82d61996e --- /dev/null +++ b/e2e-playground/mocks/sms-sink/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/e2e-playground/mocks/webhook-sink/Dockerfile b/e2e-playground/mocks/webhook-sink/Dockerfile new file mode 100644 index 000000000..a92f3f213 --- /dev/null +++ b/e2e-playground/mocks/webhook-sink/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY server.ts tsconfig.json ./ +EXPOSE 4200 +CMD ["npm", "start"] diff --git a/e2e-playground/mocks/webhook-sink/package.json b/e2e-playground/mocks/webhook-sink/package.json new file mode 100644 index 000000000..0bcfb57db --- /dev/null +++ b/e2e-playground/mocks/webhook-sink/package.json @@ -0,0 +1,8 @@ +{ + "name": "webhook-sink", + "private": true, + "version": "1.0.0", + "scripts": { "start": "ts-node server.ts" }, + "dependencies": { "express": "^4.21.0" }, + "devDependencies": { "ts-node": "^10.9.2", "typescript": "^5.6.0", "@types/express": "^4.17.21", "@types/node": "^22.7.0" } +} diff --git a/e2e-playground/mocks/webhook-sink/server.ts b/e2e-playground/mocks/webhook-sink/server.ts new file mode 100644 index 000000000..82ea464be --- /dev/null +++ b/e2e-playground/mocks/webhook-sink/server.ts @@ -0,0 +1,55 @@ +import express from 'express'; + +const app = express(); +// Capture the raw request bytes (not a re-serialized copy) so a test can verify +// the X-Authorizer-Signature HMAC over the EXACT payload authorizer signed — +// re-marshalling JSON would reorder/reformat keys and break the signature. +app.use(express.raw({ type: '*/*', limit: '1mb' })); + +interface Delivery { + eventName: string; + rawBody: string; + signature: string; + body: any; +} + +// Keyed by user email (unique per test), then by event name, so a single global +// webhook receiving many SCIM events across parallel tests can be queried for one +// specific user's provisioned / scim_updated / deprovisioned deliveries. +const byEmail = new Map>(); + +app.post('/webhook', (req, res) => { + const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : ''; + let body: any; + try { + body = JSON.parse(rawBody); + } catch { + res.sendStatus(400); + return; + } + const eventName: string = body.event_name; + const email: string | undefined = body.user?.email; + if (email && eventName) { + if (!byEmail.has(email)) byEmail.set(email, new Map()); + byEmail.get(email)!.set(eventName, { + eventName, + rawBody, + signature: (req.header('X-Authorizer-Signature') as string) || '', + body, + }); + } + res.sendStatus(200); +}); + +// Returns every event delivered for one user's email: +// { email, events: { "user.provisioned": { signature, rawBody, body }, ... } } +app.get('/webhook/:email', (req, res) => { + const events = byEmail.get(req.params.email); + if (!events) { + res.sendStatus(404); + return; + } + res.json({ email: req.params.email, events: Object.fromEntries(events) }); +}); + +app.listen(4200, () => console.log('webhook-sink listening on :4200')); diff --git a/e2e-playground/mocks/webhook-sink/tsconfig.json b/e2e-playground/mocks/webhook-sink/tsconfig.json new file mode 100644 index 000000000..82d61996e --- /dev/null +++ b/e2e-playground/mocks/webhook-sink/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/e2e-playground/package-lock.json b/e2e-playground/package-lock.json new file mode 100644 index 000000000..6a12f837b --- /dev/null +++ b/e2e-playground/package-lock.json @@ -0,0 +1,152 @@ +{ + "name": "e2e-playground", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e-playground", + "version": "1.0.0", + "dependencies": { + "graphql-request": "^7.1.2", + "otpauth": "^9.3.6" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz", + "integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "node_modules/otpauth": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", + "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/e2e-playground/package.json b/e2e-playground/package.json new file mode 100644 index 000000000..48ae7918c --- /dev/null +++ b/e2e-playground/package.json @@ -0,0 +1,16 @@ +{ + "name": "e2e-playground", + "private": true, + "version": "1.0.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "typescript": "^5.6.0" + }, + "dependencies": { + "graphql-request": "^7.1.2", + "otpauth": "^9.3.6" + } +} diff --git a/e2e-playground/playwright.config.ts b/e2e-playground/playwright.config.ts new file mode 100644 index 000000000..3faea738e --- /dev/null +++ b/e2e-playground/playwright.config.ts @@ -0,0 +1,86 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testIgnore: ['**/node_modules/**', '**/mocks/**'], + timeout: 30_000, + retries: 0, + reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }]], + use: { + baseURL: process.env.AUTHORIZER_BASE_URL || 'http://localhost:8080', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'mfa-off', + testIgnore: [ + /mfa-routing-matrix\.spec\.ts/, + /oidc-sso-rp\.spec\.ts/, + /sso-discovery\.spec\.ts/, + /webauthn\.spec\.ts/, + /magic-link\.spec\.ts/, + '**/mocks/**', + '**/node_modules/**', + ], + use: { ...devices['Desktop Chrome'] }, + }, + { + // Runs against authorizer-mfa-enforced (docker-compose.yml), the only + // service with --enforce-mfa=true - see that service's comment in + // docker-compose.yml for why EnforceMFA can't be toggled at runtime + // on the shared `authorizer` service (the _update_env mutation + // fixtures/adminClient.ts's setEnforceMFA calls is a stub that always + // errors - "deprecated. please configure env via cli args"). The one + // test in this spec needing magic-link login too talks to a second + // dedicated instance, authorizer-mfa-magic-link, via an explicit + // absolute URL rather than this project's baseURL. + name: 'mfa-on', + testMatch: /mfa-routing-matrix\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.AUTHORIZER_MFA_ENFORCED_BASE_URL || 'http://localhost:8084', + }, + }, + { + // Runs against authorizer-sso (docker-compose.yml), the only service + // with --enable-org-discovery=true. That flag is a global login-UX + // toggle, so it can't be turned on for the `mfa-off` project's service + // without breaking tests/oidc-provider.spec.ts's plain PKCE flow — see + // that service's comment in docker-compose.yml. + name: 'sso-discovery', + testMatch: [/oidc-sso-rp\.spec\.ts/, /sso-discovery\.spec\.ts/], + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.AUTHORIZER_SSO_BASE_URL || 'http://localhost:8081', + }, + }, + { + // Runs against authorizer-webauthn (docker-compose.yml), the only + // service configured with a dotted --url hostname - required for + // go-webauthn's RPID validation to accept it at all (see that + // service's comment in docker-compose.yml). Can't share the `authorizer` + // service's single-label hostname the way most other specs do. + name: 'webauthn', + testMatch: /webauthn\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.AUTHORIZER_WEBAUTHN_BASE_URL || 'http://localhost:8082', + }, + }, + { + // Runs against authorizer-magic-link (docker-compose.yml), the only + // service with --enable-magic-link-login=true AND + // --enable-email-verification=true - see that service's comment in + // docker-compose.yml for why those can't live on the shared + // `authorizer` service. + name: 'magic-link', + testMatch: /magic-link\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.AUTHORIZER_MAGIC_LINK_BASE_URL || 'http://localhost:8083', + }, + }, + ], +}); diff --git a/e2e-playground/sdk-tests/go/Dockerfile b/e2e-playground/sdk-tests/go/Dockerfile new file mode 100644 index 000000000..14754deaa --- /dev/null +++ b/e2e-playground/sdk-tests/go/Dockerfile @@ -0,0 +1,22 @@ +# e2e-playground/sdk-tests/go/Dockerfile +# +# Runs the SDK-driven Go suite INSIDE the compose network (same rationale as the +# playwright service): so the docker-internal hostnames the tests target — +# especially the WebAuthn instance's pinned origin webauthn.e2e-playground.test — +# resolve and match the servers' --url/--allowed-origins exactly. +# +# go 1.25.5 is required by the authorizer-go SDK's own go.mod; golang:1.25 tracks +# the latest 1.25.x patch, which satisfies it. +FROM golang:1.25 + +WORKDIR /sdk-tests + +# Cache module downloads separately from source for faster rebuilds. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# The suite talks to the live stack, so it's the container's runtime command +# (not a build step). -count=1 bypasses the test cache; -v surfaces each case. +CMD ["go", "test", "-count=1", "-v", "./..."] diff --git a/e2e-playground/sdk-tests/go/README.md b/e2e-playground/sdk-tests/go/README.md new file mode 100644 index 000000000..a39a8ab3a --- /dev/null +++ b/e2e-playground/sdk-tests/go/README.md @@ -0,0 +1,70 @@ +# SDK-driven e2e-playground suite (Go) + +A second test surface alongside the Playwright suite in `../../tests`. Where the +Playwright specs drive the same live stack via **raw GraphQL/REST + a browser**, +this suite drives it through the **actually-published `authorizer-go` SDK** +(`github.com/authorizerdev/authorizer-go/v2@v2.2.0-rc.4`, a real `go get` +dependency — no `replace` onto local source). The point is to catch wire-shape +drift between what the SDK sends/parses and what the server does, in feature +areas the SDK's own integration suite never touches. + +## How to run + +Inside the compose network (canonical — WebAuthn's pinned origin and the +docker-internal hostnames only resolve here): + +```bash +make e2e-playground-sdk # from repo root: up --build → run → down -v (always) +``` + +Equivalent manual invocation: + +```bash +docker compose -f e2e-playground/docker-compose.yml up -d --wait --build \ + authorizer authorizer-webauthn authorizer-mfa-enforced authorizer-mfa-magic-link \ + mock-oauth mock-saml-idp mailpit sms-sink webhook-sink +docker compose -f e2e-playground/docker-compose.yml run --rm --build go-sdk-tests +docker compose -f e2e-playground/docker-compose.yml down -v +``` + +The suite also runs from the host (`go test ./...`) against the published ports +for everything **except** WebAuthn, which skips off-network (see below). + +## What is genuinely SDK-driven, and what is not + +| Area | Verdict | How | +|---|---|---| +| **MFA enforcement routing** | ✅ fully SDK | `Login` / `SkipMfaSetup` typed methods assert the withheld-token response shape (`mfa_routing_test.go`). | +| **SAML IdP admin** | ✅ fully SDK | SP CRUD + IdP signing-key rotation/listing via the admin client's typed proto methods (`saml_idp_admin_test.go`). | +| **SCIM admin + provisioning webhooks** | ✅ SDK admin + raw SCIM | Org / SCIM-endpoint / token-rotation / webhook registration through the SDK admin client; the RFC 7644 `/scim/v2/Users` CRUD itself stays raw HTTP **by design** (it is a standardized protocol an IdP hits directly, not an SDK concern) (`scim_test.go`). | +| **WebAuthn registration** | ✅ full ceremony via SDK | `WebauthnRegistrationOptions`/`Verify` (typed, cookie threaded) completed by a pure-Go software authenticator (`descope/virtualwebauthn`) — no browser (`webauthn_test.go`). | +| **WebAuthn login** | ⚠️ options only | `WebauthnLoginOptions` shape asserted via SDK; completion blocked by the cookie gap below. | +| **TOTP** | ⚠️ SDK setup + raw verify | `TotpMfaSetup` via SDK (cookie threaded); `verify_otp` + lockout via raw jar client (cookie gap) (`totp_test.go`, `otp_lockout_test.go`). | +| **SMS-OTP / WebOTP** | ⚠️ SDK setup + raw verify | `SmsOtpMfaSetup` via SDK (triggers the real code to `sms-sink`); `verify_otp` + lockout raw (`sms_otp_test.go`, `otp_lockout_test.go`). | +| **OTP brute-force lockout** | ⚠️ raw verify | 5 wrong `verify_otp` → distinct lockout error; enrollment via SDK (`otp_lockout_test.go`). | +| **Social OAuth (10 providers)** | ❌ not SDK-testable | The SDK has **no** login-initiation surface — social login is a `/oauth_login/:provider` browser redirect chain. There is nothing for the SDK to drive; it belongs to the Playwright suite (`tests/social/*`). | +| **SAML SP login (assertion flow)** | ❌ not SDK-testable | AuthnRequest redirect + IdP form-POST + signed assertion to the SP ACS is inherently a browser/form flow with no SDK surface. The SP *config* is admin-API and lives in the SAML IdP admin test; the *login* stays in Playwright (`tests/saml-sp.spec.ts`). | + +## Confirmed SDK gaps (v2.2.0-rc.4) — follow-up recommendations + +These are real, not test-harness limitations. The MFA-session flow is +cookie-based server-side (`mfa_session` cookie, required unconditionally by +`verify_otp` and by the OTP/WebAuthn setup calls), but the SDK cannot carry it +end to end: + +1. **`Login` (and `SignUp`) discard `Set-Cookie`.** The typed methods return only + the parsed body, so the `mfa_session` cookie a login arms is unreachable + through the SDK. A caller cannot obtain it to continue the withheld-token MFA + challenge. *Suggested fix:* expose response cookies (or an opt-in cookie jar) + on the client. +2. **`VerifyOTP` and `WebauthnLoginVerify` accept no per-call headers.** Even with + the cookie in hand, there is no way to send it on the one call that most needs + it. (By contrast `TotpMfaSetup`, `SmsOtpMfaSetup`, and the WebAuthn + registration methods *do* take a `headers map[string]string`, which is why the + setup half of each flow is genuinely SDK-driven here.) *Suggested fix:* add a + `headers` parameter to `VerifyOTP` / `WebauthnLoginVerify`, matching the setup + methods. + +Where a gap forced raw HTTP, it is a single labelled call (a cookie-jar +`verify_otp` / login), never a silent workaround — see the per-file headers. +Every step that *can* go through the SDK does. diff --git a/e2e-playground/sdk-tests/go/go.mod b/e2e-playground/sdk-tests/go/go.mod new file mode 100644 index 000000000..f5988a388 --- /dev/null +++ b/e2e-playground/sdk-tests/go/go.mod @@ -0,0 +1,32 @@ +// Standalone Go module for the SDK-driven e2e-playground suite. +// +// It is a SEPARATE module (own go.mod) from the parent +// github.com/authorizerdev/authorizer repo on purpose: that keeps it out of the +// parent's `go build ./...` / CI, and — the whole point of this suite — it +// depends on the ACTUALLY-PUBLISHED authorizer-go SDK release via the module +// proxy (a real `require`, never a `replace` onto local source), so these tests +// exercise the same bytes a downstream integrator would `go get`. +module github.com/authorizerdev/authorizer/e2e-playground/sdk-tests/go + +go 1.25.5 + +require ( + github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4 + github.com/authorizerdev/authorizer-proto-go v0.1.0 + github.com/descope/virtualwebauthn v1.0.5 + github.com/pquerna/otp v1.5.0 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/e2e-playground/sdk-tests/go/go.sum b/e2e-playground/sdk-tests/go/go.sum new file mode 100644 index 000000000..bfb8675b8 --- /dev/null +++ b/e2e-playground/sdk-tests/go/go.sum @@ -0,0 +1,67 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4 h1:w8qQmAdP9OFiejsPuSsQqCRdWT29f7gHHFf1UTc8KGU= +github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4/go.mod h1:1gnCE9aCctLn9TZRdyjkbyJIQnFJtK2nXkXoGvj40uQ= +github.com/authorizerdev/authorizer-proto-go v0.1.0 h1:oLGE2OuwCnE6Yr1tRt3fL0zh7L/HpPQfoeS4pgxszlQ= +github.com/authorizerdev/authorizer-proto-go v0.1.0/go.mod h1:cVUPv4XVeH3YeoFjfnl+ug/KlUinrGOAUZu3E+sjjHs= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ= +github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/e2e-playground/sdk-tests/go/helpers_test.go b/e2e-playground/sdk-tests/go/helpers_test.go new file mode 100644 index 000000000..706c67fdb --- /dev/null +++ b/e2e-playground/sdk-tests/go/helpers_test.go @@ -0,0 +1,317 @@ +package sdktests + +// Shared test harness for the SDK-driven e2e-playground suite. +// +// Two kinds of client appear throughout: +// +// - *authorizer.AuthorizerClient / *authorizer.AuthorizerAdminClient — the +// real published SDK. Every call that CAN go through a typed SDK method +// does, so the suite catches wire-shape drift between what the SDK +// sends/parses and what the server actually does. +// +// - a raw *http.Client carrying a cookie jar (jarClient/rawGraphQL) — used +// ONLY for the two things the SDK genuinely cannot do in v2.2.0-rc.4: +// (1) capture the `mfa_session` Set-Cookie a login/OTP-setup response +// arms (the SDK's Login discards response headers), and (2) send that +// cookie on `verify_otp` (the SDK's VerifyOTP takes no per-call headers). +// Both are labelled at every call site as SDK gaps, not preferences. See +// README.md "Confirmed SDK gaps". + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/authorizerdev/authorizer-go/v2" + "github.com/pquerna/otp/totp" +) + +// testPassword is a fixed strong password reused across signups. +const testPassword = "Str0ngPassw0rd!" + +// Target instances. Defaults are the host-published ports from +// e2e-playground/docker-compose.yml; the go-sdk-tests compose service overrides +// them with the docker-internal hostnames (so origins / the WebAuthn RPID line +// up exactly, same as the playwright service). +var ( + baseURL = envOr("AUTHORIZER_BASE_URL", "http://localhost:8080") + mfaEnforcedURL = envOr("AUTHORIZER_MFA_ENFORCED_BASE_URL", "http://localhost:8084") + mfaMagicLinkURL = envOr("AUTHORIZER_MFA_MAGIC_LINK_BASE_URL", "http://localhost:8085") + webauthnURL = envOr("AUTHORIZER_WEBAUTHN_BASE_URL", "http://localhost:8082") + adminSecret = envOr("AUTHORIZER_ADMIN_SECRET", "e2e-admin-secret") + clientID = envOr("AUTHORIZER_CLIENT_ID", "e2e-client-id") + clientSecret = envOr("AUTHORIZER_CLIENT_SECRET", "e2e-client-secret") + smsSinkURL = envOr("SMS_SINK_BASE_URL", "http://localhost:4100") + webhookSinkURL = envOr("WEBHOOK_SINK_BASE_URL", "http://localhost:4200") + mailpitURL = envOr("MAILPIT_BASE_URL", "http://localhost:8025") +) + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// userClient builds an SDK user client for the given instance. Origin is set to +// the instance's own URL: the server's CSRF guard rejects state-changing +// requests with no Origin/Referer, and the SDK already auto-injects the same +// value, so this only makes the intent explicit. +func userClient(t *testing.T, instanceURL string) *authorizer.AuthorizerClient { + t.Helper() + c, err := authorizer.NewAuthorizerClient(clientID, instanceURL, "", map[string]string{"Origin": instanceURL}) + if err != nil { + t.Fatalf("NewAuthorizerClient: %v", err) + } + return c +} + +// adminClient builds an SDK admin client (graphql protocol) for the given +// instance. +func adminClient(t *testing.T, instanceURL string) *authorizer.AuthorizerAdminClient { + t.Helper() + c, err := authorizer.NewAuthorizerAdminClient(instanceURL, adminSecret, + authorizer.WithAdminExtraHeaders(map[string]string{"Origin": instanceURL})) + if err != nil { + t.Fatalf("NewAuthorizerAdminClient: %v", err) + } + return c +} + +func randomEmail(prefix string) string { + return fmt.Sprintf("%s-%d-%d@example.com", prefix, time.Now().UnixNano(), rand.Intn(1_000_000)) +} + +func randomPhone() string { + return fmt.Sprintf("+1555%07d", rand.Intn(9_000_000)+1_000_000) +} + +// randomSlug returns a unique URL-safe organization name. +func randomSlug(prefix string) string { + return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), rand.Intn(1_000_000)) +} + +// --- raw cookie-jar GraphQL (the labelled SDK-gap escape hatch) ------------- + +// jarClient returns an http.Client with an isolated cookie jar, so a login's +// mfa_session cookie is retained and replayed on the follow-up verify_otp call. +func jarClient(t *testing.T) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("cookiejar.New: %v", err) + } + return &http.Client{Jar: jar, Timeout: 30 * time.Second} +} + +type gqlError struct { + Message string `json:"message"` +} + +type gqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []gqlError `json:"errors"` +} + +func (r gqlResponse) errorText() string { + msgs := make([]string, 0, len(r.Errors)) + for _, e := range r.Errors { + msgs = append(msgs, e.Message) + } + b, _ := json.Marshal(msgs) + return string(b) +} + +// rawGraphQL POSTs a GraphQL operation through the given cookie-jar client. Used +// only for verify_otp (SDK VerifyOTP takes no cookie header) and for login when +// the mfa_session Set-Cookie must be captured (SDK Login discards it). +func rawGraphQL(t *testing.T, hc *http.Client, instanceURL, query string, variables map[string]any) gqlResponse { + t.Helper() + body, err := json.Marshal(map[string]any{"query": query, "variables": variables}) + if err != nil { + t.Fatalf("marshal gql: %v", err) + } + req, err := http.NewRequest(http.MethodPost, instanceURL+"/graphql", bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", instanceURL) + res, err := hc.Do(req) + if err != nil { + t.Fatalf("gql POST: %v", err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + var parsed gqlResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("decode gql response (status %d): %v\nbody: %s", res.StatusCode, err, raw) + } + return parsed +} + +// mfaSessionCookieHeader serialises whatever cookies the jar holds for the +// instance into a single Cookie header value, so it can be threaded into an SDK +// method that DOES accept per-call headers (TotpMfaSetup, SmsOtpMfaSetup, +// WebauthnRegistration*). This is the genuine-SDK half of the hybrid flow. +func mfaSessionCookieHeader(t *testing.T, hc *http.Client, instanceURL string) string { + t.Helper() + u, err := url.Parse(instanceURL) + if err != nil { + t.Fatalf("parse url: %v", err) + } + var parts []byte + for i, c := range hc.Jar.Cookies(u) { + if i > 0 { + parts = append(parts, ';', ' ') + } + parts = append(parts, []byte(c.Name+"="+c.Value)...) + } + if len(parts) == 0 { + t.Fatal("no cookies captured from login (mfa_session expected)") + } + return string(parts) +} + +// loginCapture drives the login mutation through the raw jar client so the +// mfa_session cookie is retained, returning the parsed login payload for +// assertions. (SDK Login is asserted separately in mfa_routing_test.go; here we +// need the cookie the SDK would throw away.) +type loginPayload struct { + Message string `json:"message"` + AccessToken *string `json:"access_token"` + ShouldShowTotpScreen *bool `json:"should_show_totp_screen"` + ShouldOfferSmsOtpMfaSetup *bool `json:"should_offer_sms_otp_mfa_setup"` +} + +func loginCapture(t *testing.T, hc *http.Client, instanceURL, email string) loginPayload { + t.Helper() + const q = `mutation ($params: LoginRequest!) { + login(params: $params) { message access_token should_show_totp_screen should_offer_sms_otp_mfa_setup } + }` + res := rawGraphQL(t, hc, instanceURL, q, map[string]any{ + "params": map[string]any{"email": email, "password": testPassword}, + }) + if len(res.Errors) > 0 { + t.Fatalf("login errored: %s", res.errorText()) + } + var wrap struct { + Login loginPayload `json:"login"` + } + if err := json.Unmarshal(res.Data, &wrap); err != nil { + t.Fatalf("decode login payload: %v", err) + } + return wrap.Login +} + +// --- mock-sink polling ------------------------------------------------------ + +// pollSMS polls sms-sink's GET /sms/:phone (404 until a message lands) for the +// OTP body sent to phone, mirroring the Playwright suite's helper. +func pollSMS(t *testing.T, phone string) string { + t.Helper() + for i := 0; i < 40; i++ { + res, err := http.Get(smsSinkURL + "/sms/" + url.PathEscape(phone)) + if err == nil && res.StatusCode == http.StatusOK { + var body struct { + Message string `json:"message"` + } + _ = json.NewDecoder(res.Body).Decode(&body) + res.Body.Close() + if body.Message != "" { + return body.Message + } + } + if res != nil { + res.Body.Close() + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("no SMS received for %s within timeout", phone) + return "" +} + +// extractOTP pulls the 6-char code out of "...code is: XXXXXX". The code charset +// is A-Z0-9 minus ambiguous I/O/0/1 (utils.GenerateOTP), so a numeric-only regex +// would miss it — anchor on the fixed prefix and take the first whitespace token. +func extractOTP(t *testing.T, message string) string { + t.Helper() + const marker = "code is:" + idx := strings.Index(message, marker) + if idx < 0 { + t.Fatalf("no OTP marker in SMS body: %q", message) + } + fields := strings.Fields(message[idx+len(marker):]) + if len(fields) == 0 || len(fields[0]) != 6 { + t.Fatalf("could not extract 6-char OTP from SMS body: %q", message) + } + return fields[0] +} + +// --- TOTP code generation (server uses github.com/pquerna/otp/totp) --------- + +func totpCode(t *testing.T, secret string) string { + t.Helper() + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatalf("totp.GenerateCode: %v", err) + } + return code +} + +// wrongTotpCode returns a 6-digit code guaranteed different from the current +// valid one (increment mod 1e6), so it always fails verification. +func wrongTotpCode(t *testing.T, secret string) string { + t.Helper() + valid := totpCode(t, secret) + var n int + fmt.Sscanf(valid, "%d", &n) + return fmt.Sprintf("%06d", (n+1)%1_000_000) +} + +func boolValue(b *bool) bool { return b != nil && *b } + +// --- small assertion / raw-verify helpers used across the OTP suites -------- + +// httpJar bundles a *testing.T with a cookie-jar client so the OTP tests can +// call verify_otp (the one call the SDK can't carry a cookie into) tersely. +type httpJar struct { + t *testing.T + hc *http.Client +} + +// verifyOTP runs the verify_otp mutation through the jar client against the +// default instance. SDK-gap escape hatch, labelled at every call site's helper. +func (j *httpJar) verifyOTP(params map[string]any) gqlResponse { + j.t.Helper() + return rawGraphQL(j.t, j.hc, baseURL, verifyOTPMutation, map[string]any{"params": params}) +} + +func mustDecode(t *testing.T, res gqlResponse, out any) { + t.Helper() + if len(res.Errors) > 0 { + t.Fatalf("unexpected gql errors: %s", res.errorText()) + } + if err := json.Unmarshal(res.Data, out); err != nil { + t.Fatalf("decode gql data: %v", err) + } +} + +func assertErrorContains(t *testing.T, res gqlResponse, want string) { + t.Helper() + if len(res.Errors) == 0 { + t.Fatalf("expected an error containing %q, got none (data: %s)", want, res.Data) + } + if !strings.Contains(strings.ToLower(res.errorText()), strings.ToLower(want)) { + t.Fatalf("expected error containing %q, got %s", want, res.errorText()) + } +} diff --git a/e2e-playground/sdk-tests/go/mfa_routing_test.go b/e2e-playground/sdk-tests/go/mfa_routing_test.go new file mode 100644 index 000000000..6c898e95f --- /dev/null +++ b/e2e-playground/sdk-tests/go/mfa_routing_test.go @@ -0,0 +1,111 @@ +package sdktests + +// MFA enforcement routing — driven entirely through the SDK's typed Login / +// SkipMfaSetup methods. This is the highest-value pure-SDK area: it asserts the +// exact wire shape of the login response (withheld access_token + the +// should_show_totp_screen / should_offer_* flags), which is precisely the class +// of drift the existing raw-GraphQL Playwright suite can't catch on the SDK. + +import ( + "strings" + "testing" + + "github.com/authorizerdev/authorizer-go/v2" +) + +// Under --enforce-mfa, a brand-new password user is routed into MFA enrollment +// with the token withheld. Read the whole login response through the SDK. +func TestMFAEnforced_LoginWithholdsTokenAndRoutesToEnrollment(t *testing.T) { + c := userClient(t, mfaEnforcedURL) + email := randomEmail("mfa-enforced") + + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + + res, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}) + if err != nil { + t.Fatalf("Login: %v", err) + } + + // mfaGateBlockEnroll: enforcement withholds the token entirely. + if res.AccessToken != nil && *res.AccessToken != "" { + t.Errorf("expected withheld access_token under --enforce-mfa, got %q", *res.AccessToken) + } + if got := stringValue(res.Message); got != "Proceed to mfa setup" { + t.Errorf("expected message %q, got %q", "Proceed to mfa setup", got) + } + // TOTP is enabled by default in this stack, so the TOTP enrollment screen + // is offered alongside the withheld token. + if !boolValue(res.ShouldShowTotpScreen) { + t.Errorf("expected should_show_totp_screen=true under enforcement") + } +} + +// skip_mfa_setup is refused under enforcement (enforcement is never skippable). +// Through the SDK the refusal surfaces as an error; the SDK's SkipMfaSetup can't +// carry the mfa_session cookie login armed (SDK Login discards it), so the +// server rejects it before even reaching the "cannot skip" enforcement branch — +// either way the skip does NOT yield a token, which is the security property. +func TestMFAEnforced_SkipMfaSetupIsRefused(t *testing.T) { + c := userClient(t, mfaEnforcedURL) + email := randomEmail("mfa-enforced-skip") + + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + if _, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}); err != nil { + t.Fatalf("Login: %v", err) + } + + res, err := c.SkipMfaSetup(&authorizer.SkipMfaSetupRequest{Email: &email}) + if err == nil { + // If it somehow returned a payload, it must not contain a usable token. + if res != nil && res.AccessToken != nil && *res.AccessToken != "" { + t.Fatalf("skip_mfa_setup issued a token under enforcement — enforcement bypassed") + } + t.Fatalf("expected skip_mfa_setup to error under enforcement, got nil error") + } + // Accept either the enforcement message or the session-absent rejection; + // both prove the skip did not succeed. + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "cannot skip") && !strings.Contains(msg, "unauthor") && !strings.Contains(msg, "session") { + t.Errorf("unexpected skip refusal reason: %v", err) + } +} + +// On the default (non-enforced) instance, a brand-new user is still gated but +// with the SKIPPABLE offer (mfaGateOfferAll): token withheld, TOTP offered. The +// distinction from enforcement is that skip WOULD be allowed here — asserted via +// the login response shape the SDK returns. +func TestDefaultInstance_LoginOffersOptionalTotpEnrollment(t *testing.T) { + c := userClient(t, baseURL) + email := randomEmail("mfa-optional") + + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + res, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}) + if err != nil { + t.Fatalf("Login: %v", err) + } + if res.AccessToken != nil && *res.AccessToken != "" { + t.Errorf("expected withheld token on first-login optional-MFA offer") + } + if !boolValue(res.ShouldShowTotpScreen) { + t.Errorf("expected should_show_totp_screen=true on optional-MFA offer") + } +} + +func stringValue(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/e2e-playground/sdk-tests/go/otp_lockout_test.go b/e2e-playground/sdk-tests/go/otp_lockout_test.go new file mode 100644 index 000000000..35762eb10 --- /dev/null +++ b/e2e-playground/sdk-tests/go/otp_lockout_test.go @@ -0,0 +1,103 @@ +package sdktests + +// OTP brute-force lockout (#698): 5 failed verify_otp attempts within the +// sliding window lock the user out with a DISTINCT "too many failed attempts" +// error — increment-then-check, so the 6th call is the one rejected as locked, +// even with a correct code. Mirrors e2e-playground/tests/otp-lockout.spec.ts. +// +// Enrollment goes through the SDK (TotpMfaSetup / SmsOtpMfaSetup); the verify_otp +// attempts go through the raw jar client for the same SDK-gap reason as the +// TOTP/SMS happy-path tests (VerifyOTP takes no cookie header). + +import ( + "testing" + + "github.com/authorizerdev/authorizer-go/v2" +) + +func TestOTPLockout_TOTP(t *testing.T) { + email := randomEmail("totp-lockout") + jar, secret := enrollTotp(t, email) + wrong := wrongTotpCode(t, secret) + + // 5 wrong codes → still the plain invalid-otp error. + for i := 0; i < 5; i++ { + res := jar.verifyOTP(map[string]any{"email": email, "otp": wrong, "is_totp": true}) + assertErrorContains(t, res, "invalid otp") + } + + // 6th attempt (even wrong) → distinct lockout error, not invalid-otp. + locked := jar.verifyOTP(map[string]any{"email": email, "otp": wrong, "is_totp": true}) + assertErrorContains(t, locked, "too many failed attempts") + + // The CORRECT code is also refused while locked — lockout blocks + // verification outright, not just wrong guesses. + stillLocked := jar.verifyOTP(map[string]any{"email": email, "otp": totpCode(t, secret), "is_totp": true}) + assertErrorContains(t, stillLocked, "too many failed attempts") +} + +func TestOTPLockout_TOTPResetsAfterSuccess(t *testing.T) { + email := randomEmail("totp-reset") + jar, secret := enrollTotp(t, email) + wrong := wrongTotpCode(t, secret) + + // 3 failed attempts (under the 5 budget), then succeed → counter cleared. + for i := 0; i < 3; i++ { + jar.verifyOTP(map[string]any{"email": email, "otp": wrong, "is_totp": true}) + } + ok := jar.verifyOTP(map[string]any{"email": email, "otp": totpCode(t, secret), "is_totp": true}) + if len(ok.Errors) > 0 { + t.Fatalf("expected success after 3 fails, got: %s", ok.errorText()) + } + + // Re-login (fresh mfa_session; lock key is per-user.ID so a stale count + // would carry over) and burn a full 5 wrong attempts — if the reset hadn't + // happened, 3 carried + 3 new would have locked before the 5th. + jar2 := jarClient(t) + loginCapture(t, jar2, baseURL, email) + wrapped := &httpJar{t, jar2} + for i := 0; i < 5; i++ { + res := wrapped.verifyOTP(map[string]any{"email": email, "otp": wrong, "is_totp": true}) + assertErrorContains(t, res, "invalid otp") + } +} + +func TestOTPLockout_SMS(t *testing.T) { + c := userClient(t, baseURL) + email := randomEmail("sms-otp-lockout") + phone := randomPhone() + + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, PhoneNumber: &phone, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + jar := jarClient(t) + login := loginCapture(t, jar, baseURL, email) + if !boolValue(login.ShouldOfferSmsOtpMfaSetup) { + t.Fatalf("expected should_offer_sms_otp_mfa_setup=true") + } + cookieHdr := mfaSessionCookieHeader(t, jar, baseURL) + if _, err := c.SmsOtpMfaSetup(&authorizer.SmsOtpMfaSetupRequest{PhoneNumber: &phone}, + map[string]string{"Cookie": cookieHdr}); err != nil { + t.Fatalf("SmsOtpMfaSetup: %v", err) + } + correct := extractOTP(t, pollSMS(t, phone)) + const wrong = "ZZZZZZ" // outside the OTP charset window → always a mismatch + + for i := 0; i < 5; i++ { + res := rawGraphQL(t, jar, baseURL, verifyOTPMutation, map[string]any{ + "params": map[string]any{"phone_number": phone, "otp": wrong, "is_totp": false}, + }) + assertErrorContains(t, res, "otp") + } + locked := rawGraphQL(t, jar, baseURL, verifyOTPMutation, map[string]any{ + "params": map[string]any{"phone_number": phone, "otp": wrong, "is_totp": false}, + }) + assertErrorContains(t, locked, "too many failed attempts") + + stillLocked := rawGraphQL(t, jar, baseURL, verifyOTPMutation, map[string]any{ + "params": map[string]any{"phone_number": phone, "otp": correct, "is_totp": false}, + }) + assertErrorContains(t, stillLocked, "too many failed attempts") +} diff --git a/e2e-playground/sdk-tests/go/saml_idp_admin_test.go b/e2e-playground/sdk-tests/go/saml_idp_admin_test.go new file mode 100644 index 000000000..1f665e2c7 --- /dev/null +++ b/e2e-playground/sdk-tests/go/saml_idp_admin_test.go @@ -0,0 +1,136 @@ +package sdktests + +// SAML IdP admin surface — fully SDK-drivable, no browser needed. +// +// Authorizer-as-IdP exposes a real admin API for managing downstream SAML +// Service Providers and the IdP signing keys, and authorizer-go wraps all of it +// with typed proto methods. That admin/config plane is exactly what belongs in +// an SDK and is exercised here end to end: SP create → get → list → update → +// key rotation → key listing → delete. +// +// The SAML *login ceremony itself* (AuthnRequest redirect, IdP form POST, signed +// assertion back to the SP ACS) is inherently a browser/form-post flow with no +// SDK login-initiation surface — that stays in the Playwright suite +// (tests/saml-idp.spec.ts, tests/saml-sp.spec.ts). See README. + +import ( + "strings" + "testing" + + authorizer "github.com/authorizerdev/authorizer-go/v2" + authorizerv1 "github.com/authorizerdev/authorizer-proto-go/authorizer/v1" +) + +func newOrg(t *testing.T, admin *authorizer.AuthorizerAdminClient, prefix string) *authorizer.Organization { + t.Helper() + org, err := admin.CreateOrganization(&authorizer.CreateOrganizationRequest{ + Name: randomSlug(prefix), + }) + if err != nil { + t.Fatalf("CreateOrganization: %v", err) + } + return org +} + +func TestSAMLIdP_ServiceProviderCRUDAndKeyRotation(t *testing.T) { + admin := adminClient(t, baseURL) + org := newOrg(t, admin, "saml-sp") + + entityID := "https://sp.example.com/" + org.ID + acsURL := "https://sp.example.com/acs" + + // create + createRes, err := admin.CreateSamlServiceProvider(&authorizerv1.CreateSamlServiceProviderRequest{ + OrgId: org.ID, + Name: "e2e-sp", + EntityId: entityID, + AcsUrl: acsURL, + }) + if err != nil { + t.Fatalf("CreateSamlServiceProvider: %v", err) + } + sp := createRes.GetSamlServiceProvider() + if sp.GetId() == "" || sp.GetEntityId() != entityID || sp.GetAcsUrl() != acsURL { + t.Fatalf("unexpected SP after create: %+v", sp) + } + + // get by id + got, err := admin.GetSamlServiceProvider(&authorizerv1.GetSamlServiceProviderRequest{Id: sp.GetId()}) + if err != nil { + t.Fatalf("GetSamlServiceProvider: %v", err) + } + if got.GetSamlServiceProvider().GetEntityId() != entityID { + t.Fatalf("get returned wrong SP: %+v", got.GetSamlServiceProvider()) + } + + // list for the org includes it + list, err := admin.ListSamlServiceProviders(&authorizerv1.ListSamlServiceProvidersRequest{OrgId: org.ID}) + if err != nil { + t.Fatalf("ListSamlServiceProviders: %v", err) + } + if !containsSP(list.GetSamlServiceProviders(), sp.GetId()) { + t.Fatalf("listed SPs did not contain %s", sp.GetId()) + } + + // update name + active flag + newName := "e2e-sp-renamed" + inactive := false + upd, err := admin.UpdateSamlServiceProvider(&authorizerv1.UpdateSamlServiceProviderRequest{ + Id: sp.GetId(), + Name: &newName, + IsActive: &inactive, + }) + if err != nil { + t.Fatalf("UpdateSamlServiceProvider: %v", err) + } + if upd.GetSamlServiceProvider().GetName() != newName { + t.Fatalf("update did not apply name: %+v", upd.GetSamlServiceProvider()) + } + if upd.GetSamlServiceProvider().GetIsActive() { + t.Fatalf("update did not deactivate SP") + } + + // rotate the org's IdP signing cert, then confirm the new key is listed + rot, err := admin.RotateSamlIdpCert(&authorizerv1.RotateSamlIdpCertRequest{OrgId: org.ID}) + if err != nil { + t.Fatalf("RotateSamlIdpCert: %v", err) + } + rotated := rot.GetSamlIdpKey() + if rotated.GetId() == "" || !strings.Contains(rotated.GetCertPem(), "CERTIFICATE") { + t.Fatalf("rotated key missing id/cert PEM: %+v", rotated) + } + + keys, err := admin.ListSamlIdpKeys(&authorizerv1.ListSamlIdpKeysRequest{OrgId: org.ID}) + if err != nil { + t.Fatalf("ListSamlIdpKeys: %v", err) + } + if !containsKey(keys.GetSamlIdpKeys(), rotated.GetId()) { + t.Fatalf("rotated key %s not present in ListSamlIdpKeys", rotated.GetId()) + } + + // delete the SP + if _, err := admin.DeleteSamlServiceProvider(&authorizerv1.DeleteSamlServiceProviderRequest{Id: sp.GetId()}); err != nil { + t.Fatalf("DeleteSamlServiceProvider: %v", err) + } + if _, err := admin.GetSamlServiceProvider(&authorizerv1.GetSamlServiceProviderRequest{Id: sp.GetId()}); err == nil { + t.Fatalf("expected GetSamlServiceProvider to fail after delete") + } +} + +func containsSP(sps []*authorizerv1.SamlServiceProvider, id string) bool { + for _, s := range sps { + if s.GetId() == id { + return true + } + } + return false +} + +func containsKey(keys []*authorizerv1.SamlIdpKey, id string) bool { + for _, k := range keys { + if k.GetId() == id { + return true + } + } + return false +} diff --git a/e2e-playground/sdk-tests/go/scim_test.go b/e2e-playground/sdk-tests/go/scim_test.go new file mode 100644 index 000000000..56cc3c0f5 --- /dev/null +++ b/e2e-playground/sdk-tests/go/scim_test.go @@ -0,0 +1,275 @@ +package sdktests + +// SCIM — a deliberately hybrid test, and the split is the architecturally +// correct one, not a workaround: +// +// - The ADMIN/config plane around SCIM IS wrapped by the SDK and is driven +// through it: CreateOrganization, CreateScimEndpoint (+ its one-time bearer +// token), GetScimEndpoint, RotateScimToken, DeleteScimEndpoint, and +// AddWebhook for the provisioning lifecycle events. These are the calls a +// back-office integrator makes, so they belong in the SDK and are tested +// there. +// +// - The SCIM /scim/v2/Users CRUD itself is a standardized RFC 7644 REST +// protocol that an external IdP (Okta/Azure AD) hits directly with a bearer +// token — it is intentionally NOT wrapped by authorizer-go (an "SDK" for it +// would just be a generic SCIM HTTP client). Those calls stay raw HTTP and +// are labelled as the SCIM protocol surface, not an SDK gap. +// +// The webhook-delivery assertion ties both halves together: a webhook +// registered via the SDK admin client must fire (with a valid HMAC signature) +// when a user is provisioned/updated/deprovisioned via the raw SCIM protocol. + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/url" + "sort" + "testing" + "time" + + authorizer "github.com/authorizerdev/authorizer-go/v2" + authorizerv1 "github.com/authorizerdev/authorizer-proto-go/authorizer/v1" +) + +// scimReq issues a raw SCIM protocol request with the endpoint's bearer token. +func scimReq(t *testing.T, method, token, path string, body any) (int, map[string]any) { + t.Helper() + var reader io.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = bytes.NewReader(b) + } + req, err := http.NewRequest(method, baseURL+path, reader) + if err != nil { + t.Fatalf("scim new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/scim+json") + req.Header.Set("Origin", baseURL) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("scim %s %s: %v", method, path, err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + var parsed map[string]any + if len(raw) > 0 { + _ = json.Unmarshal(raw, &parsed) + } + return res.StatusCode, parsed +} + +// TestSCIM_AdminLifecycleAndProvisioning drives the SDK admin surface for a SCIM +// endpoint, then provisions/updates/deprovisions a user over raw SCIM. +func TestSCIM_AdminLifecycleAndProvisioning(t *testing.T) { + admin := adminClient(t, baseURL) + org := newOrg(t, admin, "scim") + + // --- SDK admin: provision the SCIM endpoint + read it back --- + created, err := admin.CreateScimEndpoint(&authorizer.CreateScimEndpointRequest{OrgID: org.ID}) + if err != nil { + t.Fatalf("CreateScimEndpoint (SDK): %v", err) + } + if created.Token == "" || created.ScimEndpoint == nil { + t.Fatalf("CreateScimEndpoint returned empty token/endpoint: %+v", created) + } + token := created.Token + + ep, err := admin.GetScimEndpoint(&authorizer.ScimEndpointRequest{OrgID: org.ID}) + if err != nil { + t.Fatalf("GetScimEndpoint (SDK): %v", err) + } + if ep.OrgID != org.ID || !ep.Enabled { + t.Fatalf("unexpected SCIM endpoint: %+v", ep) + } + + // --- raw SCIM protocol: create → patch(active:false) → delete --- + email := "scim-user-" + org.ID + "@example.com" + status, body := scimReq(t, http.MethodPost, token, "/scim/v2/Users", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "userName": email, + "name": map[string]any{"givenName": "Katherine", "familyName": "Johnson"}, + "emails": []map[string]any{{"value": email, "primary": true}}, + "active": true, + }) + if status != http.StatusCreated { + t.Fatalf("SCIM create: expected 201, got %d (%v)", status, body) + } + userID, _ := body["id"].(string) + if userID == "" || body["userName"] != email { + t.Fatalf("SCIM create returned unexpected body: %v", body) + } + + status, _ = scimReq(t, http.MethodPatch, token, "/scim/v2/Users/"+userID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{{"op": "replace", "value": map[string]any{"active": false}}}, + }) + if status != http.StatusOK { + t.Fatalf("SCIM patch: expected 200, got %d", status) + } + + status, _ = scimReq(t, http.MethodDelete, token, "/scim/v2/Users/"+userID, nil) + if status != http.StatusNoContent { + t.Fatalf("SCIM delete: expected 204, got %d", status) + } + + // --- SDK admin: rotate the token (old one must stop working) --- + rotated, err := admin.RotateScimToken(&authorizer.ScimEndpointRequest{OrgID: org.ID}) + if err != nil { + t.Fatalf("RotateScimToken (SDK): %v", err) + } + if rotated.Token == "" || rotated.Token == token { + t.Fatalf("RotateScimToken did not return a fresh token") + } + if status, _ := scimReq(t, http.MethodGet, token, "/scim/v2/Users", nil); status == http.StatusOK { + t.Fatalf("old SCIM token still valid after rotation (status %d)", status) + } + + // --- SDK admin: delete the endpoint (destructive) --- + if _, err := admin.DeleteScimEndpoint(&authorizer.ScimEndpointRequest{OrgID: org.ID}); err != nil { + t.Fatalf("DeleteScimEndpoint (SDK): %v", err) + } +} + +// TestSCIM_ProvisioningWebhookDelivery registers the three SCIM lifecycle +// webhooks via the SDK admin client, then verifies real delivery (with valid +// HMAC) to webhook-sink when a user is provisioned/updated/deprovisioned over +// raw SCIM. Runs against the default `authorizer` instance because that is the +// only one configured with --env=e2e (so the docker-private webhook-sink is +// reachable). +func TestSCIM_ProvisioningWebhookDelivery(t *testing.T) { + admin := adminClient(t, baseURL) + endpoint := webhookSinkURL + "/webhook" + + for _, event := range []string{"user.provisioned", "user.scim_updated", "user.deprovisioned"} { + if _, err := admin.AddWebhook(&authorizerv1.AddWebhookRequest{ + EventName: event, + Endpoint: endpoint, + Enabled: true, + }); err != nil { + t.Fatalf("AddWebhook(%s) (SDK): %v", event, err) + } + } + + org := newOrg(t, admin, "scim-webhook") + created, err := admin.CreateScimEndpoint(&authorizer.CreateScimEndpointRequest{OrgID: org.ID}) + if err != nil { + t.Fatalf("CreateScimEndpoint: %v", err) + } + token := created.Token + email := "scim-webhook-user-" + org.ID + "@example.com" + + status, body := scimReq(t, http.MethodPost, token, "/scim/v2/Users", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "userName": email, + "name": map[string]any{"givenName": "Katherine", "familyName": "Johnson"}, + "emails": []map[string]any{{"value": email, "primary": true}}, + "active": true, + }) + if status != http.StatusCreated { + t.Fatalf("SCIM create: expected 201, got %d", status) + } + userID, _ := body["id"].(string) + + status, _ = scimReq(t, http.MethodPatch, token, "/scim/v2/Users/"+userID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{{"op": "replace", "path": "name.givenName", "value": "Kate"}}, + }) + if status != http.StatusOK { + t.Fatalf("SCIM patch: expected 200, got %d", status) + } + + status, _ = scimReq(t, http.MethodDelete, token, "/scim/v2/Users/"+userID, nil) + if status != http.StatusNoContent { + t.Fatalf("SCIM delete: expected 204, got %d", status) + } + + // Delivery is a detached goroutine; poll the sink until all three land. + want := []string{"user.deprovisioned", "user.provisioned", "user.scim_updated"} + var events map[string]webhookDelivery + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + events = fetchWebhookEvents(t, email) + got := make([]string, 0, len(events)) + for k := range events { + got = append(got, k) + } + sort.Strings(got) + if equalStrings(got, want) { + break + } + time.Sleep(500 * time.Millisecond) + } + + for _, event := range want { + d, ok := events[event] + if !ok { + t.Fatalf("missing webhook delivery for %s (got %v)", event, keys(events)) + } + if d.Body.EventName != event { + t.Errorf("delivery %s carried event_name %q", event, d.Body.EventName) + } + if d.Body.User.Email != email { + t.Errorf("delivery %s carried user email %q, want %q", event, d.Body.User.Email, email) + } + mac := hmac.New(sha256.New, []byte(clientSecret)) + mac.Write([]byte(d.RawBody)) + if want := hex.EncodeToString(mac.Sum(nil)); want != d.Signature { + t.Errorf("HMAC mismatch for %s: got %s want %s", event, d.Signature, want) + } + } +} + +type webhookDelivery struct { + Signature string `json:"signature"` + RawBody string `json:"rawBody"` + Body struct { + EventName string `json:"event_name"` + User struct { + Email string `json:"email"` + } `json:"user"` + } `json:"body"` +} + +func fetchWebhookEvents(t *testing.T, email string) map[string]webhookDelivery { + t.Helper() + res, err := http.Get(webhookSinkURL + "/webhook/" + url.PathEscape(email)) + if err != nil || res.StatusCode != http.StatusOK { + if res != nil { + res.Body.Close() + } + return nil + } + defer res.Body.Close() + var wrap struct { + Events map[string]webhookDelivery `json:"events"` + } + _ = json.NewDecoder(res.Body).Decode(&wrap) + return wrap.Events +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func keys(m map[string]webhookDelivery) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/e2e-playground/sdk-tests/go/sms_otp_test.go b/e2e-playground/sdk-tests/go/sms_otp_test.go new file mode 100644 index 000000000..913495746 --- /dev/null +++ b/e2e-playground/sdk-tests/go/sms_otp_test.go @@ -0,0 +1,52 @@ +package sdktests + +// SMS-OTP enrollment + challenge. Same hybrid split as TOTP: SDK drives SignUp +// and SmsOtpMfaSetup (accepts headers → genuine SDK call, threads the cookie and +// triggers the real code send to sms-sink); the raw jar client drives login +// (cookie capture) and verify_otp (no SDK header param). The code is read back +// from the sms-sink mock exactly as the Playwright suite does. + +import ( + "testing" + + "github.com/authorizerdev/authorizer-go/v2" +) + +func TestSMSOTP_EnrollAndVerify(t *testing.T) { + c := userClient(t, baseURL) + email := randomEmail("sms-otp") + phone := randomPhone() + + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, PhoneNumber: &phone, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + + jar := jarClient(t) + login := loginCapture(t, jar, baseURL, email) + if !boolValue(login.ShouldOfferSmsOtpMfaSetup) { + t.Fatalf("expected should_offer_sms_otp_mfa_setup=true, got %+v", login) + } + + cookieHdr := mfaSessionCookieHeader(t, jar, baseURL) + if _, err := c.SmsOtpMfaSetup(&authorizer.SmsOtpMfaSetupRequest{PhoneNumber: &phone}, + map[string]string{"Cookie": cookieHdr}); err != nil { + t.Fatalf("SmsOtpMfaSetup (SDK): %v", err) + } + + code := extractOTP(t, pollSMS(t, phone)) + + res := rawGraphQL(t, jar, baseURL, verifyOTPMutation, map[string]any{ + "params": map[string]any{"phone_number": phone, "otp": code, "is_totp": false}, + }) + var wrap struct { + VerifyOTP struct { + AccessToken *string `json:"access_token"` + } `json:"verify_otp"` + } + mustDecode(t, res, &wrap) + if wrap.VerifyOTP.AccessToken == nil || *wrap.VerifyOTP.AccessToken == "" { + t.Fatalf("expected access_token after successful SMS-OTP verification") + } +} diff --git a/e2e-playground/sdk-tests/go/totp_test.go b/e2e-playground/sdk-tests/go/totp_test.go new file mode 100644 index 000000000..a19493b71 --- /dev/null +++ b/e2e-playground/sdk-tests/go/totp_test.go @@ -0,0 +1,78 @@ +package sdktests + +// TOTP enrollment + challenge. Hybrid by necessity: +// +// - SDK drives SignUp and TotpMfaSetup (the latter accepts per-call headers, +// so the mfa_session cookie is threaded in and this is a genuine SDK +// exercise — it builds the totp_mfa_setup mutation and parses +// authenticator_secret, catching drift in that response shape). +// - The raw jar client drives login (to capture the mfa_session Set-Cookie +// the SDK's Login discards) and verify_otp (the SDK's VerifyOTP exposes no +// header parameter, so the cookie verify_otp requires unconditionally can't +// be sent through it). Both are labelled SDK gaps — see README. + +import ( + "testing" + + "github.com/authorizerdev/authorizer-go/v2" +) + +const verifyOTPMutation = `mutation ($params: VerifyOTPRequest!) { + verify_otp(params: $params) { message access_token } +}` + +// enrollTotp signs up, logs in (jar captures mfa_session), and runs +// TotpMfaSetup through the SDK, returning the jar client and the TOTP secret. +func enrollTotp(t *testing.T, email string) (*httpJar, string) { + t.Helper() + c := userClient(t, baseURL) + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + + jar := jarClient(t) + login := loginCapture(t, jar, baseURL, email) + if !boolValue(login.ShouldShowTotpScreen) { + t.Fatalf("expected should_show_totp_screen=true, got %+v", login) + } + + cookieHdr := mfaSessionCookieHeader(t, jar, baseURL) + setup, err := c.TotpMfaSetup(&authorizer.TotpMfaSetupRequest{Email: &email}, + map[string]string{"Cookie": cookieHdr}) + if err != nil { + t.Fatalf("TotpMfaSetup (SDK): %v", err) + } + if setup.AuthenticatorSecret == nil || *setup.AuthenticatorSecret == "" { + t.Fatalf("TotpMfaSetup returned empty authenticator_secret") + } + return &httpJar{t, jar}, *setup.AuthenticatorSecret +} + +func TestTOTP_EnrollAndVerify(t *testing.T) { + email := randomEmail("totp") + jar, secret := enrollTotp(t, email) + + res := jar.verifyOTP(map[string]any{"email": email, "otp": totpCode(t, secret), "is_totp": true}) + if len(res.Errors) > 0 { + t.Fatalf("verify_otp with correct code errored: %s", res.errorText()) + } + var wrap struct { + VerifyOTP struct { + AccessToken *string `json:"access_token"` + } `json:"verify_otp"` + } + mustDecode(t, res, &wrap) + if wrap.VerifyOTP.AccessToken == nil || *wrap.VerifyOTP.AccessToken == "" { + t.Fatalf("expected access_token after successful TOTP verification") + } +} + +func TestTOTP_InvalidCodeRejected(t *testing.T) { + email := randomEmail("totp-invalid") + jar, secret := enrollTotp(t, email) + + res := jar.verifyOTP(map[string]any{"email": email, "otp": wrongTotpCode(t, secret), "is_totp": true}) + assertErrorContains(t, res, "invalid otp") +} diff --git a/e2e-playground/sdk-tests/go/webauthn_test.go b/e2e-playground/sdk-tests/go/webauthn_test.go new file mode 100644 index 000000000..d485a2d32 --- /dev/null +++ b/e2e-playground/sdk-tests/go/webauthn_test.go @@ -0,0 +1,159 @@ +package sdktests + +// WebAuthn / passkey. +// +// Unlike the browser suite (which needs Chrome's CDP virtual authenticator), +// the WebAuthn *authenticator* here is a pure-Go software authenticator +// (github.com/descope/virtualwebauthn — the standard pairing for go-webauthn, +// which is the server-side library Authorizer uses). That means the full +// registration ceremony can be completed WITHOUT a browser and driven through +// the SDK's typed methods: +// +// WebauthnRegistrationOptions (SDK, cookie threaded) → virtualwebauthn signs +// the attestation → WebauthnRegistrationVerify (SDK, cookie threaded). +// +// Both option/verify methods accept per-call headers, so the mfa_session cookie +// (which the withheld-token first-time-offer path requires) is threaded in and +// these are genuine SDK exercises. +// +// The passwordless passkey LOGIN completion is NOT SDK-drivable: the challenge +// from webauthn_login_options lives in a server-set session cookie that +// webauthn_login_options returns via Set-Cookie (discarded by the SDK) and +// webauthn_login_verify has no header parameter to send it back on — the same +// cookie gap that blocks verify_otp. Login is therefore covered only up to the +// options-shape assertion here; its full completion stays in the browser suite. +// +// The whole file requires the authorizer-webauthn instance reached over its +// pinned origin (http://webauthn.e2e-playground.test:8080) — go-webauthn's RPID +// validation and the instance's allowed-origins reject any other host. When run +// from the host (localhost) rather than inside the compose network, it skips. + +import ( + "encoding/json" + "net/url" + "strings" + "testing" + + authorizer "github.com/authorizerdev/authorizer-go/v2" + "github.com/descope/virtualwebauthn" +) + +// relyingParty derives the RP the software authenticator must present, from the +// webauthn instance URL. It skips when the target is localhost, because the +// server pins its origin/RPID to the dotted compose hostname regardless of how +// it is reached, so only the in-network run can satisfy the CSRF + RPID checks. +func relyingParty(t *testing.T) virtualwebauthn.RelyingParty { + t.Helper() + u, err := url.Parse(webauthnURL) + if err != nil { + t.Fatalf("parse webauthnURL: %v", err) + } + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" { + t.Skipf("WebAuthn requires the pinned compose origin (%s); run inside the docker network via the go-sdk-tests service", webauthnURL) + } + return virtualwebauthn.RelyingParty{ + Name: "Authorizer", + ID: host, // e.g. webauthn.e2e-playground.test + Origin: u.Scheme + "://" + u.Host, // e.g. http://webauthn.e2e-playground.test:8080 + } +} + +func TestWebAuthn_FullRegistrationCeremonyThroughSDK(t *testing.T) { + rp := relyingParty(t) + c := userClient(t, webauthnURL) + email := randomEmail("webauthn-reg") + + if _, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, Password: testPassword, ConfirmPassword: testPassword, + }); err != nil { + t.Fatalf("SignUp: %v", err) + } + + // Login to arm the mfa_session cookie (captured by the jar; SDK Login would + // discard it), then thread it into the SDK registration methods. + jar := jarClient(t) + loginCapture(t, jar, webauthnURL, email) + cookieHdr := mfaSessionCookieHeader(t, jar, webauthnURL) + hdrs := map[string]string{"Cookie": cookieHdr} + + // 1. registration options (SDK) + optsRes, err := c.WebauthnRegistrationOptions(&authorizer.WebauthnRegistrationOptionsRequest{Email: &email}, hdrs) + if err != nil { + t.Fatalf("WebauthnRegistrationOptions (SDK): %v", err) + } + attestationOpts, err := virtualwebauthn.ParseAttestationOptions(optsRes.Options) + if err != nil { + t.Fatalf("parse attestation options %q: %v", optsRes.Options, err) + } + + // 2. software authenticator signs the attestation + authenticator := virtualwebauthn.NewAuthenticator() + credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + attestationResponse := virtualwebauthn.CreateAttestationResponse(rp, authenticator, credential, *attestationOpts) + + // 3. registration verify (SDK) + name := "go-virtual-authenticator" + verifyRes, err := c.WebauthnRegistrationVerify(&authorizer.WebauthnRegistrationVerifyRequest{ + Name: &name, + Credential: attestationResponse, + Email: &email, + }, hdrs) + if err != nil { + t.Fatalf("WebauthnRegistrationVerify (SDK): %v", err) + } + // On the mfa-session path the withheld token is issued once enrollment + // completes; at minimum a non-error response with a message is returned. + if verifyRes == nil { + t.Fatalf("WebauthnRegistrationVerify returned nil response") + } + + // 4. the passkey now appears in the caller's credential list (SDK query, + // authenticated with the token just issued, or the session cookie). + authHdrs := hdrs + if verifyRes.AccessToken != nil && *verifyRes.AccessToken != "" { + authHdrs = map[string]string{"Authorization": "Bearer " + *verifyRes.AccessToken} + } + creds, err := c.WebauthnCredentials(authHdrs) + if err != nil { + t.Fatalf("WebauthnCredentials (SDK): %v", err) + } + if len(creds) == 0 { + t.Fatalf("expected at least one registered passkey after ceremony") + } + found := false + for _, cr := range creds { + if cr.Name == name { + found = true + } + } + if !found { + t.Errorf("registered passkey %q not found in credential list", name) + } +} + +// WebauthnLoginOptions is unauthenticated (start of passwordless login) and IS +// SDK-drivable; assert the challenge JSON is well-formed. Completing the login +// (webauthn_login_verify) is blocked by the cookie gap documented above. +func TestWebAuthn_LoginOptionsShapeThroughSDK(t *testing.T) { + relyingParty(t) // skip gate for non-compose runs + c := userClient(t, webauthnURL) + + res, err := c.WebauthnLoginOptions(nil) // discoverable / usernameless + if err != nil { + t.Fatalf("WebauthnLoginOptions (SDK): %v", err) + } + if strings.TrimSpace(res.Options) == "" { + t.Fatalf("WebauthnLoginOptions returned empty options") + } + // Must parse as a valid assertion challenge. + if _, err := virtualwebauthn.ParseAssertionOptions(res.Options); err != nil { + // Some servers wrap as {publicKey:{...}}; ensure it is at least valid JSON + // carrying a challenge field before failing hard. + var probe map[string]json.RawMessage + if json.Unmarshal([]byte(res.Options), &probe) != nil { + t.Fatalf("login options not valid JSON: %q", res.Options) + } + t.Fatalf("ParseAssertionOptions: %v (options: %q)", err, res.Options) + } +} diff --git a/e2e-playground/sdk-tests/python/.gitignore b/e2e-playground/sdk-tests/python/.gitignore new file mode 100644 index 000000000..3766e60ab --- /dev/null +++ b/e2e-playground/sdk-tests/python/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +.venv/ diff --git a/e2e-playground/sdk-tests/python/Dockerfile b/e2e-playground/sdk-tests/python/Dockerfile new file mode 100644 index 000000000..1a33435fc --- /dev/null +++ b/e2e-playground/sdk-tests/python/Dockerfile @@ -0,0 +1,19 @@ +# Runs the Python SDK e2e suite from inside the compose network, for the exact +# same reason the sibling `playwright` service does: the social-OAuth redirect +# chain (mock-oauth:4000) and the WebAuthn RP origin +# (webauthn.e2e-playground.test:8080) only resolve/validate from within the +# docker-internal network. Never started by `up`; invoked via +# `docker compose run --rm python-sdk ...`. +FROM python:3.12-slim + +WORKDIR /sdk-tests + +# Install deps first for layer caching. requirements.txt pins the PUBLISHED +# authorizer-py — no source of this repo's Go server or the SDK is copied in. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . ./ + +# -p no:cacheprovider keeps the read-only image clean; -s streams poll output. +ENTRYPOINT ["python", "-m", "pytest", "-p", "no:cacheprovider"] diff --git a/e2e-playground/sdk-tests/python/README.md b/e2e-playground/sdk-tests/python/README.md new file mode 100644 index 000000000..dda6803ba --- /dev/null +++ b/e2e-playground/sdk-tests/python/README.md @@ -0,0 +1,76 @@ +# Python SDK e2e suite + +Live e2e tests that drive the `e2e-playground` stack's enterprise/MFA feature +set **through the published `authorizer-py` SDK** (PyPI `authorizer-py==0.3.0rc3`), +not raw GraphQL/REST. The sibling `../../tests/*.spec.ts` Playwright suite +already covers these features at the wire level and through a browser; this +suite re-drives the same flows through the SDK's own methods to catch +SDK↔server wire-shape drift (pagination shapes, stale/renamed fields, request +encodings) that a raw-HTTP or browser test would never surface. + +It depends on the **published** SDK as a normal dependency — no local/editable +install. That is the point: it exercises exactly what a consumer's `pip install +authorizer-py` gets. + +## Why it runs inside the compose network + +Like the `playwright` service, this suite runs as a compose service +(`python-sdk`) on the docker-internal network. Two feature areas require it: + +- **Social OAuth** — the server redirects the login chain to `mock-oauth:4000` + (a docker-internal hostname), so the redirect-follow must resolve it. +- **WebAuthn** — go-webauthn validates the RP origin against the instance's + `--url` (`http://webauthn.e2e-playground.test:8080`); the software + authenticator must sign against that exact origin. + +Everything else (TOTP, SMS-OTP, MFA routing, SCIM, SAML admin, OTP lockout) +would also run against host-exposed ports, but one runner keeps it uniform. + +## Run + +From the repository root, with the stack up (see `../../README.md`): + +```bash +docker compose -f e2e-playground/docker-compose.yml up -d --wait \ + authorizer authorizer-webauthn authorizer-mfa-enforced authorizer-mfa-magic-link \ + mock-oauth sms-sink webhook-sink mailpit +docker compose -f e2e-playground/docker-compose.yml run --rm --build python-sdk +docker compose -f e2e-playground/docker-compose.yml down -v +``` + +`--build` is required after any change under `sdk-tests/python/` (test files +are baked into the image, same as the Playwright runner). + +Subset / single file: + +```bash +docker compose -f e2e-playground/docker-compose.yml run --rm --build python-sdk tests/test_totp.py -v +``` + +## Lint / type-check + +Config in `pyproject.toml` mirrors `authorizer-py`'s own (ruff line-length 100, +mypy strict): + +```bash +pip install -e '.[dev]' +ruff check . +mypy . +``` + +## Coverage & the SDK/architecture boundary + +| Area | Through the SDK | Raw HTTP (labeled) — and why | +|---|---|---| +| TOTP | signup/login/`totp_mfa_setup`/`verify_otp` | — | +| SMS-OTP | signup/login/`sms_otp_mfa_setup`/`verify_otp` | read code from `sms-sink` (delivery sink) | +| MFA routing | login/`skip_mfa_setup`/`magic_link_login` | follow the magic-link email URL (browser redirect endpoint) | +| SCIM | endpoint CRUD, token rotation, `add_webhook`, `webhook_logs` | `/scim/v2/*` protocol (inbound RFC 7644 REST — not SDK surface) | +| Social OAuth | admin `users` verify; `skip_mfa_setup`+`validate_jwt_token`+`get_profile` | the `/oauth_login`→callback redirect chain (login initiation is a browser redirect, not wrapped by the SDK) | +| SAML | SP registry CRUD, IdP key rotate/retire, metadata import | — (the SSO ceremony is browser + HTML-form-POST; out of scope by construction) | +| WebAuthn | full ceremony via `webauthn_*` + `soft-webauthn` authenticator | — | +| OTP lockout | 5× `verify_otp` → `AuthorizerError` lockout message | read code from `sms-sink` | + +WebOTP has no distinct server flow — it is the browser WebOTP autofill reading +the same SMS-OTP code; the wire flow is covered by the SMS-OTP tests, the +autofill itself is browser-only. diff --git a/e2e-playground/sdk-tests/python/conftest.py b/e2e-playground/sdk-tests/python/conftest.py new file mode 100644 index 000000000..fd2f23c59 --- /dev/null +++ b/e2e-playground/sdk-tests/python/conftest.py @@ -0,0 +1,101 @@ +"""Pytest fixtures: SDK client factories wired to the live e2e-playground stack. + +Every test is marked ``live`` (see pyproject) — the whole suite talks to a +running stack. There is no mock of Authorizer itself. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator + +import httpx +import pytest +from authorizer import AuthorizerAdminClient, AuthorizerClient + +import helpers + + +class _CookieShim: + """Keeps the MFA session cookie flowing across SDK calls on single-label hosts. + + The MFA flow is cookie-based: login sets an ``mfa_session`` cookie that + ``totp_mfa_setup`` / ``verify_otp`` / ``skip_mfa_setup`` read back. The + server sets ``Domain=``; in this docker stack that host is a + single label (``authorizer``, ``authorizer-mfa-enforced``, ...). Two + layers of Python's stdlib cookie handling then drop it, and neither is a + product bug — both are artifacts of single-label hostnames that a real + dotted domain (auth.example.com) and the browser jar the Playwright suite + uses never hit: + + 1. ``http.cookiejar`` rewrites a single-label request host to + ``.local`` (``eff_request_host``), so the stored ``Domain`` + stops matching at send time. + 2. httpx 0.28's ``_merge_cookies`` rebuilds a fresh *default-policy* + jar per request (``Cookies(self.cookies)``), so any relaxed jar + policy is discarded before the request is sent. + + Rather than fight both, this shim captures Set-Cookie off responses and + re-injects a plain ``Cookie`` request header — exactly the bytes a browser + would send — via httpx event hooks. It never overrides a ``Cookie`` header + the caller set explicitly (so the social-login skip flow, which passes its + own captured cookie, is left untouched). + """ + + def __init__(self) -> None: + self._cookies: dict[str, str] = {} + + def on_response(self, response: httpx.Response) -> None: + for name, value in response.cookies.items(): + self._cookies[name] = value + + def on_request(self, request: httpx.Request) -> None: + if not self._cookies or "cookie" in request.headers: + return + request.headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self._cookies.items()) + + +@pytest.fixture(scope="session") +def admin() -> Iterator[AuthorizerAdminClient]: + """Session-wide admin client (x-authorizer-admin-secret) on the default instance.""" + c = AuthorizerAdminClient( + authorizer_url=helpers.AUTHORIZER_BASE_URL, + admin_secret=helpers.ADMIN_SECRET, + ) + try: + yield c + finally: + c.close() + + +@pytest.fixture +def make_client() -> Iterator[Callable[..., AuthorizerClient]]: + """Factory for public SDK clients. + + Each call returns a fresh ``AuthorizerClient`` (its own httpx cookie jar — + so a login's mfa_session cookie is replayed across subsequent setup/verify + calls on the SAME client, exactly as the MFA flow requires). All clients + handed out are closed at test teardown. + """ + created: list[AuthorizerClient] = [] + + def _make(base_url: str = helpers.AUTHORIZER_BASE_URL) -> AuthorizerClient: + c = AuthorizerClient(client_id=helpers.CLIENT_ID, authorizer_url=base_url) + # See _CookieShim: keep the mfa_session cookie flowing across the + # login -> setup/verify calls despite single-label docker hosts. + shim = _CookieShim() + c._http.event_hooks["request"].append(shim.on_request) + c._http.event_hooks["response"].append(shim.on_response) + created.append(c) + return c + + try: + yield _make + finally: + for c in created: + c.close() + + +@pytest.fixture +def client(make_client: Callable[..., AuthorizerClient]) -> AuthorizerClient: + """A public SDK client on the default `authorizer` instance.""" + return make_client() diff --git a/e2e-playground/sdk-tests/python/helpers.py b/e2e-playground/sdk-tests/python/helpers.py new file mode 100644 index 000000000..72308740d --- /dev/null +++ b/e2e-playground/sdk-tests/python/helpers.py @@ -0,0 +1,141 @@ +"""Shared configuration and mock-polling helpers for the SDK e2e suite. + +Everything here is deliberately transport-level (raw ``httpx``): these are the +parts a real integration exercises around the SDK — reading a delivered SMS +from ``sms-sink``, a magic-link email from Mailpit, a webhook from +``webhook-sink`` — none of which the ``authorizer-py`` SDK wraps (nor should +it; they are third-party sinks). Anything that *is* SDK surface goes through +the SDK in the test files, not here. +""" + +from __future__ import annotations + +import base64 +import os +import re +import time +import uuid +from typing import Any + +import httpx + +# --- base URLs -------------------------------------------------------------- # +# Host-port defaults let the suite run against a locally-exposed stack; the +# compose `python-sdk` service overrides each with the docker-internal +# hostname (mirroring the `playwright` service's env block) so social-OAuth +# and WebAuthn resolve/validate correctly. + + +def _env(name: str, default: str) -> str: + return os.environ.get(name, default) + + +AUTHORIZER_BASE_URL = _env("AUTHORIZER_BASE_URL", "http://localhost:8080") +AUTHORIZER_MFA_ENFORCED_BASE_URL = _env("AUTHORIZER_MFA_ENFORCED_BASE_URL", "http://localhost:8084") +AUTHORIZER_MFA_MAGIC_LINK_BASE_URL = _env( + "AUTHORIZER_MFA_MAGIC_LINK_BASE_URL", "http://localhost:8085" +) +AUTHORIZER_WEBAUTHN_BASE_URL = _env("AUTHORIZER_WEBAUTHN_BASE_URL", "http://localhost:8082") + +MOCK_OAUTH_BASE_URL = _env("MOCK_OAUTH_BASE_URL", "http://localhost:4000") +SMS_SINK_BASE_URL = _env("SMS_SINK_BASE_URL", "http://localhost:4100") +WEBHOOK_SINK_BASE_URL = _env("WEBHOOK_SINK_BASE_URL", "http://localhost:4200") +MAILPIT_BASE_URL = _env("MAILPIT_BASE_URL", "http://localhost:8025") + +ADMIN_SECRET = _env("AUTHORIZER_ADMIN_SECRET", "e2e-admin-secret") +CLIENT_ID = _env("AUTHORIZER_CLIENT_ID", "e2e-client-id") +# Matches --client-secret in docker-compose.yml; webhook HMAC signing key. +CLIENT_SECRET = _env("AUTHORIZER_CLIENT_SECRET", "e2e-client-secret") + +PASSWORD = "Str0ngPassw0rd!" + + +# --- random identities ------------------------------------------------------ # +def random_email(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4()}@example.com" + + +def random_phone() -> str: + # E.164-ish, wide enough range to avoid collisions across runs. + return f"+1555{uuid.uuid4().int % 9000000 + 1000000}" + + +# --- OTP extraction --------------------------------------------------------- # +# utils.GenerateOTP draws from "ABCDEFGHJKLMNPQRSTUVWXYZ123456789" (ambiguous +# I/O/0/1 excluded) — a naive \d{6} would never match, so split on the fixed +# "code is: " prefix, charset-agnostic. Same reasoning as the Playwright suite. +_OTP_RE = re.compile(r"code is:\s*([A-Z0-9]{6})") + + +def extract_otp(message: str) -> str: + m = _OTP_RE.search(message) + if not m: + raise AssertionError(f"could not find OTP in SMS body: {message!r}") + return m.group(1) + + +# --- mock polling ----------------------------------------------------------- # +def wait_for_sms(phone: str, *, attempts: int = 40, delay: float = 0.25) -> str: + """Poll sms-sink's GET /sms/:phone until a message lands; return its body.""" + url = f"{SMS_SINK_BASE_URL}/sms/{phone}" + with httpx.Client(timeout=5.0) as c: + for _ in range(attempts): + r = c.get(url) + if r.status_code == 200: + return str(r.json()["message"]) + time.sleep(delay) + raise AssertionError(f"no SMS received for {phone} within timeout") + + +def wait_for_magic_link(email: str, *, attempts: int = 40, delay: float = 0.25) -> str: + """Poll Mailpit for a verify_email magic link addressed to *email*.""" + link_re = re.compile(r"(https?://\S+verify_email\?\S+)") + with httpx.Client(timeout=5.0) as c: + for _ in range(attempts): + msgs = c.get(f"{MAILPIT_BASE_URL}/api/v1/messages").json().get("messages", []) + match = next( + (m for m in msgs if any(t["Address"] == email for t in m["To"])), + None, + ) + if match: + detail = c.get(f"{MAILPIT_BASE_URL}/api/v1/message/{match['ID']}").json() + found = link_re.search(detail.get("Text", "")) + if found: + return found.group(1).rstrip(")") + time.sleep(delay) + raise AssertionError(f"no magic-link email received for {email} within timeout") + + +def wait_for_webhook_events( + email: str, *, attempts: int = 40, delay: float = 0.25 +) -> dict[str, dict[str, Any]]: + """Poll webhook-sink's GET /webhook/:email; return its keyed events map.""" + url = f"{WEBHOOK_SINK_BASE_URL}/webhook/{email}" + with httpx.Client(timeout=5.0) as c: + for _ in range(attempts): + r = c.get(url) + if r.status_code == 200: + events: dict[str, dict[str, Any]] = r.json().get("events", {}) + if events: + return events + time.sleep(delay) + raise AssertionError(f"no webhook deliveries for {email} within timeout") + + +def configure_mock_profile(provider: str, profile: dict[str, Any]) -> None: + """Set the profile mock-oauth returns for the next login against *provider*.""" + with httpx.Client(timeout=5.0) as c: + r = c.post(f"{MOCK_OAUTH_BASE_URL}/{provider}/__configure", json={"profile": profile}) + if r.status_code != 204: + raise AssertionError( + f"failed to configure mock profile for {provider}: {r.status_code}" + ) + + +# --- base64url (WebAuthn wire codec, matches go-webauthn RawURLEncoding) ----- # +def b64url_decode(data: str) -> bytes: + return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) + + +def b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") diff --git a/e2e-playground/sdk-tests/python/pyproject.toml b/e2e-playground/sdk-tests/python/pyproject.toml new file mode 100644 index 000000000..3e5325d7c --- /dev/null +++ b/e2e-playground/sdk-tests/python/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "authorizer-sdk-e2e" +version = "0.0.0" +description = "Live e2e tests driving the e2e-playground stack through the published authorizer-py SDK." +requires-python = ">=3.9" +# Runtime deps live in requirements.txt (pinned). Kept in sync there so the +# Docker runner installs exactly what CI/devs install locally. +dependencies = [ + "authorizer-py==0.3.0rc3", + "pyotp==2.9.0", + "soft-webauthn==0.1.4", + "httpx==0.28.1", +] + +[project.optional-dependencies] +dev = [ + "pytest==8.3.4", + "ruff>=0.5", + "mypy>=1.8", +] + +# Lint/type config mirrors authorizer-py's own pyproject.toml so this suite is +# held to the same bar as the SDK it exercises. +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "W"] + +# python_version tracks the Docker runner (3.12); strict matches authorizer-py. +[tool.mypy] +python_version = "3.12" +strict = true +warn_unused_ignores = true + +# soft_webauthn / fido2 / pyotp ship no type stubs. +[[tool.mypy.overrides]] +module = ["soft_webauthn", "pyotp", "fido2", "fido2.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "live: integration tests that hit the live e2e-playground stack", +] diff --git a/e2e-playground/sdk-tests/python/requirements.txt b/e2e-playground/sdk-tests/python/requirements.txt new file mode 100644 index 000000000..a60c6b5d0 --- /dev/null +++ b/e2e-playground/sdk-tests/python/requirements.txt @@ -0,0 +1,24 @@ +# Live SDK e2e test suite dependencies. +# +# authorizer-py is pinned to the published PyPI release under test — NOT a +# local/editable install. The whole point of this suite is to exercise the +# real wire shapes the shipped SDK sends and parses. +authorizer-py==0.3.0rc3 + +# Test runner. +pytest==8.3.4 + +# TOTP code generation (RFC 6238) — the "authenticator app" side of the TOTP +# ceremony. Mirrors otpauth in the Playwright suite. +pyotp==2.9.0 + +# Software WebAuthn authenticator — completes a real registration/assertion +# ceremony from Python (the calling side has no browser CDP virtual +# authenticator). Pulls in fido2 + cryptography. +soft-webauthn==0.1.4 + +# Used directly for the raw-HTTP work the SDK deliberately does NOT wrap: +# polling the sms-sink / mailpit / webhook-sink mocks, and following the +# social-OAuth and magic-link redirect chains a browser would normally drive. +# (Also a transitive dependency of authorizer-py; pinned here for clarity.) +httpx==0.28.1 diff --git a/e2e-playground/sdk-tests/python/tests/test_mfa_routing.py b/e2e-playground/sdk-tests/python/tests/test_mfa_routing.py new file mode 100644 index 000000000..025b9a554 --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_mfa_routing.py @@ -0,0 +1,89 @@ +"""MFA enforcement routing matrix — driven through authorizer-py. + +Runs against the --enforce-mfa=true instances (authorizer-mfa-enforced and +authorizer-mfa-magic-link). Mirrors tests/mfa-routing-matrix.spec.ts. + +SDK coverage: + * password login under enforcement -> token withheld, routed to enrollment + * skip_mfa_setup rejected under enforcement (can't be routed around) + * magic_link_login initiation under enforcement + +The one non-SDK step is following the magic-link email URL: that link is an +HTTP redirect endpoint (GET /verify_email) whose whole purpose is to 307 a +*browser* onward, carrying the mfa_required/mfa_gate query params this test +asserts. It is browser-facing, not SDK surface — so it's driven with a raw, +redirect-suppressed httpx GET, exactly as the Playwright suite does. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import httpx +import pytest +from authorizer import ( + AuthorizerClient, + AuthorizerError, + LoginRequest, + MagicLinkLoginRequest, + SignUpRequest, + SkipMfaSetupRequest, +) + +import helpers + +pytestmark = pytest.mark.live + +ClientFactory = Callable[..., AuthorizerClient] + + +def test_password_login_no_factor_withholds_token(make_client: ClientFactory) -> None: + client = make_client(helpers.AUTHORIZER_MFA_ENFORCED_BASE_URL) + email = helpers.random_email("mfa-matrix") + client.signup( + SignUpRequest(email=email, password=helpers.PASSWORD, confirm_password=helpers.PASSWORD) + ) + + login = client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + # mfaGateBlockEnroll: EnforceMFA is absolute for an unenrolled user. + assert login.access_token is None + assert login.message == "Proceed to mfa setup" + assert login.should_show_totp_screen is True + + +def test_skip_mfa_setup_rejected_under_enforcement(make_client: ClientFactory) -> None: + client = make_client(helpers.AUTHORIZER_MFA_ENFORCED_BASE_URL) + email = helpers.random_email("mfa-matrix-skip") + client.signup( + SignUpRequest(email=email, password=helpers.PASSWORD, confirm_password=helpers.PASSWORD) + ) + # Login arms the mfa_session cookie on this client's jar; skip_mfa_setup + # replays it — proving enforcement can't be bypassed even with a genuine + # session, not just that login *offers* setup. + client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + + with pytest.raises(AuthorizerError) as exc: + client.skip_mfa_setup(SkipMfaSetupRequest(email=email)) + assert "cannot skip" in exc.value.message.lower() + + +def test_magic_link_login_routes_through_mfa_challenge(make_client: ClientFactory) -> None: + base = helpers.AUTHORIZER_MFA_MAGIC_LINK_BASE_URL + client = make_client(base) + email = helpers.random_email("mfa-matrix-magic") + + # SDK initiates the magic-link login. + client.magic_link_login(MagicLinkLoginRequest(email=email)) + + link = helpers.wait_for_magic_link(email) + + # Non-SDK: the email link is a browser-facing redirect endpoint. Under + # enforcement, VerifyEmailHandler routes an unenrolled user to the MFA + # gate (mfa_required=1&mfa_gate=offer) instead of minting a token. + with httpx.Client(follow_redirects=False, timeout=10.0) as raw: + resp = raw.get(link) + assert resp.status_code == 307 + location = resp.headers["location"] + assert "access_token=" not in location + assert "mfa_required=1" in location + assert "mfa_gate=offer" in location diff --git a/e2e-playground/sdk-tests/python/tests/test_otp_lockout.py b/e2e-playground/sdk-tests/python/tests/test_otp_lockout.py new file mode 100644 index 000000000..de7704113 --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_otp_lockout.py @@ -0,0 +1,125 @@ +"""OTP brute-force lockout (#698) — driven through authorizer-py. + +Mirrors tests/otp-lockout.spec.ts through the SDK: 5 failed verify_otp calls +inside the sliding window lock the user out with a distinct +"too many failed attempts" error (increment-then-check, so the 6th call — even +with a correct code — is the one rejected). A successful verification clears +the counter. The lock key is per-user, so it persists across logins. Fully +SDK-drivable: every attempt is an SDK verify_otp; the distinct error surfaces +as AuthorizerError.message. +""" + +from __future__ import annotations + +import pyotp +import pytest +from authorizer import ( + AuthorizerClient, + AuthorizerError, + LoginRequest, + OtpMfaSetupRequest, + SignUpRequest, + VerifyOTPRequest, +) + +import helpers + +pytestmark = pytest.mark.live + +LOCKOUT_MSG = "too many failed attempts" +INVALID_MSG = "invalid otp" + + +def _verify_error(client: AuthorizerClient, req: VerifyOTPRequest) -> str: + with pytest.raises(AuthorizerError) as exc: + client.verify_otp(req) + return exc.value.message.lower() + + +def _totp_setup(client: AuthorizerClient, email: str) -> pyotp.TOTP: + client.signup( + SignUpRequest(email=email, password=helpers.PASSWORD, confirm_password=helpers.PASSWORD) + ) + client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + secret = client.totp_mfa_setup(OtpMfaSetupRequest(email=email)).authenticator_secret + assert secret + return pyotp.TOTP(secret) + + +def _wrong_totp(totp: pyotp.TOTP) -> str: + return str((int(totp.now()) + 1) % 1_000_000).zfill(6) + + +def test_totp_lockout_after_five_failures(client: AuthorizerClient) -> None: + email = helpers.random_email("totp-lockout") + totp = _totp_setup(client, email) + wrong = _wrong_totp(totp) + + for _ in range(5): + assert INVALID_MSG in _verify_error( + client, VerifyOTPRequest(email=email, otp=wrong, is_totp=True) + ) + + # 6th attempt (still wrong) -> distinct lockout error, not invalid-otp. + assert LOCKOUT_MSG in _verify_error( + client, VerifyOTPRequest(email=email, otp=wrong, is_totp=True) + ) + + # The CORRECT code is also refused while locked — lockout blocks + # verification outright, it doesn't just keep rejecting wrong guesses. + assert LOCKOUT_MSG in _verify_error( + client, VerifyOTPRequest(email=email, otp=totp.now(), is_totp=True) + ) + + +def test_totp_success_resets_failure_counter(client: AuthorizerClient) -> None: + email = helpers.random_email("totp-reset") + totp = _totp_setup(client, email) + wrong = _wrong_totp(totp) + + # 3 failures, then succeed — under the 5-attempt budget. + for _ in range(3): + _verify_error(client, VerifyOTPRequest(email=email, otp=wrong, is_totp=True)) + ok = client.verify_otp(VerifyOTPRequest(email=email, otp=totp.now(), is_totp=True)) + assert ok.access_token + + # Log in again; the lock key is per-user, so if the reset hadn't happened + # the prior 3 + 5 new = 8 would lock out before the 5th. Assert all 5 are + # plain invalid-otp, never the lockout error. + client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + for _ in range(5): + assert LOCKOUT_MSG not in _verify_error( + client, VerifyOTPRequest(email=email, otp=wrong, is_totp=True) + ) + + +def test_sms_otp_lockout_correct_code_refused_while_locked(client: AuthorizerClient) -> None: + email = helpers.random_email("sms-lockout") + phone = helpers.random_phone() + client.signup( + SignUpRequest( + email=email, + phone_number=phone, + password=helpers.PASSWORD, + confirm_password=helpers.PASSWORD, + ) + ) + login = client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + assert login.should_offer_sms_otp_mfa_setup is True + + client.sms_otp_mfa_setup(OtpMfaSetupRequest(phone_number=phone)) + correct = helpers.extract_otp(helpers.wait_for_sms(phone)) + wrong = "ZZZZZZ" # outside the OTP charset window -> guaranteed mismatch + + for _ in range(5): + assert "otp" in _verify_error( + client, VerifyOTPRequest(phone_number=phone, otp=wrong, is_totp=False) + ) + + assert LOCKOUT_MSG in _verify_error( + client, VerifyOTPRequest(phone_number=phone, otp=wrong, is_totp=False) + ) + # Correct code refused while locked. + assert LOCKOUT_MSG in _verify_error( + client, VerifyOTPRequest(phone_number=phone, otp=correct, is_totp=False) + ) diff --git a/e2e-playground/sdk-tests/python/tests/test_saml_admin.py b/e2e-playground/sdk-tests/python/tests/test_saml_admin.py new file mode 100644 index 000000000..401f7885e --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_saml_admin.py @@ -0,0 +1,122 @@ +"""SAML — the SDK-drivable administration surface. + +What IS SDK surface (tested here through authorizer-py's admin client): + * Service Provider registry CRUD (create / read / list / update / delete) — + the IdP-side record of a downstream SP. + * IdP signing-key lifecycle (rotate / list / retire). + * SP metadata XML import (parse-only) into entity_id / acs_url / certificate. + +What is NOT SDK surface (and why): the actual SAML SSO ceremony — +SP-initiated AuthnRequest (HTTP-Redirect binding) -> Authorizer login UI -> +auto-submitted, XML-signed POSTed to the SP's ACS URL — is a +browser + HTML-form-POST protocol (crewjam WriteResponse writes a self-posting +form). There is no request/response method for it and there cannot be: the SDK +never sees the signed assertion, the browser carries it. That flow is covered +by tests/saml-idp.spec.ts / tests/saml-sp.spec.ts in the Playwright suite. + +Mirrors the admin-side setup those specs perform via _create_saml_service_provider. +""" + +from __future__ import annotations + +import pytest +from authorizer import ( + AuthorizerAdminClient, + CreateOrganizationRequest, + CreateSAMLServiceProviderRequest, + ImportSAMLSPMetadataRequest, + ListSAMLIDPKeysRequest, + ListSAMLServiceProvidersRequest, + RetireSAMLIDPKeyRequest, + RotateSAMLIDPCertRequest, + SAMLServiceProviderRequest, + UpdateSAMLServiceProviderRequest, +) +from authorizer.exceptions import AuthorizerError + +import helpers + +pytestmark = pytest.mark.live + +SP_METADATA_XML = """ + + + + +""" + + +def _new_org(admin: AuthorizerAdminClient, prefix: str) -> str: + org = admin.create_organization( + CreateOrganizationRequest(name=helpers.random_email(prefix).split("@")[0]) + ) + return org.id + + +def test_saml_service_provider_crud(admin: AuthorizerAdminClient) -> None: + org_id = _new_org(admin, "saml-sp") + entity_id = f"sp-{org_id}" + acs_url = "https://sp.example.test/acs" + + sp = admin.create_saml_service_provider( + CreateSAMLServiceProviderRequest( + org_id=org_id, name="fake-sp", entity_id=entity_id, acs_url=acs_url + ) + ) + assert sp.entity_id == entity_id + assert sp.acs_url == acs_url + assert sp.is_active is True + + fetched = admin.get_saml_service_provider(SAMLServiceProviderRequest(id=sp.id)) + assert fetched.id == sp.id + + listed = admin.list_saml_service_providers(ListSAMLServiceProvidersRequest(org_id=org_id)) + assert any(p.id == sp.id for p in listed.saml_service_providers) + + updated = admin.update_saml_service_provider( + UpdateSAMLServiceProviderRequest(id=sp.id, name="renamed-sp", is_active=False) + ) + assert updated.name == "renamed-sp" + assert updated.is_active is False + + deleted = admin.delete_saml_service_provider(SAMLServiceProviderRequest(id=sp.id)) + assert deleted.message + + +def test_saml_idp_signing_key_rotation_lifecycle(admin: AuthorizerAdminClient) -> None: + org_id = _new_org(admin, "saml-idp") + + key1 = admin.rotate_saml_idp_cert(RotateSAMLIDPCertRequest(org_id=org_id)) + assert key1.status == "current" + assert key1.cert_pem.startswith("-----BEGIN CERTIFICATE-----") + + # Second rotation demotes key1 to "active" and mints a new "current". + key2 = admin.rotate_saml_idp_cert(RotateSAMLIDPCertRequest(org_id=org_id)) + assert key2.status == "current" + assert key2.id != key1.id + + keys = {k.id: k.status for k in admin.list_saml_idp_keys(ListSAMLIDPKeysRequest(org_id=org_id))} + assert keys[key1.id] == "active" + assert keys[key2.id] == "current" + + # The current key cannot be retired; a superseded ("active") one can. + with pytest.raises(AuthorizerError) as exc: + admin.retire_saml_idp_key(RetireSAMLIDPKeyRequest(id=key2.id)) + assert "cannot retire the current signing key" in exc.value.message.lower() + + retired = admin.retire_saml_idp_key(RetireSAMLIDPKeyRequest(id=key1.id)) + assert retired.message + after_keys = admin.list_saml_idp_keys(ListSAMLIDPKeysRequest(org_id=org_id)) + after = {k.id: k.status for k in after_keys} + assert after[key1.id] == "retired" + + +def test_saml_sp_metadata_import_parses(admin: AuthorizerAdminClient) -> None: + result = admin.import_saml_sp_metadata( + ImportSAMLSPMetadataRequest(metadata_xml=SP_METADATA_XML) + ) + assert result.entity_id == "https://sp.example.com/saml/metadata" + assert result.acs_url == "https://sp.example.com/saml/acs" diff --git a/e2e-playground/sdk-tests/python/tests/test_scim.py b/e2e-playground/sdk-tests/python/tests/test_scim.py new file mode 100644 index 000000000..7c110d3a8 --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_scim.py @@ -0,0 +1,157 @@ +"""SCIM — SDK-drivable surface vs. the inbound provisioning protocol. + +Architecture (honest split): + + * SDK surface — the *administration* of SCIM: creating an org, provisioning + a SCIM endpoint + bearer token, reading it back, rotating the token, + deleting it; plus registering the SCIM lifecycle webhooks and reading + webhook delivery logs. All of that goes through authorizer-py here. + + * NOT SDK surface — the SCIM 2.0 protocol itself (POST/PATCH/DELETE + /scim/v2/Users). This is an INBOUND, third-party-facing REST spec (RFC + 7644) that an external IdP's provisioning connector speaks to Authorizer. + It is deliberately not wrapped by the public/admin SDK, so those calls are + made with a raw httpx client — transparently labeled, not dressed up as + "SDK". Their server-side effects are then verified back THROUGH the SDK + (admin users query, webhook logs). + +Mirrors tests/scim.spec.ts. +""" + +from __future__ import annotations + +import hashlib +import hmac + +import httpx +import pytest +from authorizer import ( + AddWebhookRequest, + AuthorizerAdminClient, + CreateOrganizationRequest, + CreateScimEndpointRequest, + ListUsersRequest, + ListWebhookLogRequest, + ScimEndpointRequest, +) + +import helpers + +pytestmark = pytest.mark.live + +SCIM_CORE = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + + +def _scim_client(token: str) -> httpx.Client: + return httpx.Client( + base_url=helpers.AUTHORIZER_BASE_URL, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/scim+json"}, + timeout=10.0, + ) + + +def test_scim_endpoint_lifecycle_via_sdk(admin: AuthorizerAdminClient) -> None: + """create / read / rotate-token / delete a SCIM endpoint — all SDK.""" + org = admin.create_organization( + CreateOrganizationRequest(name=helpers.random_email("scim-org").split("@")[0]) + ) + + created = admin.create_scim_endpoint(CreateScimEndpointRequest(org_id=org.id)) + assert created.token, "endpoint creation must return a bearer token" + assert created.scim_endpoint.org_id == org.id + assert created.scim_endpoint.enabled is True + + fetched = admin.get_scim_endpoint(ScimEndpointRequest(org_id=org.id)) + assert fetched.id == created.scim_endpoint.id + + rotated = admin.rotate_scim_token(ScimEndpointRequest(org_id=org.id)) + assert rotated.token and rotated.token != created.token, "rotation must mint a new token" + + deleted = admin.delete_scim_endpoint(ScimEndpointRequest(org_id=org.id)) + assert deleted.message + + +def test_scim_provisioning_webhooks_end_to_end(admin: AuthorizerAdminClient) -> None: + """SDK registers webhooks + reads logs; raw HTTP drives the SCIM protocol.""" + endpoint = f"{helpers.WEBHOOK_SINK_BASE_URL}/webhook" + for event in ("user.provisioned", "user.scim_updated", "user.deprovisioned"): + admin.add_webhook(AddWebhookRequest(event_name=event, endpoint=endpoint, enabled=True)) + + org = admin.create_organization( + CreateOrganizationRequest(name=helpers.random_email("scim-wh").split("@")[0]) + ) + token = admin.create_scim_endpoint(CreateScimEndpointRequest(org_id=org.id)).token + email = f"scim-webhook-user-{org.id}@example.com" + + # --- raw SCIM protocol (inbound REST, not SDK surface) --- # + with _scim_client(token) as scim: + create = scim.post( + "/scim/v2/Users", + json={ + "schemas": [SCIM_CORE], + "userName": email, + "name": {"givenName": "Katherine", "familyName": "Johnson"}, + "emails": [{"value": email, "primary": True}], + "active": True, + }, + ) + assert create.status_code == 201 + user_id = create.json()["id"] + assert create.json()["userName"] == email + + patch = scim.patch( + f"/scim/v2/Users/{user_id}", + json={ + "schemas": [SCIM_PATCH], + "Operations": [{"op": "replace", "path": "name.givenName", "value": "Kate"}], + }, + ) + assert patch.status_code == 200 + + delete = scim.delete(f"/scim/v2/Users/{user_id}") + assert delete.status_code == 204 + + # --- verify server-side effects back through the SDK / sinks --- # + # Webhook delivery (detached goroutine) — poll the sink, verify HMAC. + events = helpers.wait_for_webhook_events(email) + assert sorted(events) == ["user.deprovisioned", "user.provisioned", "user.scim_updated"] + for name, delivered in events.items(): + assert delivered["body"]["event_name"] == name + assert delivered["body"]["user"]["email"] == email + expected = hmac.new( + helpers.CLIENT_SECRET.encode(), delivered["rawBody"].encode(), hashlib.sha256 + ).hexdigest() + assert delivered["signature"] == expected, f"HMAC mismatch for {name}" + + # SDK reads the webhook delivery logs — must show successful (200) deliveries. + logs = admin.webhook_logs(ListWebhookLogRequest()) + assert any(log.http_status == 200 for log in logs.webhook_logs) + + +def test_scim_provisioned_user_visible_via_admin_sdk(admin: AuthorizerAdminClient) -> None: + """A user provisioned over raw SCIM is readable through the admin SDK.""" + org = admin.create_organization( + CreateOrganizationRequest(name=helpers.random_email("scim-admin").split("@")[0]) + ) + token = admin.create_scim_endpoint(CreateScimEndpointRequest(org_id=org.id)).token + email = f"scim-admin-{org.id}@example.com" + + with _scim_client(token) as scim: + create = scim.post( + "/scim/v2/Users", + json={ + "schemas": [SCIM_CORE], + "userName": email, + "name": {"givenName": "Ada", "familyName": "Lovelace"}, + "emails": [{"value": email, "primary": True}], + "active": True, + }, + ) + assert create.status_code == 201 + + users = admin.users(ListUsersRequest(query=email)).users + match = next((u for u in users if u.email == email), None) + assert match is not None, "SCIM-provisioned user must be visible to the admin SDK" + assert match.given_name == "Ada" + assert match.family_name == "Lovelace" diff --git a/e2e-playground/sdk-tests/python/tests/test_sms_otp.py b/e2e-playground/sdk-tests/python/tests/test_sms_otp.py new file mode 100644 index 000000000..b171fd57b --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_sms_otp.py @@ -0,0 +1,61 @@ +"""SMS-OTP MFA — enroll + verify, driven through authorizer-py. + +Reproduces tests/sms-otp.spec.ts: signup with a phone number, login (token +withheld, SMS-OTP offered), sms_otp_mfa_setup, read the delivered code from +the sms-sink mock, verify_otp. Fully SDK-drivable — the only non-SDK step is +reading the mock's captured SMS (a third-party delivery sink the SDK does not, +and should not, wrap). + +WebOTP note: "WebOTP" is not a distinct server flow — it is the browser +WebOTP autofill API reading this exact same SMS code out of the message and +pre-filling the OTP field (see tests/web-otp.spec.ts, which asserts the +`@` origin-bound autofill hint in the SMS body). The wire flow the SDK drives +is identical to SMS-OTP; the autofill itself is a browser-only UX layer with +no SDK surface, so it is intentionally not reproduced here. +""" + +from __future__ import annotations + +import pytest +from authorizer import ( + AuthorizerClient, + LoginRequest, + OtpMfaSetupRequest, + SignUpRequest, + VerifyOTPRequest, +) + +import helpers + +pytestmark = pytest.mark.live + + +def test_sms_otp_enroll_and_complete_login(client: AuthorizerClient) -> None: + email = helpers.random_email("sms-otp") + phone = helpers.random_phone() + + client.signup( + SignUpRequest( + email=email, + phone_number=phone, + password=helpers.PASSWORD, + confirm_password=helpers.PASSWORD, + ) + ) + + login = client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + # SMS OTP enabled + test SMS webhook configured -> setup offered, token withheld. + assert login.should_offer_sms_otp_mfa_setup is True + assert login.access_token is None + + # Resolved via the mfa_session cookie (same httpx client), keyed by phone. + client.sms_otp_mfa_setup(OtpMfaSetupRequest(phone_number=phone)) + + code = helpers.extract_otp(helpers.wait_for_sms(phone)) + token = client.verify_otp( + VerifyOTPRequest(phone_number=phone, otp=code, is_totp=False) + ) + + assert token.access_token, "a valid SMS OTP must complete the challenge" + assert token.user is not None + assert token.user.email == email diff --git a/e2e-playground/sdk-tests/python/tests/test_social_oauth.py b/e2e-playground/sdk-tests/python/tests/test_social_oauth.py new file mode 100644 index 000000000..97e55e8fc --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_social_oauth.py @@ -0,0 +1,236 @@ +"""Social OAuth (10 providers) — precise SDK vs. raw-HTTP split. + +What the SDK does NOT wrap: login *initiation*. A social login is a browser +redirect — authorizer-react does ``window.location.href = /oauth_login/:provider`` +— so there is no SDK method that starts it. The server-side ceremony +(``/oauth_login/:provider`` -> mock-oauth ``/authorize`` -> ``/token`` -> +``/userinfo`` -> ``/oauth_callback/:provider``) is therefore driven here with a +RAW httpx client following the redirect chain (no browser needed — mock-oauth +auto-approves and 302s straight back, the same technique +tests/oidc-provider.spec.ts's PKCE test and social/helpers.ts's consent-denied +path use). This exercises real server-side callback validation, code exchange, +userinfo fetch and user creation. + +What the SDK DOES do here (its genuine, if minimal, role): + * admin ``users`` query — verify the provider's profile claims were mapped + onto a real stored user (given/family/nickname + signup_methods). This is + the assertion that would catch a wire-shape drift in the admin users query. + * ``skip_mfa_setup`` + ``validate_jwt_token`` + ``get_profile`` — for one + representative provider, complete the withheld-MFA login and validate the + resulting token THROUGH the SDK, proving the SDK parses a token minted for + a social-originated session. + +A brand-new social user lands on the withheld MFA-setup redirect +(``mfa_required=1&mfa_gate=offer``) — the callback issuing that redirect is +itself proof the whole exchange succeeded server-side. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from authorizer import ( + AuthorizerAdminClient, + ListUsersRequest, + SkipMfaSetupRequest, + TokenType, + ValidateJWTTokenRequest, +) + +import helpers + +pytestmark = pytest.mark.live + +BASE = helpers.AUTHORIZER_BASE_URL + + +@dataclass +class Case: + provider: str + profile: dict[str, Any] + lookup_kind: str # "email" | "nickname" + lookup_value: str + expected_given: str + expected_family: str | None + email: str | None + + +def _case(provider: str) -> Case: + """Build a per-run provider case matching mock-oauth's expected profile shape.""" + uid = helpers.random_email(provider).split("@")[0] # unique token per run + email = f"{provider}-{uid}@example.com" + p: dict[str, Any] + if provider == "google": + p = {"sub": f"google-{uid}", "email": email, "given_name": "Ada", "family_name": "Lovelace"} + return Case(provider, p, "email", email, "Ada", "Lovelace", email) + if provider == "github": + p = {"name": "Grace Hopper", "email": email, "avatar_url": "https://example.com/a.png"} + return Case(provider, p, "email", email, "Grace", "Hopper", email) + if provider == "facebook": + p = { + "first_name": "Katherine", + "last_name": "Johnson", + "email": email, + "picture": {"data": {"url": "https://example.com/a.png"}}, + } + return Case(provider, p, "email", email, "Katherine", "Johnson", email) + if provider == "linkedin": + p = {"localizedFirstName": "Margaret", "localizedLastName": "Hamilton", "email": email} + return Case(provider, p, "email", email, "Margaret", "Hamilton", email) + if provider == "apple": + p = {"sub": f"apple-{uid}", "email": email, "given_name": "Alan", "family_name": "Turing"} + return Case(provider, p, "email", email, "Alan", "Turing", email) + if provider == "discord": + p = {"id": f"discord-{uid}", "username": "gracehopper", "avatar": "abc", "email": email} + return Case(provider, p, "email", email, "gracehopper", None, email) + if provider == "microsoft": + p = { + "sub": f"microsoft-{uid}", + "email": email, + "given_name": "Katherine", + "family_name": "Johnson", + } + return Case(provider, p, "email", email, "Katherine", "Johnson", email) + if provider == "twitch": + p = {"sub": f"twitch-{uid}", "email": email, "given_name": "Sally", "family_name": "Ride"} + return Case(provider, p, "email", email, "Sally", "Ride", email) + if provider == "roblox": + p = { + "name": "Ada Lovelace", + "nickname": "ada", + "picture": "https://example.com/a.png", + "email": email, + } + return Case(provider, p, "email", email, "Ada ", "Lovelace", email) + if provider == "twitter": + # Twitter/X never returns an email (real API parity) -> nickname lookup. + nickname = f"ada-{uid}" + p = { + "data": { + "id": f"twitter-{uid}", + "name": "Ada Lovelace", + "username": nickname, + "profile_image_url": "https://example.com/a.png", + } + } + return Case(provider, p, "nickname", nickname, "Ada ", "Lovelace", None) + raise ValueError(provider) + + +def _collect_params(resp: httpx.Response) -> dict[str, str]: + """Merge query params from every redirect Location and the final URL.""" + params: dict[str, str] = {} + for hop in [*resp.history, resp]: + for source in (hop.headers.get("location", ""), str(hop.url)): + for k, v in parse_qs(urlparse(source).query).items(): + if v: + params[k] = v[0] + return params + + +def _run_oauth_chain(c: httpx.Client, provider: str) -> dict[str, str]: + """Drive the full server-side social login chain; return the final query params.""" + redirect_uri = f"{BASE}/app" + resp = c.get(f"{BASE}/oauth_login/{provider}", params={"redirect_uri": redirect_uri}) + assert resp.status_code == 200, f"chain did not land on the app page: {resp.status_code}" + return _collect_params(resp) + + +@pytest.mark.parametrize( + "provider", + [ + "google", + "github", + "facebook", + "linkedin", + "apple", + "discord", + "twitter", + "microsoft", + "twitch", + "roblox", + ], +) +def test_social_login_creates_mapped_user(provider: str, admin: AuthorizerAdminClient) -> None: + case = _case(provider) + helpers.configure_mock_profile(provider, case.profile) + + with httpx.Client(follow_redirects=True, timeout=20.0) as c: + params = _run_oauth_chain(c, provider) + + # New user, MFA on by default -> callback withholds the token and routes to + # the MFA-setup offer. That redirect existing at all proves the code + # exchange + userinfo fetch + user creation all succeeded server-side. + assert params.get("mfa_required") == "1" + assert "access_token" not in params + + # SDK role: the provider's claims landed on a real stored user. + users = admin.users(ListUsersRequest(query=case.lookup_value)).users + if case.lookup_kind == "email": + user = next((u for u in users if u.email == case.lookup_value), None) + else: + user = next((u for u in users if u.nickname == case.lookup_value), None) + assert user is not None, f"{provider}: user not created / not found via admin SDK" + assert user.given_name == case.expected_given + if case.expected_family is not None: + assert user.family_name == case.expected_family + assert user.signup_methods is not None and provider in user.signup_methods + + +def test_social_login_token_validates_through_sdk( + admin: AuthorizerAdminClient, make_client: Any +) -> None: + """Complete a withheld social login via the SDK and validate the token via the SDK.""" + case = _case("google") + helpers.configure_mock_profile("google", case.profile) + + with httpx.Client(follow_redirects=True, timeout=20.0) as c: + params = _run_oauth_chain(c, "google") + assert params.get("mfa_required") == "1" + # The callback set the mfa_session cookie on this jar; hand it to the SDK. + cookie_header = "; ".join(f"{k}={v}" for k, v in c.cookies.items()) + + sdk = make_client() + token = sdk.skip_mfa_setup( + SkipMfaSetupRequest(email=case.email), headers={"Cookie": cookie_header} + ) + assert token.access_token, "skip_mfa_setup must complete the social login" + + validated = sdk.validate_jwt_token( + ValidateJWTTokenRequest(token=token.access_token, token_type=TokenType.ACCESS_TOKEN) + ) + assert validated.is_valid is True + + profile = sdk.get_profile(headers={"Authorization": f"Bearer {token.access_token}"}) + assert profile.email == case.email + assert profile.signup_methods is not None and "google" in profile.signup_methods + + +def test_social_consent_denied_rejected_and_state_single_use() -> None: + """Raw-HTTP negative path: denied consent -> 400, and the state is single-use.""" + provider = "google" + with httpx.Client(follow_redirects=False, timeout=10.0) as c: + login = c.get( + f"{BASE}/oauth_login/{provider}", params={"redirect_uri": f"{BASE}/app"} + ) + assert login.status_code == 307 + state = parse_qs(urlparse(login.headers["location"]).query)["state"][0] + + denied = c.get( + f"{BASE}/oauth_callback/{provider}", + params={"error": "access_denied", "state": state}, + ) + assert denied.status_code == 400 + assert denied.json()["error"] == "invalid oauth code" + + # State is single-use — a replay is rejected differently (unknown state). + replay = c.get( + f"{BASE}/oauth_callback/{provider}", + params={"error": "access_denied", "state": state}, + ) + assert replay.status_code == 400 + assert replay.json()["error"] == "invalid oauth state" diff --git a/e2e-playground/sdk-tests/python/tests/test_totp.py b/e2e-playground/sdk-tests/python/tests/test_totp.py new file mode 100644 index 000000000..776816c1f --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_totp.py @@ -0,0 +1,66 @@ +"""TOTP MFA — enroll + verify, driven entirely through authorizer-py. + +Reproduces tests/totp.spec.ts's flow, but every server interaction goes +through the SDK's own methods (signup / login / totp_mfa_setup / verify_otp) +instead of raw GraphQL. Fully SDK-drivable: these are plain request/response +calls with no browser ceremony. +""" + +from __future__ import annotations + +import time + +import pyotp +import pytest +from authorizer import ( + AuthorizerClient, + AuthorizerError, + LoginRequest, + OtpMfaSetupRequest, + SignUpRequest, + VerifyOTPRequest, +) + +import helpers + +pytestmark = pytest.mark.live + + +def _reach_totp_setup(client: AuthorizerClient, email: str) -> str: + """signup -> login (token withheld, TOTP screen) -> totp_mfa_setup; return secret.""" + client.signup( + SignUpRequest(email=email, password=helpers.PASSWORD, confirm_password=helpers.PASSWORD) + ) + login = client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + # Brand-new user, MFA on by default, not yet enrolled -> mfaGateOfferAll: + # token withheld, should_show_totp_screen true. + assert login.should_show_totp_screen is True + assert login.access_token is None + + setup = client.totp_mfa_setup(OtpMfaSetupRequest(email=email)) + assert setup.authenticator_secret + return setup.authenticator_secret + + +def test_totp_enroll_and_complete_login(client: AuthorizerClient) -> None: + email = helpers.random_email("totp") + secret = _reach_totp_setup(client, email) + + code = pyotp.TOTP(secret).now() + token = client.verify_otp(VerifyOTPRequest(email=email, otp=code, is_totp=True)) + + assert token.access_token, "a valid TOTP code must complete the challenge" + assert token.user is not None + assert token.user.email == email + + +def test_totp_expired_code_rejected(client: AuthorizerClient) -> None: + email = helpers.random_email("totp-expired") + secret = _reach_totp_setup(client, email) + + # A code from 10 minutes ago is well outside the validator's ~90s skew. + stale = pyotp.TOTP(secret).at(int(time.time()) - 600) + + with pytest.raises(AuthorizerError) as exc: + client.verify_otp(VerifyOTPRequest(email=email, otp=stale, is_totp=True)) + assert "invalid otp" in exc.value.message.lower() diff --git a/e2e-playground/sdk-tests/python/tests/test_webauthn.py b/e2e-playground/sdk-tests/python/tests/test_webauthn.py new file mode 100644 index 000000000..f9a87ac46 --- /dev/null +++ b/e2e-playground/sdk-tests/python/tests/test_webauthn.py @@ -0,0 +1,231 @@ +"""WebAuthn / Passkey — FULL ceremony through authorizer-py + a software authenticator. + +Unlike the Playwright suite (which uses Chrome's CDP virtual authenticator), +Python has no built-in authenticator on the calling side. We use the +``soft-webauthn`` package's ``SoftWebauthnDevice`` — a real software FIDO2 +authenticator — to actually create the attestation and sign the assertion. +Every server interaction goes through the SDK's own webauthn_* methods; the +only glue code is the WebAuthn wire codec (base64url <-> bytes) a browser would +otherwise handle, which is exactly the layer most prone to SDK/server drift and +therefore the most valuable to exercise. + +Runs against the dedicated authorizer-webauthn instance: go-webauthn's RPID +validation requires a dotted hostname, so the RP origin/ID must be +``webauthn.e2e-playground.test`` (reachable in-network via that alias). The +soft authenticator signs clientDataJSON against that same origin. +""" + +from __future__ import annotations + +import json +from base64 import urlsafe_b64encode +from struct import pack +from typing import Any, Callable + +import pytest +from authorizer import ( + AuthorizerClient, + LoginRequest, + SignUpRequest, + SkipMfaSetupRequest, + TokenType, + ValidateJWTTokenRequest, + WebauthnLoginVerifyRequest, + WebauthnRegistrationVerifyRequest, +) +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from fido2 import cbor +from fido2.cose import ES256 +from fido2.utils import sha256 +from soft_webauthn import SoftWebauthnDevice + +import helpers + +pytestmark = pytest.mark.live + +BASE = helpers.AUTHORIZER_WEBAUTHN_BASE_URL +ORIGIN = BASE # RP origin == the instance's --url + + +class UvSoftWebauthnDevice(SoftWebauthnDevice): # type: ignore[misc] # untyped base + """soft-webauthn, but with the User-Verified (UV) flag set. + + Authorizer's RP is configured ``UserVerification: VerificationRequired`` + (internal/authenticators/webauthn/webauthn.go newRP), so go-webauthn + rejects any attestation/assertion whose authenticator-data UV flag is + unset. Stock ``SoftWebauthnDevice`` hardcodes UP-only flags (0x41 create / + 0x01 get). This overrides both to also set UV (0x45 / 0x05) — the software + equivalent of the Playwright CDP authenticator's ``isUserVerified: true``. + The bodies are otherwise identical to the upstream methods. + """ + + def create(self, options: dict[str, Any], origin: str) -> dict[str, Any]: + if {"alg": -7, "type": "public-key"} not in options["publicKey"]["pubKeyCredParams"]: + raise ValueError("Requested pubKeyCredParams does not contain supported type") + self.cred_init(options["publicKey"]["rp"]["id"], options["publicKey"]["user"]["id"]) + client_data = { + "type": "webauthn.create", + "challenge": urlsafe_b64encode(options["publicKey"]["challenge"]) + .decode("ascii") + .rstrip("="), + "origin": origin, + } + rp_id_hash = sha256(self.rp_id.encode("ascii")) + flags = b"\x45" # UP | UV | AT + pub = ES256.from_cryptography_key(self.private_key.public_key()) # type: ignore[no-untyped-call] + cose_key = cbor.encode(pub) + auth_data = ( + rp_id_hash + + flags + + pack(">I", self.sign_count) + + self.aaguid + + pack(">H", len(self.credential_id)) + + self.credential_id + + cose_key + ) + attestation_object = {"authData": auth_data, "fmt": "none", "attStmt": {}} + return { + "id": urlsafe_b64encode(self.credential_id), + "rawId": self.credential_id, + "response": { + "clientDataJSON": json.dumps(client_data).encode("utf-8"), + "attestationObject": cbor.encode(attestation_object), + }, + "type": "public-key", + } + + def get(self, options: dict[str, Any], origin: str) -> dict[str, Any]: + if self.rp_id != options["publicKey"]["rpId"]: + raise ValueError("Requested rpID does not match current credential") + self.sign_count += 1 + client_data = json.dumps( + { + "type": "webauthn.get", + "challenge": urlsafe_b64encode(options["publicKey"]["challenge"]) + .decode("ascii") + .rstrip("="), + "origin": origin, + } + ).encode("utf-8") + rp_id_hash = sha256(self.rp_id.encode("ascii")) + flags = b"\x05" # UP | UV + authenticator_data = rp_id_hash + flags + pack(">I", self.sign_count) + signature = self.private_key.sign( + authenticator_data + sha256(client_data), ec.ECDSA(hashes.SHA256()) + ) + return { + "id": urlsafe_b64encode(self.credential_id), + "rawId": self.credential_id, + "response": { + "authenticatorData": authenticator_data, + "clientDataJSON": client_data, + "signature": signature, + "userHandle": self.user_handle, + }, + "type": "public-key", + } + + +def _prepare_creation_options(options_json: str) -> dict[str, Any]: + """Server creation options (JSON, base64url) -> soft_webauthn input (bytes).""" + pk = json.loads(options_json) + pk["challenge"] = helpers.b64url_decode(pk["challenge"]) + pk["user"]["id"] = helpers.b64url_decode(pk["user"]["id"]) + for cred in pk.get("excludeCredentials", []) or []: + cred["id"] = helpers.b64url_decode(cred["id"]) + # soft_webauthn only produces 'none' attestation; drop any other request. + pk.pop("attestation", None) + return {"publicKey": pk} + + +def _prepare_request_options(options_json: str) -> dict[str, Any]: + """Server request (login) options (JSON, base64url) -> soft_webauthn input (bytes).""" + pk = json.loads(options_json) + pk["challenge"] = helpers.b64url_decode(pk["challenge"]) + for cred in pk.get("allowCredentials", []) or []: + cred["id"] = helpers.b64url_decode(cred["id"]) + return {"publicKey": pk} + + +def _attestation_to_json(att: dict[str, Any]) -> str: + return json.dumps( + { + "id": helpers.b64url_encode(att["rawId"]), + "rawId": helpers.b64url_encode(att["rawId"]), + "type": "public-key", + "response": { + "clientDataJSON": helpers.b64url_encode(att["response"]["clientDataJSON"]), + "attestationObject": helpers.b64url_encode(att["response"]["attestationObject"]), + }, + } + ) + + +def _assertion_to_json(assertion: dict[str, Any]) -> str: + r = assertion["response"] + return json.dumps( + { + "id": helpers.b64url_encode(assertion["rawId"]), + "rawId": helpers.b64url_encode(assertion["rawId"]), + "type": "public-key", + "response": { + "authenticatorData": helpers.b64url_encode(r["authenticatorData"]), + "clientDataJSON": helpers.b64url_encode(r["clientDataJSON"]), + "signature": helpers.b64url_encode(r["signature"]), + "userHandle": helpers.b64url_encode(r["userHandle"]), + }, + } + ) + + +def _authenticated_token(client: AuthorizerClient, email: str) -> str: + """signup -> login (withheld) -> skip MFA -> a real bearer access token.""" + client.signup( + SignUpRequest(email=email, password=helpers.PASSWORD, confirm_password=helpers.PASSWORD) + ) + client.login(LoginRequest(email=email, password=helpers.PASSWORD)) + token = client.skip_mfa_setup(SkipMfaSetupRequest(email=email)) + assert token.access_token + return token.access_token + + +def test_webauthn_full_registration_and_passwordless_login( + make_client: Callable[..., AuthorizerClient], +) -> None: + email = helpers.random_email("webauthn") + device = UvSoftWebauthnDevice() + + # --- register a passkey (settings-page path: bearer-token authenticated) --- # + reg_client = make_client(BASE) + access_token = _authenticated_token(reg_client, email) + auth = {"Authorization": f"Bearer {access_token}"} + + reg_options = reg_client.webauthn_registration_options(headers=auth) + attestation = device.create(_prepare_creation_options(reg_options.options), ORIGIN) + reg_client.webauthn_registration_verify( + WebauthnRegistrationVerifyRequest( + credential=_attestation_to_json(attestation), name="e2e-passkey" + ), + headers=auth, + ) + + creds = reg_client.webauthn_credentials(headers=auth) + assert len(creds) == 1 + assert creds[0].name == "e2e-passkey" + + # --- log back in with ONLY the passkey (usernameless / discoverable) --- # + login_client = make_client(BASE) + login_options = login_client.webauthn_login_options() # no email -> discoverable + assertion = device.get(_prepare_request_options(login_options.options), ORIGIN) + token = login_client.webauthn_login_verify( + WebauthnLoginVerifyRequest(credential=_assertion_to_json(assertion)) + ) + + assert token.access_token, "passkey-only login must issue a token" + assert token.user is not None and token.user.email == email + + validated = login_client.validate_jwt_token( + ValidateJWTTokenRequest(token=token.access_token, token_type=TokenType.ACCESS_TOKEN) + ) + assert validated.is_valid is True diff --git a/e2e-playground/tests/dashboard-org-domains.spec.ts b/e2e-playground/tests/dashboard-org-domains.spec.ts new file mode 100644 index 000000000..0ec92f84c --- /dev/null +++ b/e2e-playground/tests/dashboard-org-domains.spec.ts @@ -0,0 +1,124 @@ +// e2e-playground/tests/dashboard-org-domains.spec.ts +import { test, expect, Page } from '@playwright/test'; +import crypto from 'node:crypto'; +import { createOrg } from '../fixtures/adminClient'; + +// PR #707 added web/dashboard/src/components/OrgDomains.tsx - the admin UI +// for managing an org's verified email domains (home-realm-discovery +// routing). Prior specs (magic-link, mfa-routing-matrix, etc.) only ever +// seed verified domains via the `_add_verified_org_domain` GraphQL bypass +// (fixtures/adminClient.ts addVerifiedDomain) for test setup - this is the +// first spec exercising the actual dashboard UI. + +const ADMIN_SECRET = process.env.AUTHORIZER_ADMIN_SECRET || 'e2e-admin-secret'; + +// randomDomain returns a syntactically-valid, never-colliding, never-resolvable +// test domain. Hyphens are stripped from the UUID and a letter prefix added so +// the label can never violate IDNA's no-leading/trailing-hyphen or +// no-double-hyphen rules (internal/service/org_domain_util.go normalizeDomain +// calls idna.Lookup.ToASCII). "example.com" is a safe, non-registrable-looking +// suffix that is neither a public suffix on its own (so guardVerifiableDomain's +// public-suffix guard doesn't reject it) nor in the consumer-domain blocklist. +function randomDomain(): string { + return `d${crypto.randomUUID().replace(/-/g, '')}.example.com`; +} + +// loginAsAdmin drives the real dashboard login form (web/dashboard/src/pages/Auth.tsx). +// isOnboardingCompleted is hardcoded true server-side (internal/http_handlers/dashboard.go), +// so hasAdminSecret() always resolves to the "Login" (not "Sign up") branch - +// confirmed live below, not just by reading the source. +async function loginAsAdmin(page: Page, baseURL: string) { + await page.goto(`${baseURL}/dashboard/`); + await page.locator('#admin-secret').fill(ADMIN_SECRET); + await page.getByRole('button', { name: 'Login' }).click(); + // Auth.tsx navigates to '/' (-> /dashboard/ with the router's basename) on + // successful login; wait for that instead of a fixed sleep. + await page.waitForURL((url) => /^\/dashboard\/?$/.test(url.pathname), { timeout: 10_000 }); + + // Every other spec in this suite only ever does client-side routing after + // login (no full navigation), so this race has never surfaced before: in + // this headless/Docker Chromium, the login response's Set-Cookie can land + // in the browser's cookie jar a beat after the fetch's JS promise (and + // waitForURL) resolves. Every test in this file immediately follows login + // with a real page.goto() to a deep dashboard route, which starts a fresh + // network request - if that request races the cookie-jar write, it goes + // out unauthenticated and the fresh page mounts logged out. Wait for the + // cookie to actually be in the jar before navigating away. + await expect + .poll(async () => (await page.context().cookies()).some((c) => c.name === 'authorizer-admin'), { + timeout: 5_000, + }) + .toBe(true); +} + +test.describe('Dashboard — Org verified domains', () => { + test('add a domain without DNS verification (super-admin), then delete it', async ({ page, baseURL }) => { + const org = await createOrg(`org-domains-${crypto.randomUUID()}`); + const domain = randomDomain(); + + await loginAsAdmin(page, baseURL!); + await page.goto(`${baseURL}/dashboard/identity/organizations/${org.id}`); + + await page.getByRole('button', { name: 'Add Domain' }).click(); + // Domain-entry step is the dialog's immediate state (challenge === null + // initially) - #org-domain-input should already be visible, no + // intermediate step. + const domainInput = page.locator('#org-domain-input'); + await expect(domainInput).toBeVisible(); + await domainInput.fill(domain); + await page.getByRole('button', { name: 'Add without DNS verification (super-admin)' }).click(); + + // Dialog closes and the table re-fetches on success. + await expect(page.getByRole('dialog')).toBeHidden(); + const row = page.getByRole('row', { name: domain }); + await expect(row).toBeVisible(); + // AddVerifiedOrgDomain marks the row verified immediately - the badge + // renders a formatted verified_at date (dayjs "MMM D, YYYY"), not a dash. + await expect(row.getByText(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/)).toBeVisible(); + + await row.getByRole('button', { name: 'Delete' }).click(); + const confirmDialog = page.getByRole('dialog'); + await expect(confirmDialog.getByText('Delete Verified Domain')).toBeVisible(); + await expect(confirmDialog.getByText(domain)).toBeVisible(); + await confirmDialog.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('row', { name: domain })).toHaveCount(0); + await expect(page.getByText('No verified domains yet.')).toBeVisible(); + }); + + test('DNS challenge flow shows the TXT record and a not-verified hint on failed verification', async ({ + page, + baseURL, + }) => { + const org = await createOrg(`org-domains-dns-${crypto.randomUUID()}`); + const domain = randomDomain(); + + await loginAsAdmin(page, baseURL!); + await page.goto(`${baseURL}/dashboard/identity/organizations/${org.id}`); + + await page.getByRole('button', { name: 'Add Domain' }).click(); + await page.locator('#org-domain-input').fill(domain); + await page.getByRole('button', { name: 'Request DNS Challenge' }).click(); + + // Challenge step: the TXT record to publish is shown via two CopyField + // rows (internal/service/org_domain_dns.go challengeRecordName/Value). + await expect(page.getByText('Record name')).toBeVisible(); + await expect(page.getByText('Record value')).toBeVisible(); + await expect(page.getByText(`_authorizer-challenge.${domain}`)).toBeVisible(); + await expect(page.getByText(/^authorizer-domain-verification=/)).toBeVisible(); + + // The test domain has no real DNS records, so verification must fail with + // the retryable "DNS not propagated yet" hint (VerifyOrgDomain returns + // "dns verification failed: ..." for both NXDOMAIN and a missing/mismatched + // TXT record - either way OrgDomains.tsx surfaces this exact hint text). + await page.getByRole('button', { name: "I've published this record — Verify" }).click(); + await expect( + page.getByText( + 'Not verified yet. DNS changes can take a few minutes to propagate — leave the record in place and try again shortly.' + ) + ).toBeVisible({ timeout: 10_000 }); + + // The challenge stays on screen (not discarded) so the tenant can retry. + await expect(page.getByText(`_authorizer-challenge.${domain}`)).toBeVisible(); + }); +}); diff --git a/e2e-playground/tests/magic-link.spec.ts b/e2e-playground/tests/magic-link.spec.ts new file mode 100644 index 000000000..21c2319e7 --- /dev/null +++ b/e2e-playground/tests/magic-link.spec.ts @@ -0,0 +1,93 @@ +// e2e-playground/tests/magic-link.spec.ts +import { test, expect } from '@playwright/test'; +import crypto from 'node:crypto'; +import { graphql } from '../fixtures/graphqlRequest'; + +// This spec runs against authorizer-magic-link (docker-compose.yml, `magic-link` +// project in playwright.config.ts) - the only instance with +// --enable-magic-link-login=true AND --enable-email-verification=true (both +// required for magic_link_login to actually enqueue a verification email - +// internal/service/magic_link_login.go gates the whole +// verification-request+SendEmail block on Config.EnableEmailVerification) and +// --disable-mfa=true (keeps this a clean primary-login-method test - see that +// service's comment in docker-compose.yml for why MFA would otherwise let the +// same link be clicked more than once without failing). +const MAILPIT_BASE = process.env.MAILPIT_BASE_URL || 'http://localhost:8025'; + +function randomEmail() { + return `magic-link-${crypto.randomUUID()}@example.com`; +} + +// waitForMagicLinkURL polls Mailpit's real HTTP API for the verification link +// mailed to `email`. Confirmed live against a running axllent/mailpit +// container (not the plan's untested guess): GET /api/v1/messages returns +// {messages: [{ID, To: [{Address}], ...}]} - a summary list, body NOT +// included - so the link itself only shows up via GET /api/v1/message/:id, +// which returns the full {Text, HTML, ...}. The link is pulled from Text, not +// HTML - Mailpit's HTML body entity-encodes the URL's "&" as "&", which +// would otherwise need decoding before the URL is directly usable. +async function waitForMagicLinkURL(request: import('@playwright/test').APIRequestContext, email: string): Promise { + for (let i = 0; i < 40; i++) { + const res = await request.get(`${MAILPIT_BASE}/api/v1/messages`); + const { messages } = await res.json(); + const msg = messages.find((m: { To: { Address: string }[] }) => m.To.some((t) => t.Address === email)); + if (msg) { + const detail = await (await request.get(`${MAILPIT_BASE}/api/v1/message/${msg.ID}`)).json(); + const match = /(https?:\/\/\S+verify_email\?\S+)/.exec(detail.Text); + if (match) return match[1].replace(/\)$/, ''); + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no magic link email received for ${email} within 10s`); +} + +const magicLinkLoginMutation = ` + mutation ($params: MagicLinkLoginRequest!) { + magic_link_login(params: $params) { message } + } +`; + +test.describe('Magic link login', () => { + test('request link, click it, land on the dashboard with a session', async ({ page, request, baseURL }) => { + const email = randomEmail(); + await graphql(request, baseURL!, magicLinkLoginMutation, { params: { email } }); + + const link = await waitForMagicLinkURL(request, email); + await page.goto(link); + + // Real rendered dashboard text (web/app/src/pages/dashboard.tsx: "Signed + // in as {email}") - the same established + // session-established assertion tests/sms-otp.spec.ts and + // tests/social/helpers.ts use, stronger than checking for the absence of + // an "error" string. + await expect(page.getByText('Signed in as')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('link', { name: email })).toBeVisible(); + }); + + test('link is single-use: replaying it after a successful click is rejected', async ({ request, baseURL }) => { + const email = randomEmail(); + await graphql(request, baseURL!, magicLinkLoginMutation, { params: { email } }); + + const link = await waitForMagicLinkURL(request, email); + + // GET /verify_email (internal/http_handlers/verify_email.go) always + // redirects (307 Temporary Redirect) - even on error - instead of + // returning a >=400 status, so a browser landing on the link never sees + // a raw JSON error page; the error is carried back as a query param on + // the /app redirect instead. Confirmed live against this stack: a + // genuine first click's Location carries access_token=...; replaying the + // exact same link carries error=... instead, with the SAME 307 status + // both times. maxRedirects: 0 stops Playwright from auto-following so + // the Location header itself can be inspected. + const first = await request.get(link, { maxRedirects: 0 }); + expect(first.status()).toBe(307); + const firstLocation = first.headers()['location']; + expect(firstLocation).toContain('access_token='); + + const second = await request.get(link, { maxRedirects: 0 }); + expect(second.status()).toBe(307); + const secondLocation = second.headers()['location']; + expect(secondLocation).not.toContain('access_token='); + expect(secondLocation).toContain('error='); + }); +}); diff --git a/e2e-playground/tests/mfa-routing-matrix.spec.ts b/e2e-playground/tests/mfa-routing-matrix.spec.ts new file mode 100644 index 000000000..c151dd6a6 --- /dev/null +++ b/e2e-playground/tests/mfa-routing-matrix.spec.ts @@ -0,0 +1,147 @@ +// e2e-playground/tests/mfa-routing-matrix.spec.ts +import { test, expect, APIRequestContext } from '@playwright/test'; +import crypto from 'node:crypto'; +import { graphql } from '../fixtures/graphqlRequest'; + +// This spec runs against authorizer-mfa-enforced (docker-compose.yml, `mfa-on` +// project in playwright.config.ts) - the only instance with --enforce-mfa=true. +// +// EnforceMFA turned out NOT to be a runtime-toggleable admin setting, despite +// fixtures/adminClient.ts exporting a setEnforceMFA helper that calls the +// `_update_env` mutation: verified live against a running instance that this +// mutation is a stub which always errors - +// "deprecated. please configure env via cli args" +// (internal/graph/schema.resolvers.go's UpdateEnv resolver) - consistent with +// this repo's v2 CLI-flags-only config model (AGENTS.md: "no .env or OS env +// vars"). So, same as WebAuthn/magic-link/SSO before it, --enforce-mfa needs +// its own dedicated static-CLI-flag instance rather than a beforeAll/afterAll +// toggle against the shared `authorizer` service - setEnforceMFA is not used +// here at all. +const PASSWORD = 'Str0ngPassw0rd!'; + +// Dedicated second instance (also docker-compose.yml) for the one test below +// needing BOTH --enforce-mfa=true and magic-link login - see that service's +// comment in docker-compose.yml for why it can't be either of +// authorizer-magic-link (pinned to --disable-mfa=true, which forces +// EnforceMFA off) or authorizer-mfa-enforced (no magic-link/email-verification +// wiring, and turning that on there would break its own password-login tests +// below). +const MAGIC_LINK_MFA_BASE_URL = process.env.AUTHORIZER_MFA_MAGIC_LINK_BASE_URL || 'http://localhost:8085'; +const MAILPIT_BASE = process.env.MAILPIT_BASE_URL || 'http://localhost:8025'; + +function randomEmail(prefix: string) { + return `${prefix}-${crypto.randomUUID()}@example.com`; +} + +const signupMutation = ` + mutation ($params: SignUpRequest!) { + signup(params: $params) { message } + } +`; + +const loginMutation = ` + mutation ($params: LoginRequest!) { + login(params: $params) { message access_token should_show_totp_screen } + } +`; + +// waitForMagicLinkURL mirrors tests/magic-link.spec.ts's helper (Task 27) - +// not shared/exported from there either, so duplicated here rather than +// introducing a new cross-spec fixture for one function. Bounded to 40 +// tries * 250ms = 10s, matching that spec's own timeout. +async function waitForMagicLinkURL(request: APIRequestContext, email: string): Promise { + for (let i = 0; i < 40; i++) { + const res = await request.get(`${MAILPIT_BASE}/api/v1/messages`); + const { messages } = await res.json(); + const msg = messages.find((m: { To: { Address: string }[] }) => m.To.some((t) => t.Address === email)); + if (msg) { + const detail = await (await request.get(`${MAILPIT_BASE}/api/v1/message/${msg.ID}`)).json(); + const match = /(https?:\/\/\S+verify_email\?\S+)/.exec(detail.Text); + if (match) return match[1].replace(/\)$/, ''); + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no magic link email received for ${email} within 10s`); +} + +test.describe('EnforceMFA=true routing matrix', () => { + test('password login, no factor enrolled: token withheld, routed to mfa enrollment', async ({ request, baseURL }) => { + const email = randomEmail('mfa-matrix'); + await graphql(request, baseURL!, signupMutation, { + params: { email, password: PASSWORD, confirm_password: PASSWORD }, + }); + + // mfaGateBlockEnroll (internal/service/mfa_gate.go, resolveMFAGate): + // EnforceMFA is absolute for an unenrolled user, so login.go's + // mfaGateBlockEnroll branch (lines ~435-454) withholds access_token + // entirely - this is the real enforcement signal, not just a truthy + // message. TOTP is enabled by default in this stack (no + // --disable-totp-login flag set), so should_show_totp_screen comes back + // true alongside the withheld token. + const loginRes = await graphql<{ + login: { message: string; access_token: string | null; should_show_totp_screen: boolean | null }; + }>(request, baseURL!, loginMutation, { params: { email, password: PASSWORD } }); + + expect(loginRes.login.access_token).toBeFalsy(); + expect(loginRes.login.message).toBe('Proceed to mfa setup'); + expect(loginRes.login.should_show_totp_screen).toBe(true); + }); + + test('skip_mfa_setup is rejected while enforcement is active', async ({ request, baseURL }) => { + const email = randomEmail('mfa-matrix-skip'); + await graphql(request, baseURL!, signupMutation, { + params: { email, password: PASSWORD, confirm_password: PASSWORD }, + }); + // Login withholds the token and arms the mfa_session cookie + // (login.go's setMFASession) - Playwright's request fixture replays it + // automatically on the skip_mfa_setup call below (same cookie-jar + // behavior tests/totp.spec.ts and tests/sms-otp.spec.ts rely on). + await graphql(request, baseURL!, loginMutation, { params: { email, password: PASSWORD } }); + + // internal/service/skip_mfa_setup.go recomputes the gate server-side and + // only allows the skip when gate === mfaGateOfferAll. Under enforcement + // the gate is mfaGateBlockEnroll, so this must be rejected even though + // the caller holds a real mfa_session cookie from a genuine login - + // proves enforcement can't be routed around by calling skip directly, + // not just that the login response *offers* setup. + const skipMutation = ` + mutation ($params: SkipMfaSetupRequest!) { + skip_mfa_setup(params: $params) { message } + } + `; + const res = await request.post('/graphql', { + data: { query: skipMutation, variables: { params: { email } } }, + headers: { Origin: baseURL! }, + }); + const body = await res.json(); + expect(body.errors).toBeTruthy(); + expect(JSON.stringify(body.errors)).toMatch(/cannot skip/i); + }); + + test('magic-link login still routes through the mfa challenge under enforcement', async ({ request }) => { + const email = randomEmail('mfa-matrix-magic'); + const magicLinkLoginMutation = ` + mutation ($params: MagicLinkLoginRequest!) { + magic_link_login(params: $params) { message } + } + `; + await graphql(request, MAGIC_LINK_MFA_BASE_URL, magicLinkLoginMutation, { params: { email } }); + + const link = await waitForMagicLinkURL(request, email); + + // VerifyEmailHandler (internal/http_handlers/verify_email.go) runs the + // same gate as login.go via EvaluateMFAGateForOAuth + // (internal/service/oauth_mfa_gate.go) before ever minting a token: for + // mfaGateBlockEnroll it redirects with mfa_required=1&mfa_gate=offer + // instead of access_token=... - assert on the real redirect target/query + // params (not just "URL isn't /app"), per Task 13's established + // mfa_required/mfa_gate/mfa_methods param handling in + // web/app/src/Root.tsx. + const res = await request.get(link, { maxRedirects: 0 }); + expect(res.status()).toBe(307); + const location = res.headers()['location']; + expect(location).not.toContain('access_token='); + expect(location).toContain('mfa_required=1'); + expect(location).toContain('mfa_gate=offer'); + }); +}); diff --git a/e2e-playground/tests/oidc-provider.spec.ts b/e2e-playground/tests/oidc-provider.spec.ts new file mode 100644 index 000000000..105dcbd14 --- /dev/null +++ b/e2e-playground/tests/oidc-provider.spec.ts @@ -0,0 +1,160 @@ +// e2e-playground/tests/oidc-provider.spec.ts +import { test, expect } from '@playwright/test'; +import { GraphQLClient, gql } from 'graphql-request'; +import crypto from 'node:crypto'; + +const BASE_URL = process.env.AUTHORIZER_BASE_URL || 'http://localhost:8080'; +// CSRF middleware requires Origin/Referer on state-changing requests (see +// internal/http_handlers/csrf.go) — same rationale as fixtures/adminClient.ts. +const client = new GraphQLClient(`${BASE_URL}/graphql`, { headers: { Origin: BASE_URL } }); + +function randomEmail() { + return `oidc-provider-${crypto.randomUUID()}@example.com`; +} + +test.describe('OIDC — provider side', () => { + test('discovery document matches actual endpoint behavior', async ({ request, baseURL }) => { + const res = await request.get('/.well-known/openid-configuration'); + expect(res.status()).toBe(200); + const doc = await res.json(); + expect(doc.authorization_endpoint).toBe(`${baseURL}/authorize`); + expect(doc.token_endpoint).toBe(`${baseURL}/oauth/token`); + expect(doc.jwks_uri).toBe(`${baseURL}/.well-known/jwks.json`); + + const jwksRes = await request.get('/.well-known/jwks.json'); + expect(jwksRes.status()).toBe(200); + const jwks = await jwksRes.json(); + // This stack runs --jwt-type=HS256 (docker-compose.yml). HMAC keys are + // symmetric and must never be exposed via JWKS (internal/http_handlers/jwks.go + // generateJWKFromKey) — the keys array must be exactly empty. Asserting + // the exact value (not just Array.isArray) catches a future regression + // where an HMAC secret starts leaking into the JWKS response. + expect(jwks.keys).toEqual([]); + }); + + test('signup then password login issues a valid session', async () => { + const email = randomEmail(); + const signup = gql` + mutation ($params: SignUpRequest!) { + signup(params: $params) { message } + } + `; + await client.request(signup, { params: { email, password: 'Str0ngPassw0rd!', confirm_password: 'Str0ngPassw0rd!' } }); + + const login = gql` + mutation ($params: LoginRequest!) { + login(params: $params) { message } + } + `; + const loginRes = await client.request<{ login: { message: string } }>(login, { + params: { email, password: 'Str0ngPassw0rd!' }, + }); + expect(loginRes.login.message).toBeTruthy(); + }); + + test('a fabricated authorization code is rejected', async ({ request }) => { + // Unknown/fabricated code — proves /oauth/token doesn't accept arbitrary + // codes. Real single-use (replay of a genuine, once-used code) is + // covered separately below, where a real code is minted via a live + // browser-driven PKCE /authorize flow. + const res = await request.post('/oauth/token', { + form: { grant_type: 'authorization_code', code: 'not-a-real-code', client_id: 'authorizer' }, + }); + expect(res.status()).toBeGreaterThanOrEqual(400); + }); + + test('real PKCE authorization-code flow exchanges a code for tokens, and replay is rejected', async ({ + page, + request, + }) => { + // client_id/redirect_uri must match what this stack's own login UI is + // configured for (docker-compose.yml: --client-id=e2e-client-id, + // --allowed-origins=http://localhost:8080). No client is registered in + // the clients table for e2e-client-id, so /authorize (internal/http_handlers/authorize.go) + // falls back to validating redirect_uri's origin against AllowedOrigins — + // any path under http://localhost:8080 is accepted. + const clientId = 'e2e-client-id'; + const redirectUri = `${BASE_URL}/e2e-callback`; + const email = randomEmail(); + const password = 'Str0ngPassw0rd!'; + + const signup = gql` + mutation ($params: SignUpRequest!) { + signup(params: $params) { message } + } + `; + await client.request(signup, { params: { email, password, confirm_password: password } }); + + // RFC 7636 PKCE: verifier is a random string, challenge is its S256 hash. + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); + const state = crypto.randomUUID(); + + const authorizeUrl = new URL('/authorize', BASE_URL); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', 'openid'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + + await page.goto(authorizeUrl.toString()); + + // Real rendered /app login form (web/app/src/pages/login.tsx → + // AuthorizerBasicAuthLogin in authorizer-react): email/phone field id + // authorizer-login-email-or-phone-number, password field id + // authorizer-login-password, submit button inside the named form. + await page.locator('#authorizer-login-email-or-phone-number').fill(email); + await page.locator('#authorizer-login-password').fill(password); + await page.locator('form[name="authorizer-login-form"] button[type="submit"]').click(); + + // First-time login for a brand-new user hits the optional MFA-setup + // offer screen (withheld-token first-time-setup redesign — see + // authorizer-react AuthorizerMFASetup / commit 992b3bf4): the token is + // withheld until a factor is set up or skipped. "Skip for now" calls + // skipMfaSetup, which issues the token and completes login. click() + // waits for the button up to its own timeout, so this also tolerates + // the offer screen not appearing at all. + await page + .getByRole('button', { name: 'Skip for now' }) + .click({ timeout: 10_000 }) + .catch(() => {}); + + // On success the SPA (web/app/src/Root.tsx) resumes the authorization + // request against /authorize using the now-authenticated session cookie, + // and the server issues the final redirect to redirect_uri?code=...&state=.... + await page.waitForURL((url) => url.origin === BASE_URL && url.pathname === '/e2e-callback' && url.searchParams.has('code')); + + const finalUrl = new URL(page.url()); + const code = finalUrl.searchParams.get('code'); + expect(code).toBeTruthy(); + expect(finalUrl.searchParams.get('state')).toBe(state); + + const tokenForm = { + grant_type: 'authorization_code', + code: code!, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }; + + const tokenRes = await request.post('/oauth/token', { form: tokenForm }); + expect(tokenRes.status()).toBe(200); + const tokenBody = await tokenRes.json(); + expect(tokenBody.access_token).toBeTruthy(); + expect(tokenBody.id_token).toBeTruthy(); + + // RFC 6749 §4.1.2: authorization codes MUST be single-use. Replaying the + // exact same (real, already-consumed) code must now be rejected. + const replayRes = await request.post('/oauth/token', { form: tokenForm }); + expect(replayRes.status()).toBeGreaterThanOrEqual(400); + }); + + test('redirect URI not in allowlist is rejected at /authorize', async ({ page }) => { + await page.goto( + '/authorize?response_type=code&client_id=authorizer&redirect_uri=https://evil.example.com/cb&scope=openid&state=xyz' + ); + await expect(page.locator('body')).toContainText(/invalid redirect uri|redirect_uri/i); + }); +}); diff --git a/e2e-playground/tests/oidc-sso-rp.spec.ts b/e2e-playground/tests/oidc-sso-rp.spec.ts new file mode 100644 index 000000000..abdd8385e --- /dev/null +++ b/e2e-playground/tests/oidc-sso-rp.spec.ts @@ -0,0 +1,86 @@ +// e2e-playground/tests/oidc-sso-rp.spec.ts +import { test, expect } from '@playwright/test'; +import { createOrg, createOIDCConnection, addVerifiedDomain } from '../fixtures/adminClient'; + +// Host-reachable base for the test PROCESS's own calls to mock-oauth (the +// __configure REST call below runs from this Node process, not from inside +// the authorizer container). +const MOCK_OAUTH_BASE = process.env.MOCK_OAUTH_BASE_URL || 'http://localhost:4000'; +// Container-network-reachable base for the issuer_url stored on the org's +// OIDC connection: that URL is dialed SERVER-SIDE, by the authorizer-sso +// container itself (SSOLoginHandler -> fetchOIDCDiscovery), so it must +// resolve inside the docker-compose network, not on the host. Plain http +// because mock-oauth has no TLS termination; that (and the private +// docker-network address below) requires --env=e2e, which every +// e2e-playground authorizer instance runs with (see docker-compose.yml). +const MOCK_OAUTH_INTERNAL_BASE = process.env.MOCK_OAUTH_INTERNAL_BASE_URL || 'http://mock-oauth:4000'; + +// This spec exercises the /app email-first home-realm-discovery (HRD) step +// (web/app/src/pages/login.tsx: HRDForm / handleHRDSubmit), which only +// renders when the server runs with --enable-org-discovery=true +// (internal/config/config.go Config.EnableOrgDiscovery; cmd/root.go +// --enable-org-discovery, default false / opt-in). That flag is a GLOBAL +// login-UX toggle (every /app login gets the HRD screen, not just org-scoped +// ones), so it can't be turned on for the plain `authorizer` service without +// breaking tests/oidc-provider.spec.ts's PKCE flow. It's instead scoped to +// the dedicated `authorizer-sso` compose service (port 8081) and this spec +// runs against it via the `sso-discovery` Playwright project +// (playwright.config.ts), whose baseURL points at that port. +// +// The real HRD screen (confirmed against a live stack) is served at /app +// (NOT /app/login — Root.tsx only registers "/app" for the unauthenticated +// Login route), has no