Skip to content

test(e2e-playground): live-playground e2e suite for OIDC/SAML/SCIM/SSO/OAuth/MFA#729

Open
lakhansamani wants to merge 74 commits into
mainfrom
worktree-playground-e2e
Open

test(e2e-playground): live-playground e2e suite for OIDC/SAML/SCIM/SSO/OAuth/MFA#729
lakhansamani wants to merge 74 commits into
mainfrom
worktree-playground-e2e

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

A comprehensive live-playground end-to-end test suite (e2e-playground/) that drives a real authorizer binary + real browser (Playwright) — no unit-level mocking of Authorizer itself. Third-party services (10 social OAuth providers, SMS delivery, an external SAML IdP, webhook receivers) are stood in by small local mock servers, wired in via new test-only CLI overrides that are true no-ops when unset (see Part A below).

69/69 tests pass. See e2e-playground/README.md for how to run it and how to view results.

Coverage

  • OIDC provider (signup/login/PKCE/token issuance) + 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 (Playwright's CDP virtual authenticator)
  • TOTP, SMS-OTP, WebOTP auto-fill, magic-link
  • MFA enforcement routing matrix, OTP brute-force lockout
  • Dashboard verified-domains UI

Part A — production-path test hooks (Go, ships in the real binary)

Test-only CLI overrides so the suite can redirect social OAuth / SMS / webhook delivery to local mocks: --test-oauth-<provider>-base-url (×10), --test-sms-webhook-url, --test-allow-private-sso-hosts, --test-allow-private-webhook-hosts. Every one is a verified no-op when unset — independently traced by a dedicated code review and two separate security-engineer reviews (SSO escape hatch, then webhook escape hatch), both concluding safe to merge, no fixes required. Full findings in .superpowers/sdd/progress.md.

Real bugs found and fixed along the way (separate PRs, already merged to main)

Magic-link email-service gate, Roblox/Twitter/Discord/Apple OAuth email bugs, SCIM webhook event allow-list (backend + dashboard dropdown), a GraphQL pagination schema inconsistency (fixed across the backend + all 3 first-party SDKs), a SAML IdP login-resumption bug, and a WebOTP wiring gap in web/app — see individual PRs #713-727.

Both originally-skipped tests are now real, passing tests

  • SCIM webhook delivery: was blocked by two SSRF chokepoints (registration + delivery) unconditionally rejecting private-IP endpoints. Added a scoped, reviewed escape hatch + a Node.js webhook-sink mock; test now asserts real delivery + HMAC signature verification.
  • WebOTP auto-fill: authorizer-react's WebOTP support (rc.3 dependency bump) wasn't being exercised — fixed a missing hasSmsOtp prop in web/app, then rewrote the test to stub navigator.credentials.get() and verify the real product code path end-to-end.

Final verification (post-merge with latest main)

  • go build ./..., go vet ./... — clean
  • TEST_DBS=sqlite go test -p 1 ./internal/... — full suite, 0 failures
  • make lint — golangci-lint 0 issues, both web apps prettier-clean
  • Full e2e-playground suite: 69/69 passed

One pre-existing, unrelated intermittent flake noted (not introduced by this branch, not fixed here): social/twitter.spec.ts's "repeat login" test occasionally (~1 in 4 runs observed) hits "invalid redirect uri" on its second browser context; reproduces standalone regardless of load/rate-limits. Tracked separately.

Test plan

  • Full e2e-playground suite: 69/69 passed (see above)
  • go build/go vet/gofmt clean
  • Full internal/... Go test suite (SQLite): 0 failures
  • make lint: 0 issues
  • Whole-branch code review (opus): ready to merge, findings addressed
  • Security review — Part A test hooks (opus): safe to merge, no fixes
  • Security review — webhook SSRF escape hatch (opus): safe to merge, no fixes

mock-oauth (4000) simulates all 10 social-login providers path-routed
per provider, plus issuer/JWKS/discovery for the OIDC-verified subset
(google/apple/microsoft/twitch), matching the route convention Task 2
wired TestOAuth*BaseURL to.

mock-saml-idp (4001) deviates from the plan's assumed samlify usage in
two ways verified against the installed samlify build (2.13.1):

- IdentityProvider() now requires singleSignOnService in its settings
  or construction throws ERR_IDP_METADATA_MISSING_SINGLE_SIGN_ON_SERVICE.
- createLoginResponse's non-customTagReplacement path unconditionally
  renders an empty AttributeStatement, so loginResponseTemplate.attributes
  alone never appears in the response. Authorizer's real SP
  (crewjam/saml) reads email/given_name/family_name out of the
  AttributeStatement by attribute name, so /sso now parses the inbound
  AuthnRequest via parseLoginRequest to get the real ACS URL and SP
  entity ID (rather than trusting a made-up acs_url query param
  Authorizer never sends), and builds the response through a
  customTagReplacement callback that fills in the attribute tags
  samlify's own tvalue path drops.

sms-sink (4100) is a plain in-memory store-and-retrieve server for
Task 3's TestWebhookProvider.

Test-only SAML cert/key generated by gen-certs.sh are checked in per
the plan's Global Constraints; .gitignore's blanket certs/ rule gets
narrow negations for the two e2e-playground cert paths so `git add`
can actually pick them up.
…d client

Wires authorizer + mock-oauth/mock-saml-idp/sms-sink (task 5) + mailpit
into one compose stack, and adds fixtures/adminClient.ts as the GraphQL
admin seed client every later spec-file task consumes.

Fixed against the real cmd/root.go and a live docker compose run:
- --http-port (not --port), plus --jwt-type/--client-id/--client-secret,
  all required at startup but missing from the original draft.
- --database-url moved off /data (nonexistent, non-root user can't
  create it) onto the already-owned /authorizer workdir.
- admin auth header is x-authorizer-admin-secret, not Bearer.
- GraphQL mutations need an Origin header or the CSRF middleware 403s.
- playwright.config.ts testDir widened from ./tests (doesn't exist yet)
  to include fixtures/, with testIgnore fixed so it stops importing (and
  thereby executing) the mocks' own server.test.ts files.

Smoke-tested end to end: build, up --wait, playwright test, down -v.
Adds tests/oidc-provider.spec.ts covering discovery doc, JWKS shape,
signup+login, replayed-code rejection, and redirect_uri allowlist
rejection against the real docker-compose stack.

Two calibrations against real behavior vs the plan's draft:
- graphql-request client needs an explicit Origin header, same as
  fixtures/adminClient.ts, since CSRFMiddleware (csrf.go) requires
  Origin/Referer on state-changing GraphQL mutations.
- JWKS assertion checks array shape, not non-empty length: this stack
  runs --jwt-type=HS256, and jwks.go intentionally never publishes
  HMAC (symmetric) keys.
…y) spec

Real HRD screen lives at /app (not /app/login), placeholder-only email
input, "Continue" button text -- calibrated against a live stack with
--enable-org-discovery=true.

home-realm-discovery JIT-provisioning test is test.fixme: unconditionally
blocked by validateSSOIssuerURL's https-only issuer_url check and
SafeHTTPClient's private-IP rejection (mock-oauth's docker-network address
is unavoidably private), neither of which has a test/dev bypass. Confirmed
live: connection creation rejects http://, and a real browser-driven login
attempt with an https:// issuer dead-ends at sso_upstream_error "could not
reach the identity provider".

Unrecognized-domain fallback test passes for real against a stack with the
flag enabled.
… (in progress)

Adds SafeHTTPClientAllowPrivate (internal/validators) gated behind a new
--test-allow-private-sso-hosts flag, threaded through the 3 SSO-broker
outbound-fetch call sites and validateSSOIssuerURL's https-only check, plus
a second authorizer-sso docker-compose service (--enable-org-discovery=true)
and matching Playwright project so Task 8's home-realm-discovery spec
doesn't collide with Task 7's PKCE flow test.

Task 8's JIT-provisioning test still fails: mock-oauth's docker-internal
hostname isn't reachable from a real browser running on the host. Root
cause and options recorded in .superpowers/sdd/progress.md.
Runs the Playwright test process itself as a docker-compose service
(mcr.microsoft.com/playwright image) on the same network as authorizer/
authorizer-sso/mock-oauth/mock-saml-idp, instead of on the host. A real
browser previously could not resolve mock-oauth's docker-internal hostname
when Authorizer redirected it there mid-OAuth-flow (confirmed live:
chrome-error://chromewebdata/) — every browser-driven mock-oauth redirect
(SSO discovery, all 10 planned social-login specs) was broken by this.
Running the browser inside the network fixes it: every service hostname
now resolves identically for server-side fetches and browser navigation.

`make e2e-playground` now brings up the core services, then runs the suite
via `docker compose run --rm playwright`, then tears down.

Getting this fully green also surfaced and required fixing:
- CSRF: added the docker-internal origin (http://authorizer:8080 etc.) to
  --allowed-origins, since state-changing requests from inside the
  container present that Origin, not the host-published localhost one.
- Added --url (the new trusted-canonical-base-URL flag from the
  security(auth) hardening merged upstream) pinned to each service's own
  docker-internal address, per that flag's own recommendation.
- --app-cookie-secure defaults to true and --app-cookie-same-site defaults
  to "none" — SameSite=None cookies require Secure, and Secure cookies are
  silently dropped by the browser over plain HTTP except on the literal
  "localhost" origin (which Chromium treats as a trustworthy-origin
  exception). Neither authorizer/authorizer-sso's docker-internal hostname
  gets that exception, so every session cookie was being silently dropped.
  Set --app-cookie-secure=false / --app-cookie-same-site=lax for this
  plain-HTTP test stack.
…, not just 4 named providers

Task 8's SSO relying-party flow registers an OIDC connection against a
synthetic realm name (sso-org-<id>), not one of the 4 hardcoded
OIDC_PROVIDERS (google/apple/microsoft/twitch). The token endpoint only
signed an id_token for that fixed set, so Authorizer's SSO broker got a
token response with no id_token at all ("token response has no id_token").

Also: /authorize never captured the incoming nonce, so once id_token
issuance was fixed for arbitrary realms, verification still failed with
"id_token nonce mismatch" (Authorizer's OIDC broker requires nonce to
round-trip through the id_token per spec). Now captured per-code at
/authorize and echoed into the signed id_token at /token.

Harmless for the 6 REST-profile social providers (github/facebook/
linkedin/discord/twitter/roblox) — their code path in oauth_callback.go
never reads token.Extra("id_token"), so the extra field is ignored.
…ertion

test.fixme -> test now that the browser-reachability, CSRF-origin,
--url, cookie, and mock-oauth id_token/nonce fixes (Task 6b + prior
commits) make the flow actually completable. The assertion itself had a
trailing-slash bug (checked pathname === '/app', but Authorizer's real
redirect lands on '/app/') and was too weak (only checked for absence of
the word "error"); tightened to match either form and to assert the
JIT-provisioned employee's email is actually visible on the landed page.

Verified live end-to-end via the containerized Playwright runner: both
tests in this file pass (home-realm discovery + JIT provisioning, and the
unrecognized-domain fallback), and tests/oidc-provider.spec.ts's 5 tests
remain green — no regression.
Covers SP-initiated login (redirect -> IdP -> POST-binding assertion ->
JIT provisioning), a malformed-SAMLResponse abuse case at the ACS, and
the SP metadata endpoint.

mock-saml-idp now terminates TLS with its own signing cert: Authorizer's
idp_sso_url validation is https-only with no test bypass (unlike OIDC's
--test-allow-private-sso-hosts), so the mock has to speak https to be
registerable at all. The spec ignores its self-signed cert via Playwright's
ignoreHTTPSErrors, scoped to this file only.
…ied checkpoint)

Extends mock-saml-idp with a fake-SP role (samlify supports both IdP and SP
from the same package) so tests can drive a real SP-initiated flow against
Authorizer's actual IdP-side routes (internal/http_handlers/saml_idp.go),
which expect a genuine crewjam/saml AuthnRequest, not a hand-rolled query
param. Adds adminClient.ts helpers for the SAML IdP-side admin surface
(signupUser, getUserIdByEmail, verifyUserEmail, addOrgMember,
createSAMLServiceProvider) and the two tests (SP-initiated login with a
real signed-assertion readback, unregistered-SP rejection).

Recovered from an interrupted session before this work was verified against
a live stack or reviewed — committing as a checkpoint before rebasing onto
2.4.0-rc.7. Live verification and review follow in subsequent commits.
…o-submit

Root.tsx's post-login resumption effect only handled the /authorize
resumption shape (state/response_type/response_mode/client_id/scope) and
silently dropped bounceSAMLIDPToLogin's redirect_uri (path
/saml/idp/{slug}/sso?saml_continue=...), so the browser landed on the
plain dashboard instead of resuming the pending SAML flow. Added a
narrow, separate check: same-origin + exact /saml/idp/{slug}/sso path +
saml_continue param, only then window.location.replace to that literal
URL. Anything else falls through unchanged - this is not a generic
redirect_uri navigator, since redirect_uri is attacker-controllable on
/app.

Fixing that exposed a second, previously-unreachable bug: the SSO
endpoint's auto-submit response (crewjam WriteResponse, a self-posting
HTML form to the SP's ACS URL) was blocked by the global CSP's
form-action 'self', since the ACS URL is always a different origin by
design. Scoped a relaxed CSP (form-action omitted, not inherited from
default-src) to just the two SAML IdP SSO routes - safe because the ACS
destination is never request-supplied, it's validated server-side
against the org's registered SP record before the response is built.

Verified via the containerized Playwright e2e suite: saml-idp.spec.ts
now 2/2 (was 1/2), no regression in oidc-provider.spec.ts,
oidc-sso-rp.spec.ts, or saml-sp.spec.ts.
Covers GET /api/v1/org-discovery directly via the request fixture: OIDC-only
and SAML-only routing, SAML precedence over OIDC when both are configured,
the privacy-preserving indistinguishable null response (unknown domain vs.
verified-domain-with-no-connection), malformed email 400, and the
domain-uniqueness rejection at verification time.

Runs under the sso-discovery Playwright project (authorizer-sso, the only
compose service with --enable-org-discovery=true; the flag defaults to
false/opt-in on the plain authorizer service).

Adds an optional baseUrl param to adminClient.createSAMLConnection, mirroring
createOIDCConnection/createOrg/addVerifiedDomain, so SAML connections can be
created against authorizer-sso instead of always the default :8080 client.
Root derives mfaRedirect from window.location.href on every render and
checks it before token. skip/verify success set a real token via
setAuthData but never touched the URL, so the still-present
mfa_required=1 query params kept matching on the next render and the
app got stuck on the MFA screen forever instead of falling through to
the dashboard/resumption effect. Hits every login that lands on /app
via a server redirect carrying these params: OAuth social login,
magic-link, and email-verification click-through - found via a real
Google OAuth e2e login.
Adds tests/social/helpers.ts: configureProviderProfile + runSocialLoginHappyPath,
the pattern tasks 14-22 (the other 9 OAuth2 providers) will reuse - mock
profile setup, real button-click-driven login, and a real session
assertion (dashboard's "Signed in as <email>" render) instead of a
"no error text" tautology.

google.spec.ts covers the OIDC-verified-provider shape: first-time
signup maps id_token claims onto the stored user (given_name/
family_name/signup_methods via adminClient's new getUserByEmail), and
a consent-denied negative test hits /oauth_login + /oauth_callback
directly to get a real server-issued state and assert the callback's
actual error/replay behavior.
Covers first-time signup with given_name/family_name split from GitHub's
single `name` field, the empty-primary-email fallback to /user/emails,
and the consent-denied negative path, reusing the shared social helpers
from task 13.
Covers happy-path signup (localizedFirstName/localizedLastName ->
given_name/family_name, email always via the separate emailAddress
endpoint, unlike GitHub's fallback-only path) and the shared
consent-denied negative path.
Apple's real flow has its hosted consent page POST a `user` form field
(name only, first-login) alongside the code - not part of the id_token,
and not something mock-oauth's authorize/token exchange builds. Without
it, processAppleUserInfo's json.Unmarshal on an empty FormValue 400s on
every Apple login, mock or real second-time. Mirror it in mock-oauth's
/authorize redirect (as a query param, which Go's FormValue reads same
as a POST body) so the generic runSocialLoginHappyPath flow reaches the
same code path real Apple does on first signup.
… field

Apple sends the `user` form field only on an account's first authorization;
every later login omits it (a one-time grant, not re-sent). The handler
unconditionally json.Unmarshal'd it, so an empty value failed and 400'd
every returning Apple user's login. Only parse it when non-empty; a
non-empty malformed value still errors. Email always comes from the
verified id_token regardless of this field, so nothing else changes.
… field

Add omit_user_field to mock-oauth's /apple/authorize so a test profile can
suppress the `user` query param, mirroring real Apple's one-time-grant
behavior. Extend apple.spec.ts with a third test: sign up, log out, log
back in with `user` omitted, and confirm the session still establishes.
Every other social provider (Google, GitHub, Facebook, LinkedIn,
Apple, Twitter, Microsoft, Twitch, Roblox) derives its
is_<provider>_login_enabled flag in internal/service/meta.go from
ClientID/ClientSecret being set - Discord's was simply never
assigned, so it always returned false regardless of configuration.
Any frontend gating a Discord login button on this flag (e.g.
authorizer-react's AuthorizerSocialLogin, once it added Discord
support in 2.2.0-rc.2) could never render one, even with Discord
OAuth fully configured and working end to end on the backend.

Bump web/app's authorizer-react to 2.2.0-rc.2 to pick up its Discord
button. With both fixes in place, tests/social/discord.spec.ts (Task
19) goes from 1/3 to 3/3 passing.
…ials.get

Task 26 review caught the describe-block comment claiming authorizer-js's
"source tree" was grepped with zero matches for navigator.credentials.get.
authorizer-js ships no source in node_modules (compiled lib/ only), and
that compiled output does contain one call to the API - inside
loginWithPasskey, for WebAuthn (publicKey mode), unrelated to WebOTP (otp
mode) and to the SMS-OTP verify input this file exercises. Corrects the
claim without changing any test behavior.
…erification

MagicLinkLogin's isEmailVerificationEnabled check only looked at
EnableEmailVerification, unlike signup.go's identical guard (which
also requires IsEmailServiceEnabled). With email verification on but
SMTP not fully configured (e.g. no sender email), the mutation still
created a VerificationRequest row and fired an async SendEmail
guaranteed to fail - the caller got a generic success message, but
no email was ever deliverable and the failure was only visible at
debug log level.

Found live while building e2e coverage for magic-link login: the
compose service needed --smtp-sender-email added because nothing had
exercised a real send through this stack before, surfacing a
"gomail: invalid address" failure that a caller would never see.
--admin-cookie-secure defaults to true (cmd/root.go), and was never
set to false anywhere in docker-compose.yml, unlike
--app-cookie-secure=false which already is on every service. Over
plain HTTP, a Secure cookie is silently dropped by the browser
entirely, so the admin login cookie never actually persisted, and
any full-page navigation after logging into the dashboard lost the
session.

Found building the first e2e spec to drive the dashboard admin UI
through a real browser (all prior admin interactions in this plan
went through the GraphQL API directly via fixtures/adminClient.ts,
never a real login form + page reload).
ListOrgDomainsRequest.pagination is PaginatedRequest, which itself
wraps a `pagination: PaginationRequest` field
(internal/graph/schema.graphqls) - fetchDomains sent the single-nested
`pagination: { limit: 100 }`, which the server rejected on every call
with GRAPHQL_VALIDATION_FAILED. The error was silently swallowed
(`res.data?._org_domains?.org_domains || []` never checked res.error),
so the verified-domains table always rendered "No verified domains
yet." even when domains existed - this feature (added in #707) has
never worked since it shipped.

Also adds the missing res.error handling fetchDomains lacked, matching
every other mutation handler in this file, and a regression test
asserting the exact query variable shape sent (the existing test
file's generic client mock never validated variable shape against a
real schema, which is why this went unnoticed by 6 passing unit
tests).

Found while building the first e2e spec to drive this UI through a
real browser and backend.
Covers the two flows OrgDomains.tsx supports: adding a domain without
DNS verification (super-admin shortcut) then deleting it, and the
full DNS challenge flow (request, see the TXT record to publish,
verify - which fails against the test domain's real absence of DNS
records, surfacing the retryable "not verified yet" hint).

loginAsAdmin polls for the admin session cookie to actually land in
the browser's cookie jar before navigating away: every other spec in
this suite only ever does client-side routing after login, so this is
the first to hit a real race between the login response resolving and
Chromium's cookie-jar write completing, under headless/Docker Chrome.
IsValidWebhookEventName's allow-list was never updated when PR #705
added the SCIM/group webhook events (user.provisioned,
user.deprovisioned, user.scim_updated, group.created, group.updated,
group.deleted - internal/constants/webhook_event_scim.go). The SCIM
service fires these events correctly, but an admin could never
register a webhook for any of them - _add_webhook rejected every one
as "invalid event name", leaving the whole feature with no working
configuration path since it shipped.

Rewritten as a map lookup rather than extending the existing 7-way
!= chain to 13, which was already at the edge of readability.

Found while extending e2e SCIM coverage for #705's provisioning
webhooks - the test could not even register a webhook to observe
delivery against, let alone verify it fired.
BREAKING CHANGE: 6 List*Request input types (ListClients, ListTrustedIssuers,
ListSAMLServiceProviders, ListOrganizations, ListOrgDomains, ListOrgMembers)
declared their pagination field as PaginatedRequest, a wrapper type whose
only field is itself named `pagination: PaginationRequest` - forcing every
caller to double-nest (`pagination: { pagination: { limit, page } }`).
3 more operations (_verification_requests, _webhooks, _email_templates)
took PaginatedRequest as their entire top-level params type.

This wrapper never existed on the canonical proto/gRPC surface
(proto/authorizer/v1/pagination.proto and admin.proto - every List RPC
takes PaginationRequest directly, no wrapper), and 4 other GraphQL-only
request types (ListUsers, UserOrganizations, ListWebhookLog, ListAuditLog)
already embedded PaginationRequest directly. The double-nesting was a
GraphQL-only accident, not an intentional API design - and it was
actively broken: OrgDomains.tsx (this repo's own dashboard) sent the
single-nested shape and got GRAPHQL_VALIDATION_FAILED on every call
until a prior fix matched the wrapper, and authorizer-js's TypeScript
types for all 6 List*Request types were already (accidentally) typed as
single-nested PaginationRequest, meaning no strictly-typed SDK consumer
could construct the double-nested shape the schema required either.

Removes the PaginatedRequest type entirely; the 9 affected fields/params
now declare PaginationRequest directly, matching every other GraphQL
endpoint and the proto surface. internal/utils/pagination.go's shared
GetPagination helper now takes *model.PaginationRequest directly,
eliminating the wrap-then-call boilerplate every "already single-nested"
caller needed to reuse it. The gRPC handler layer's duplicate
modelPaginatedRequest/modelPaginationRequest converters collapse into one.

Every consumer in this repo (internal/service, internal/graphql,
internal/grpcsrv/handlers, web/dashboard) is updated to match.
Follows the schema fix (previous commit): Clients.tsx, TrustedIssuers.tsx,
Organizations.tsx, and OrganizationDetail.tsx's org-members fetch drop
their double-nested `pagination: { pagination: {...} } }` calls down to
single-level. OrgDomains.tsx reverts the double-nesting patch from an
earlier fix now that the schema itself no longer requires it. Webhooks.tsx
and EmailTemplates.tsx drop the `pagination:` wrapper key entirely, since
_webhooks/_email_templates now take PaginationRequest directly as their
whole params type instead of the removed PaginatedRequest wrapper.
…spec (#698)

Covers verify_otp's 5-failed-attempt lockout (internal/service/verify_otp.go):
distinct "too many failed attempts" error on the 6th attempt for both TOTP
and SMS-OTP, correct code still refused while locked, and a successful
verification resetting the counter (checked via a second login whose own
5 wrong attempts all get plain invalid-otp errors, not an immediate lock -
proving the prior session's count didn't carry over).
Picks up the latest authorizer-js (3.3.0-rc.4, pagination + signup MFA-flag
fixes) plus real WebOTP auto-fill via navigator.credentials.get(). Verified
against the live playground stack: oidc-provider, oidc-sso-rp, magic-link,
webauthn, totp, sms-otp, web-otp, and mfa-routing-matrix specs all pass
(18/18, 1 pre-existing unrelated skip).
# Conflicts:
#	internal/http_handlers/oauth_callback.go
#	internal/http_handlers/oauth_callback_test.go
#	internal/http_handlers/oauth_discord_test.go
#	internal/http_handlers/oauth_roblox_test.go
#	internal/http_handlers/oauth_twitter_test.go
… fails

up -d --wait and the playwright run were separate recipe lines; if the
stack failed to start (image build error, healthcheck timeout), make
aborted before reaching `down -v`, leaking the partial stack. Chain
everything into one shell invocation so teardown always runs.
Security review of the test-only OAuth/SMS/SSRF hooks (internal/oauth,
internal/sms, internal/validators/safe_http.go) confirmed all four
capabilities are true no-ops when unset, but flagged two gaps in the
regression coverage backing that claim:

- internal/sms: no test asserted TestSMSWebhookURL unset falls through to
  the untouched Twilio branch (only the test-webhook happy path was
  covered).
- internal/service: validateSSOIssuerURL (the plain-http SSRF escape
  hatch for e2e-playground's mock IdP) had zero test coverage at all.

Both gated behaviors were already correct; this closes the coverage gap
so the no-op guarantee is enforced going forward, not just true today.
…eal WebOTP test

- Root.tsx: wire hasSmsOtp on the mfa-redirect AuthorizerVerifyOtp so
  authorizer-react rc.3's real navigator.credentials.get() auto-fill
  actually activates for that flow (mirrors authorizer#727 on main).
- Root.tsx: the origin/main merge left two copies of the SAML IdP
  resumption effect (main's separate useEffect duplicating logic HEAD's
  combined effect already handled inline) - removed the redundant one,
  keeping the single effect the original comment already explains is
  intentional (avoids two effects racing on window.location.replace).
- web-otp.spec.ts: authorizer-react's WebOTP PR (in the rc.3 dependency
  bump) added a real navigator.credentials.get({otp:...}) call, closing
  the gap the skipped test's investigation had found. Rewrote it to stub
  navigator.credentials.get via page.addInitScript, bridging into Node
  (page.exposeFunction) to resolve with the real SMS-sink code the same
  way a genuine WebOTP-capable browser would (polls for the actual
  message, never a canned value) - exercises the real product code path
  instead of test-authored glue. All 3 tests in the file pass against
  the live stack.
Webhook registration (ValidateEndpointURL) and delivery (SafeHTTPClient)
both reject private/loopback IPs unconditionally, so e2e-playground's
docker-private webhook-sink is unreachable and SCIM lifecycle webhooks
cannot be tested end to end.

- add Config.TestAllowPrivateWebhookHosts + --test-allow-private-webhook-hosts
  (default false), kept independent of TestAllowPrivateSSOHosts so each
  escape hatch is scoped to one call site (least privilege)
- thread the flag into ValidateEndpointURL and deliver()'s client selection;
  it relaxes ONLY the private-IP check — scheme allow-list and DNS-rebinding
  host pin stay enforced, true no-op when unset
- reachable solely via the operator CLI flag, never a request/GraphQL/webhook
  field

Mirrors the existing --test-allow-private-sso-hosts precedent.
- add webhook-sink mock: records POSTs keyed by user email + event name,
  captures raw body so a test can verify the HMAC over exact bytes
- wire webhook-sink into docker-compose; set
  --test-allow-private-webhook-hosts=true only on the `authorizer` service
- add addWebhook admin-client helper
- un-skip the SCIM webhook test: register user.provisioned/scim_updated/
  deprovisioned, drive a SCIM create/patch/delete, and assert each delivery's
  payload, event name, and X-Authorizer-Signature HMAC
… add README

- fixtures/adminClient.ts's deleteSAMLConnectionByEntityID still sent the
  old double-nested pagination shape from before the pagination
  standardization fix - caused a real, reproducible GraphQL validation
  failure in saml-sp.spec.ts's SP-initiated login test.
- Raised --rate-limit-rps/--rate-limit-burst on all 6 authorizer instances:
  the default (30rps/20burst) intended for production traffic occasionally
  tripped under a full sequential suite run's admin-API call volume,
  producing flaky 429s unrelated to product correctness. These are
  ephemeral, purpose-built test stacks - no real DoS concern.
- Added README.md: how to run the full suite / a subset, how to view
  results (HTML report, traces) after a run.

Full suite verified clean: 69/69 passed. One pre-existing, unrelated
intermittent flake noted (not fixed here): social/twitter.spec.ts's
"repeat login" test occasionally hits "invalid redirect uri" on its
second browser context (~1 in 4 runs observed); reproduces standalone
regardless of rate limits, so it's not the rate-limit issue above -
tracked separately, not blocking.
Drives the live e2e-playground stack through the published authorizer-go
SDK (v2.2.0-rc.4, real go get require) over the feature areas its own
integration suite never covers: TOTP, SMS-OTP, MFA enforcement routing,
OTP lockout, SCIM admin+webhook, SAML IdP admin, WebAuthn.

- New standalone nested module e2e-playground/sdk-tests/go (out of the
  parent's build/CI); additive go-sdk-tests compose service + a
  make e2e-playground-sdk target with teardown-on-failure.
- WebAuthn registration is completed as a FULL ceremony through the SDK
  via a pure-Go software authenticator (descope/virtualwebauthn), no
  browser.
- Documents two confirmed SDK gaps (Login discards Set-Cookie;
  VerifyOTP/WebauthnLoginVerify take no headers) that block the
  cookie-based verify_otp path end to end — labelled raw-HTTP is used
  only for those calls, never as a silent workaround.
- Social OAuth and SAML SP login are documented as inherently
  browser/redirect flows with no SDK surface, left to the Playwright
  suite.

Live: 9 services healthy, 14 passed / 0 failed, teardown clean.
Drive the e2e-playground stack's MFA/enterprise features through the published
authorizer-py SDK (0.3.0rc3), the Python analogue of sdk-tests/go — catching
SDK<->server wire-shape drift the Playwright/raw-HTTP suites can't.

- 28 tests, all live-verified: TOTP, SMS-OTP, OTP lockout, MFA routing, SCIM
  admin + webhook HMAC delivery, SAML admin (SP CRUD + IdP key rotation +
  metadata import), social OAuth (10 providers), and a FULL WebAuthn ceremony
  via a soft-webauthn software authenticator (UV-enabled).
- Runs in-network via an additive python-sdk compose service (mirrors the
  playwright service; no existing service touched) so mock-oauth and the
  WebAuthn RP origin resolve.
- Honest SDK/raw split documented per area: inbound SCIM REST and the
  social-OAuth/magic-link/SAML-SSO browser ceremonies are raw-HTTP by design,
  not SDK surface.
- ruff + mypy-strict clean, config mirrored from authorizer-py.
…und-e2e

# Conflicts:
#	e2e-playground/docker-compose.yml
13 e2e-playground-only root flags (10 OAuth mock-base-URL flags, 1 SMS
webhook flag, 2 SSRF private-host bypass flags) added test-only surface
to the production CLI. Collapse them into a single new constants.E2EEnv
("e2e") value on the existing --env flag instead.

Kept distinct from the pre-existing constants.TestEnv ("test"), which
the whole internal/integration_tests suite runs under, so this carries
zero blast radius into that suite (verified: full internal/... suite
green, including new isolation tests proving TestEnv triggers none of
this behavior).

Go unit tests for the 4 OAuth providers that spin up their own local
httptest.Server still need an arbitrary override URL; added a
test-only package var (config.TestOAuthMockBaseOverride) for that
instead of reintroducing per-provider flags.

docker-compose.yml's authorizer/authorizer-sso services now pass a
single --env=e2e in place of the 13 removed flags.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant