Build SAML login (enterprise/internal/loginhandler), mirroring OIDC
Adds GET /auth/saml/login + POST /auth/saml/acs alongside the existing OIDC pair, both converging on the same upsert-user/resolve-tenant/ issue-session path. loginhandler.New now takes an optional *saml.ServiceProvider, RegisterRoutes registers each protocol's routes independently so either, both, or neither can be configured. SAML's replay/unsolicited-response defense (InResponseTo, standing in for OIDC's state) is carried via a SameSite=None sentry_saml_request cookie -- None because the ACS endpoint receives a cross-site POST from the IdP's origin, which SameSite=Lax cookies are never sent on. enterprise-auth's main.go now fetches+parses SAML_IDP_METADATA_URL at startup (samlsp.FetchMetadata) and wires the result through. Verified to the same bar as OIDC: a real fake IdP (crewjam/saml/samlidp, genuine XML signing/verification) drives the full login->ACS->session-cookie round trip and negative paths (bad InResponseTo, missing request cookie, missing email/NameID, no/multiple tenant memberships), all in loginhandler/saml_test.go, no Docker needed. The login-form HTML is bypassed by pre-seeding a saml.Session directly into samlidp's session store and presenting the matching `session` cookie -- an IdP-supported shortcut (confirmed by reading GetSession), the same "skip the UI, keep the crypto real" approach oidctest gave the OIDC tests. Writing that test caught two real bugs in internal/saml.ParseResponse, both fixed here: it never called r.ParseForm() before reading the POSTed SAMLResponse field, so every real ACS POST would have silently decoded an empty response; and its email-attribute matching missed urn:oid:0.9.2342.19200300.100.1.3 (the standard LDAP "mail" OID), which is what an IdP sends by default absent an explicit AttributeConsumingService request for "email" -- exactly what samlidp's own DefaultAssertionMaker does, and plausibly what real IdPs' default SAML app templates do too. Docs (CLAUDE.md, threat-model.md, architecture.md, enterprise/README.md, phase-4-runbook.md, docker-compose.yml's enterprise-auth comment) updated in lockstep: SAML login moves from "protocol mechanics only" to "built, verified with a real fake IdP, not yet tried against a real external IdP or a running enterprise-auth container" -- the same disclosed gap OIDC already carried.
This commit is contained in:
@@ -152,14 +152,23 @@ access partway through the phase, so only the audit-logging guarantees
|
|||||||
were actually confirmed against a live database; the rest is untested
|
were actually confirmed against a live database; the rest is untested
|
||||||
beyond "compiles, and skips cleanly when no live database is
|
beyond "compiles, and skips cleanly when no live database is
|
||||||
configured" (see `/docs/phase-4-runbook.md`'s verification-status
|
configured" (see `/docs/phase-4-runbook.md`'s verification-status
|
||||||
section). Human OIDC login is now built too
|
section). Human SSO login is now built for both protocols
|
||||||
(`enterprise/internal/loginhandler`: `GET /auth/oidc/login` +
|
(`enterprise/internal/loginhandler`: `GET /auth/oidc/login` +
|
||||||
`GET /auth/oidc/callback`, issuing a real session cookie after resolving
|
`GET /auth/oidc/callback`, and `GET /auth/saml/login` +
|
||||||
tenant/role from `tenant_memberships`) — genuinely verified, unlike the
|
`POST /auth/saml/acs` via `enterprise/internal/saml`'s `crewjam/saml`
|
||||||
ClickHouse pieces, via a real fake IdP that signs and verifies actual
|
wiring, both issuing a real session cookie after resolving tenant/role
|
||||||
RS256 tokens (`loginhandler_test.go`, all passing), though never tried
|
from `tenant_memberships`) — genuinely verified, unlike the ClickHouse
|
||||||
against a real external IdP or through a running `enterprise-auth`
|
pieces, via a real fake IdP for each protocol that performs actual
|
||||||
container. Tantivy per-tenant index routing is now built too
|
cryptographic signing and verification (`coreos/go-oidc`'s `oidctest`
|
||||||
|
for OIDC, `crewjam/saml/samlidp` for SAML — `loginhandler_test.go` and
|
||||||
|
`saml_test.go`, all passing, including the full login round trip and
|
||||||
|
negative paths for both), though never tried against a real external IdP
|
||||||
|
or through a running `enterprise-auth` container. Writing the SAML test
|
||||||
|
caught and fixed two real bugs in `internal/saml.ParseResponse`: a
|
||||||
|
missing `r.ParseForm()` call that would have silently broken every real
|
||||||
|
ACS POST, and email-attribute matching that missed the standard LDAP
|
||||||
|
"mail" OID IdPs send by default. Tantivy per-tenant index routing is now
|
||||||
|
built too
|
||||||
(`search/src/registry.rs` + `enterprise/internal/searchclient`) —
|
(`search/src/registry.rs` + `enterprise/internal/searchclient`) —
|
||||||
**genuinely verified**, like the OIDC login flow: Tantivy is an embedded
|
**genuinely verified**, like the OIDC login flow: Tantivy is an embedded
|
||||||
library, not a networked service, so the isolation probe (three tenants,
|
library, not a networked service, so the isolation probe (three tenants,
|
||||||
@@ -172,11 +181,10 @@ RBAC/audit/SSO, rendering to the same Service name/port either way — a
|
|||||||
Helm-deployed cluster can't accidentally run the wrong one.
|
Helm-deployed cluster can't accidentally run the wrong one.
|
||||||
`docker-compose.yml` still runs plain `api` unconditionally, though
|
`docker-compose.yml` still runs plain `api` unconditionally, though
|
||||||
(local/dev parity with the Helm chart's enforcement is real remaining
|
(local/dev parity with the Helm chart's enforcement is real remaining
|
||||||
work). What still keeps this phase from being done: SAML login (protocol
|
work). What still keeps this phase from being done: ingest itself has no
|
||||||
wiring exists, no ACS handler calls it, following OIDC's now-built
|
tenant concept for either storage engine (every record lands in the one
|
||||||
pattern), ingest itself has no tenant concept for either storage engine
|
shared ClickHouse database and Tantivy index no matter what —
|
||||||
(every record lands in the one shared ClickHouse database and Tantivy
|
undesigned, not just unbuilt), and the two
|
||||||
index no matter what — undesigned, not just unbuilt), and the two
|
|
||||||
provisioning mechanisms (`deploy/operator`'s `Tenant` CRD and
|
provisioning mechanisms (`deploy/operator`'s `Tenant` CRD and
|
||||||
`enterprise-api -provision-tenant`) still aren't unified — running both
|
`enterprise-api -provision-tenant`) still aren't unified — running both
|
||||||
for the same tenant ID is two separate operator actions today. Full
|
for the same tenant ID is two separate operator actions today. Full
|
||||||
|
|||||||
+9
-5
@@ -241,11 +241,15 @@ services:
|
|||||||
# here so it can be built/run/curled like every other service, but
|
# here so it can be built/run/curled like every other service, but
|
||||||
# deliberately NOT wired into api's ENTERPRISE_AUTH_URL or alerting's
|
# deliberately NOT wired into api's ENTERPRISE_AUTH_URL or alerting's
|
||||||
# API_SERVICE_TOKEN below: turning that on makes every /query and
|
# API_SERVICE_TOKEN below: turning that on makes every /query and
|
||||||
# /dashboards request require a valid session/service token, and there
|
# /dashboards request require a valid session/service token. Both
|
||||||
# is no OIDC/SAML login flow built yet to issue a human one (see
|
# OIDC and SAML login flows now exist (enterprise/internal/loginhandler),
|
||||||
# enterprise/cmd/enterprise-auth/main.go's doc comment) -- flipping it
|
# but this compose file sets neither OIDC_ISSUER_URL nor
|
||||||
# on by default would break the web UI and sentryctl with no way to
|
# SAML_IDP_METADATA_URL, so both stay disabled here, and there's still
|
||||||
# log in. See enterprise/README.md for how to turn enforcement on for
|
# no admin UI to create the first tenant_memberships row -- see
|
||||||
|
# /docs/phase-4-runbook.md sections 3a/3b for wiring a real IdP and
|
||||||
|
# bootstrapping that row by hand. Flipping enforcement on by default
|
||||||
|
# without that would break the web UI and sentryctl with no way to log
|
||||||
|
# in. See enterprise/README.md for how to turn enforcement on for
|
||||||
# manual testing (mint a service token, set the two env vars, restart).
|
# manual testing (mint a service token, set the two env vars, restart).
|
||||||
enterprise-auth:
|
enterprise-auth:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ This split is not to be changed without discussion — see CLAUDE.md.
|
|||||||
| `search` (Rust, Phase 1) | Consumes the same Redpanda topic `ingest` does (own offset tracking), builds a Tantivy full-text index over `message`, serves matches over gRPC. Writes always go to one shared (default) index (`ingest` isn't tenant-aware); reads can be scoped per-tenant via `SearchRequest.tenant_id` and `src/registry.rs`'s `IndexRegistry` (Phase 4) — see "Tenant isolation" below. |
|
| `search` (Rust, Phase 1) | Consumes the same Redpanda topic `ingest` does (own offset tracking), builds a Tantivy full-text index over `message`, serves matches over gRPC. Writes always go to one shared (default) index (`ingest` isn't tenant-aware); reads can be scoped per-tenant via `SearchRequest.tenant_id` and `src/registry.rs`'s `IndexRegistry` (Phase 4) — see "Tenant isolation" below. |
|
||||||
| `api` (Go) | gRPC + REST gateway. `POST /query` compiles pipe-syntax or raw SQL to one IR, executed across ClickHouse/Tantivy (`/docs/query-language-design.md`). `internal/dashboards` is CRUD only — panel query execution happens client-side, reusing `/query`. `internal/authz` (Phase 4) enforces RBAC via a network call to `enterprise-auth`, never an import. |
|
| `api` (Go) | gRPC + REST gateway. `POST /query` compiles pipe-syntax or raw SQL to one IR, executed across ClickHouse/Tantivy (`/docs/query-language-design.md`). `internal/dashboards` is CRUD only — panel query execution happens client-side, reusing `/query`. `internal/authz` (Phase 4) enforces RBAC via a network call to `enterprise-auth`, never an import. |
|
||||||
| `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). |
|
| `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). |
|
||||||
| `enterprise` (Go, commercial license, Phase 4) | OIDC login (`internal/loginhandler`'s `/auth/oidc/login`+`/auth/oidc/callback`, real IdP round trip, verified with a fake IdP but not a real external one), RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Also `internal/searchclient` (per-tenant Tantivy routing, wired the same way into `search`). Does **not** yet include SAML's login ACS handler (protocol mechanics only) — see `/docs/security/threat-model.md`. |
|
| `enterprise` (Go, commercial license, Phase 4) | OIDC login (`internal/loginhandler`'s `/auth/oidc/login`+`/auth/oidc/callback`) and SAML login (`/auth/saml/login`+`/auth/saml/acs`, via `internal/saml`'s `crewjam/saml` wiring) — both a real IdP round trip, each verified with a real fake IdP (`coreos/go-oidc`'s `oidctest`, `crewjam/saml`'s `samlidp`) but not a real external one, RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Also `internal/searchclient` (per-tenant Tantivy routing, wired the same way into `search`). |
|
||||||
| `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling enterprise-licensed components. |
|
| `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling enterprise-licensed components. |
|
||||||
| `cli` (`sentryctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$SENTRYCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). |
|
| `cli` (`sentryctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$SENTRYCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). |
|
||||||
| `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. |
|
| `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. |
|
||||||
|
|||||||
+62
-10
@@ -24,6 +24,20 @@ of what was already run and passed. Two genuine exceptions:
|
|||||||
all, so this one was actually run in this runbook's own session, not
|
all, so this one was actually run in this runbook's own session, not
|
||||||
just an earlier one. What's still unverified is wiring it into a real
|
just an earlier one. What's still unverified is wiring it into a real
|
||||||
running `enterprise-auth` container against a real external IdP.
|
running `enterprise-auth` container against a real external IdP.
|
||||||
|
- `enterprise/internal/loginhandler`'s full SAML login flow (§3b) --
|
||||||
|
same bar as OIDC above, verified against a real fake SAML IdP
|
||||||
|
(`crewjam/saml/samlidp`: genuine XML signing and signature
|
||||||
|
verification, a real `AuthnRequest`/`Response` round trip), no Docker
|
||||||
|
needed. Writing this test caught two real bugs in
|
||||||
|
`enterprise/internal/saml`, now fixed: `ParseResponse` never called
|
||||||
|
`r.ParseForm()` before reading the POSTed `SAMLResponse` field (every
|
||||||
|
real ACS POST would have silently decoded to nothing), and the email-
|
||||||
|
attribute matching didn't recognize `urn:oid:0.9.2342.19200300.100.1.3`
|
||||||
|
(the standard LDAP "mail" OID), which is what an IdP sends by default
|
||||||
|
when the SP doesn't explicitly request an attribute literally named
|
||||||
|
"email" -- crewjam's own fake IdP hit this path. Same remaining gap as
|
||||||
|
OIDC: not yet tried against a real external IdP or a running
|
||||||
|
`enterprise-auth` container.
|
||||||
|
|
||||||
Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement
|
Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement
|
||||||
walkthrough, the dashboards tenant-scoping fix, the Helm chart, the
|
walkthrough, the dashboards tenant-scoping fix, the Helm chart, the
|
||||||
@@ -145,6 +159,44 @@ to create the first tenant membership) is exactly the kind of rough
|
|||||||
edge an admin UI would smooth over -- named as real future work, not
|
edge an admin UI would smooth over -- named as real future work, not
|
||||||
hidden.
|
hidden.
|
||||||
|
|
||||||
|
## 3b. `enterprise-auth`: human login via SAML (new -- same "verified
|
||||||
|
live in this session, not against a real running container or a real
|
||||||
|
external IdP" caveat as §3a)
|
||||||
|
|
||||||
|
`enterprise/internal/loginhandler`'s SAML tests already prove the
|
||||||
|
mechanism works end to end against a real fake SAML IdP (`go test
|
||||||
|
./internal/loginhandler/... -run SAML -v` from `enterprise/`, no Docker
|
||||||
|
needed). What's still unverified is wiring it into this actual running
|
||||||
|
stack. To try that for real, point `docker-compose.yml`'s
|
||||||
|
`enterprise-auth` service at a real SAML IdP (many identity providers
|
||||||
|
offer a free developer/trial tenant with SAML app support):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Add to enterprise-auth's environment in docker-compose.yml (or a
|
||||||
|
# docker-compose.override.yml):
|
||||||
|
# SAML_ENTITY_ID: "http://localhost:8082/saml/metadata"
|
||||||
|
# SAML_ACS_URL: "http://localhost:8082/auth/saml/acs"
|
||||||
|
# SAML_IDP_METADATA_URL: "https://your-idp.example.com/metadata"
|
||||||
|
# Register SAML_ENTITY_ID/SAML_ACS_URL with the IdP's application config
|
||||||
|
# -- the IdP needs Sentry's ACS URL to know where to POST the assertion.
|
||||||
|
|
||||||
|
docker compose up -d --build enterprise-auth
|
||||||
|
curl -s http://localhost:8082/auth/features
|
||||||
|
# expect: {"sso_configured":true,"oidc_enabled":false,"saml_enabled":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrapping the first `tenant_memberships` row is the same manual-SQL
|
||||||
|
dance as §3a (log in once, it fails with 403, insert the membership
|
||||||
|
using the `users` row that got created, log in again). Then visit
|
||||||
|
`http://localhost:8082/auth/saml/login` in a real browser, complete the
|
||||||
|
IdP's login, and confirm a `sentry_session` cookie lands after redirect
|
||||||
|
to `POST_LOGIN_REDIRECT_URL`. Note SAML's `sentry_saml_request` cookie
|
||||||
|
is `SameSite=None`, which requires `Secure` -- i.e. this only works over
|
||||||
|
HTTPS in a real deployment, unlike OIDC's redirect-based callback which
|
||||||
|
tolerates plain HTTP for local dev (see
|
||||||
|
`enterprise/internal/loginhandler/loginhandler.go`'s `handleSAMLLogin`
|
||||||
|
doc comment for why).
|
||||||
|
|
||||||
## 4. Turn on RBAC enforcement and prove it actually blocks/allows
|
## 4. Turn on RBAC enforcement and prove it actually blocks/allows
|
||||||
|
|
||||||
Without touching the main stack's `api` container (so step 2's baseline
|
Without touching the main stack's `api` container (so step 2's baseline
|
||||||
@@ -164,11 +216,11 @@ docker stop sentry-api-enforced
|
|||||||
```
|
```
|
||||||
|
|
||||||
`GET /dashboards` on the same enforced instance should return 401
|
`GET /dashboards` on the same enforced instance should return 401
|
||||||
without a token — there's no way to mint a human (Viewer/Editor/etc.)
|
without a token — this section only demonstrates the service-token path
|
||||||
session yet (no OIDC/SAML login handler exists — see
|
(§3/§3a/§3b cover minting a real human session via OIDC or SAML); walking
|
||||||
`enterprise/cmd/enterprise-auth/main.go`'s doc comment), so this
|
that session cookie through this same enforced instance to get a 200 is
|
||||||
runbook can't walk through a real human RBAC scenario end to end. That
|
left as the natural next verification step once real Docker/K8s access
|
||||||
gap is real, not an oversight in this runbook.
|
exists, not yet done in this runbook.
|
||||||
|
|
||||||
## 5. Dashboards tenant scoping
|
## 5. Dashboards tenant scoping
|
||||||
|
|
||||||
@@ -363,11 +415,11 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
|
|||||||
and the one shared Tantivy index no matter what. A newly-provisioned
|
and the one shared Tantivy index no matter what. A newly-provisioned
|
||||||
tenant's storage is real, isolated at query time, and permanently
|
tenant's storage is real, isolated at query time, and permanently
|
||||||
empty until this changes — undesigned, not just unbuilt.
|
empty until this changes — undesigned, not just unbuilt.
|
||||||
- **Human SSO login now works for OIDC** (§3a) -- verified with a real
|
- **Human SSO login now works for both OIDC (§3a) and SAML (§3b)** --
|
||||||
fake IdP, not yet a real external one or a running `enterprise-auth`
|
each verified with a real fake IdP (genuine cryptographic signing and
|
||||||
container. **SAML login still doesn't exist** -- protocol wiring only,
|
verification), not yet a real external IdP or a running
|
||||||
no ACS handler. No tenant-picker UI for a multi-membership identity
|
`enterprise-auth` container. No tenant-picker UI for a multi-membership
|
||||||
either (refused outright).
|
identity either (refused outright) for either protocol.
|
||||||
- No admin UI to create a `tenant_memberships` row -- §3a's manual SQL
|
- No admin UI to create a `tenant_memberships` row -- §3a's manual SQL
|
||||||
bootstrap is the only way to grant a logged-in identity access today.
|
bootstrap is the only way to grant a logged-in identity access today.
|
||||||
- **No per-resource dashboard grants** (`dashboard_permissions` has a
|
- **No per-resource dashboard grants** (`dashboard_permissions` has a
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ to `enterprise-auth` — see "Deployment/network assumptions" below.
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
**Implemented for OIDC, still missing for SAML.**
|
**Implemented for both OIDC and SAML, to the same verification bar.**
|
||||||
`enterprise/internal/loginhandler` serves `GET /auth/oidc/login`
|
`enterprise/internal/loginhandler` serves `GET /auth/oidc/login`
|
||||||
(redirects to the configured IdP, with a short-lived HttpOnly cookie
|
(redirects to the configured IdP, with a short-lived HttpOnly cookie
|
||||||
carrying CSRF-protection state) and `GET /auth/oidc/callback`
|
carrying CSRF-protection state) and `GET /auth/oidc/callback`
|
||||||
@@ -138,29 +138,46 @@ carrying CSRF-protection state) and `GET /auth/oidc/callback`
|
|||||||
`enterprise/internal/oidc`'s real `coreos/go-oidc` wiring, upserts a
|
`enterprise/internal/oidc`'s real `coreos/go-oidc` wiring, upserts a
|
||||||
`users` row keyed by SSO subject, resolves tenant/role from
|
`users` row keyed by SSO subject, resolves tenant/role from
|
||||||
`tenant_memberships`, and issues a `session.Manager`-signed session
|
`tenant_memberships`, and issues a `session.Manager`-signed session
|
||||||
cookie). Verified end-to-end with real cryptography, not mocked: the
|
cookie), plus the SAML equivalent, `GET /auth/saml/login` (redirects to
|
||||||
tests spin up a real fake IdP (`coreos/go-oidc`'s own `oidctest`
|
the configured IdP via `enterprise/internal/saml`'s
|
||||||
package) that signs genuine RS256 ID tokens, and
|
`ServiceProvider.LoginURL`, persisting the AuthnRequest ID in a
|
||||||
`enterprise/internal/loginhandler`'s handler verifies them for real via
|
short-lived cookie — SAML's replay/unsolicited-response defense,
|
||||||
the same code path production uses — every test in
|
standing in for OIDC's `state`) and `POST /auth/saml/acs` (validates the
|
||||||
`loginhandler_test.go` passes, including the full login→callback→
|
assertion's signature and `InResponseTo` against that cookie via
|
||||||
session-cookie round trip. **Not yet verified**: wiring this into a
|
`ServiceProvider.ParseResponse`, then converges on the same
|
||||||
running `enterprise-auth` container against a *real* external IdP
|
upsert/resolve/issue-session path OIDC uses). Both are verified
|
||||||
(Google/Okta/etc.) — that needs real IdP credentials and a reachable
|
end-to-end with real cryptography, not mocked: OIDC's tests spin up a
|
||||||
callback URL neither of which this environment has; see
|
real fake IdP (`coreos/go-oidc`'s own `oidctest` package) that signs
|
||||||
`/docs/phase-4-runbook.md`.
|
genuine RS256 ID tokens; SAML's tests spin up a real fake IdP
|
||||||
|
(`crewjam/saml/samlidp`) that builds and signs genuine SAML assertions
|
||||||
|
and XML-signs the response, exercising the same `ServiceProvider.
|
||||||
|
ParseResponse` signature-verification path production uses. Every test
|
||||||
|
in `loginhandler_test.go` and `saml_test.go` passes, including the full
|
||||||
|
login→callback/ACS→session-cookie round trip for both protocols, and
|
||||||
|
negative-path tests for each (state/`InResponseTo` mismatch, missing/
|
||||||
|
expired credential, missing required claim, no/multiple tenant
|
||||||
|
memberships). Writing the SAML test caught two real bugs in
|
||||||
|
`enterprise/internal/saml`'s `ParseResponse`, both fixed before this
|
||||||
|
verification was considered complete: it never called `r.ParseForm()`
|
||||||
|
before reading the POSTed `SAMLResponse` field (every real ACS POST
|
||||||
|
would have decoded an empty response), and its email-attribute matching
|
||||||
|
missed `urn:oid:0.9.2342.19200300.100.1.3` (the standard LDAP "mail"
|
||||||
|
OID) — what an IdP sends by default when the SP hasn't explicitly
|
||||||
|
requested an attribute literally named "email", which is exactly what
|
||||||
|
`samlidp`'s own default assertion builder does. **Not yet verified for
|
||||||
|
either protocol**: wiring this into a running `enterprise-auth`
|
||||||
|
container against a *real* external IdP (Google/Okta/etc.) — that needs
|
||||||
|
real IdP credentials and a reachable callback/ACS URL neither of which
|
||||||
|
this environment has; see `/docs/phase-4-runbook.md`.
|
||||||
|
|
||||||
A user with zero or more than one `tenant_memberships` row is refused
|
A user with zero or more than one `tenant_memberships` row is refused
|
||||||
outright (403 / 501 respectively) rather than guessed at — a
|
outright (403 / 501 respectively) rather than guessed at — a
|
||||||
tenant-selection UI for the multi-membership case is real, undesigned
|
tenant-selection UI for the multi-membership case is real, undesigned
|
||||||
future work, not silently approximated. `enterprise/internal/saml` still
|
future work, not silently approximated, for either protocol.
|
||||||
only does the protocol mechanics (AuthnRequest generation, assertion
|
|
||||||
validation) with no ACS HTTP handler calling it — SAML login remains
|
|
||||||
unimplemented, following `loginhandler`'s OIDC pattern once it is built.
|
|
||||||
`GET /auth/features` (`enterprise/internal/authhandler`) reports whether
|
`GET /auth/features` (`enterprise/internal/authhandler`) reports whether
|
||||||
OIDC/SAML are *configured*, for `/web`'s settings page to conditionally
|
OIDC/SAML are *configured*, for `/web`'s settings page to conditionally
|
||||||
render — independent of whether a login button actually exists yet in
|
render — independent of whether a login button actually exists yet in
|
||||||
the UI (it doesn't; only the two HTTP endpoints do).
|
the UI (it doesn't; only the HTTP endpoints do).
|
||||||
|
|
||||||
**Implemented for the one machine caller.** `/alerting`'s evaluator is
|
**Implemented for the one machine caller.** `/alerting`'s evaluator is
|
||||||
the sole service-to-service caller (`POST /query`, to evaluate rule
|
the sole service-to-service caller (`POST /query`, to evaluate rule
|
||||||
@@ -370,7 +387,7 @@ terms:
|
|||||||
| Deployment actually routing traffic to `enterprise-api` (Helm) | **Enforced** — `api`/`enterprise-api` are mutually exclusive, same flag as RBAC/audit/SSO |
|
| Deployment actually routing traffic to `enterprise-api` (Helm) | **Enforced** — `api`/`enterprise-api` are mutually exclusive, same flag as RBAC/audit/SSO |
|
||||||
| Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Not implemented** — `docker-compose.yml` runs plain `api` unconditionally |
|
| Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Not implemented** — `docker-compose.yml` runs plain `api` unconditionally |
|
||||||
| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
|
| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
|
||||||
| Human SSO login — SAML | **Not implemented** |
|
| Human SSO login — SAML | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
|
||||||
| Multi-tenant-membership login (tenant picker) | **Not implemented** — refused with a clear error, not guessed |
|
| Multi-tenant-membership login (tenant picker) | **Not implemented** — refused with a clear error, not guessed |
|
||||||
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
|
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
|
||||||
| Query audit logging (routine queries) | **Enforced**, fail-open, and now wired to a real writer via `enterprise-api` (`audit.QueryAPILogger`) |
|
| Query audit logging (routine queries) | **Enforced**, fail-open, and now wired to a real writer via `enterprise-api` (`audit.QueryAPILogger`) |
|
||||||
|
|||||||
+24
-9
@@ -86,12 +86,27 @@ section for exactly what "not yet run" means here and why. Don't read
|
|||||||
same "offline action, not a network endpoint" shape as
|
same "offline action, not a network endpoint" shape as
|
||||||
`enterprise-auth -mint-service-token`.
|
`enterprise-auth -mint-service-token`.
|
||||||
|
|
||||||
|
OIDC and SAML login are both now fully wired: `internal/loginhandler`
|
||||||
|
serves `GET /auth/oidc/login`+`GET /auth/oidc/callback` and
|
||||||
|
`GET /auth/saml/login`+`POST /auth/saml/acs`, converging on the same
|
||||||
|
upsert-user/resolve-tenant/issue-session path. Both are verified the
|
||||||
|
same way -- a real fake IdP with genuine cryptographic signing and
|
||||||
|
verification (`coreos/go-oidc`'s `oidctest` for OIDC,
|
||||||
|
`crewjam/saml/samlidp` for SAML), no Docker needed, every test in
|
||||||
|
`loginhandler_test.go`/`saml_test.go` passing including the full login
|
||||||
|
round trip and negative paths (bad state/`InResponseTo`, expired/missing
|
||||||
|
credential, no/multiple tenant memberships). Writing the SAML test
|
||||||
|
caught two real bugs in `internal/saml.ParseResponse`, both fixed:
|
||||||
|
missing `r.ParseForm()` before reading the POSTed `SAMLResponse` field,
|
||||||
|
and email-attribute matching that missed the standard LDAP "mail" OID
|
||||||
|
(`urn:oid:0.9.2342.19200300.100.1.3`) that IdPs send by default absent
|
||||||
|
an explicit `AttributeConsumingService` request for "email" -- exactly
|
||||||
|
what `samlidp`'s own default assertion builder does. Neither protocol
|
||||||
|
has been tried against a real external IdP or a running
|
||||||
|
`enterprise-auth` container -- see `/docs/phase-4-runbook.md` §3a/§3b.
|
||||||
|
|
||||||
**Deliberately deferred, not half-built** -- named explicitly rather than
|
**Deliberately deferred, not half-built** -- named explicitly rather than
|
||||||
silently left out:
|
silently left out:
|
||||||
- SAML's login handler (the ACS endpoint) -- `internal/saml` does the
|
|
||||||
protocol mechanics (AuthnRequest generation, assertion validation);
|
|
||||||
nothing calls it from an HTTP handler, following `internal/
|
|
||||||
loginhandler`'s now-built OIDC pattern once someone builds it.
|
|
||||||
- A tenant-picker UI/flow for an identity with more than one
|
- A tenant-picker UI/flow for an identity with more than one
|
||||||
`tenant_memberships` row -- `loginhandler` refuses these logins
|
`tenant_memberships` row -- `loginhandler` refuses these logins
|
||||||
outright rather than guessing (`ErrMultipleMemberships`).
|
outright rather than guessing (`ErrMultipleMemberships`).
|
||||||
@@ -123,7 +138,7 @@ internal/oidc/ coreos/go-oidc wiring: discovery, login redirect, code
|
|||||||
internal/saml/ crewjam/saml wiring: SP setup, login redirect, response parsing/validation
|
internal/saml/ crewjam/saml wiring: SP setup, login redirect, response parsing/validation
|
||||||
internal/session/ issues/validates signed session + RoleService tokens
|
internal/session/ issues/validates signed session + RoleService tokens
|
||||||
internal/authhandler/ POST /internal/authorize, GET /auth/features
|
internal/authhandler/ POST /internal/authorize, GET /auth/features
|
||||||
internal/loginhandler/ GET /auth/oidc/login, GET /auth/oidc/callback -- the human login flow
|
internal/loginhandler/ GET /auth/oidc/{login,callback} + GET /auth/saml/login + POST /auth/saml/acs -- the human login flow
|
||||||
internal/rbacstore/ users/tenants/tenant_memberships/data_sources CRUD (pgx against sentry_metadata)
|
internal/rbacstore/ users/tenants/tenant_memberships/data_sources CRUD (pgx against sentry_metadata)
|
||||||
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
|
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
|
||||||
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
|
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
|
||||||
@@ -134,9 +149,9 @@ internal/apiconfig/ enterprise-api's own env-var config
|
|||||||
internal/config/ enterprise-auth's env-var config
|
internal/config/ enterprise-auth's env-var config
|
||||||
```
|
```
|
||||||
|
|
||||||
Future additions: SAML's login handler, `dashboard_permissions` CRUD,
|
Future additions: `dashboard_permissions` CRUD, ingest tenant-awareness
|
||||||
ingest tenant-awareness (undesigned), and real deployment-topology
|
(undesigned), and real deployment-topology wiring for `enterprise-api`
|
||||||
wiring for `enterprise-api` -- see "Status" above.
|
-- see "Status" above.
|
||||||
|
|
||||||
## Why OIDC and SAML aren't hand-rolled
|
## Why OIDC and SAML aren't hand-rolled
|
||||||
|
|
||||||
@@ -246,7 +261,7 @@ edit today, not a supported flag.
|
|||||||
| `OIDC_REDIRECT_URL` | (empty — must be `<enterprise-auth base URL>/auth/oidc/callback`, registered with the IdP) |
|
| `OIDC_REDIRECT_URL` | (empty — must be `<enterprise-auth base URL>/auth/oidc/callback`, registered with the IdP) |
|
||||||
| `SAML_ENTITY_ID` | (empty) |
|
| `SAML_ENTITY_ID` | (empty) |
|
||||||
| `SAML_ACS_URL` | (empty) |
|
| `SAML_ACS_URL` | (empty) |
|
||||||
| `SAML_IDP_METADATA_URL` | (empty — presence only feeds `GET /auth/features`; not yet fetched/parsed) |
|
| `SAML_IDP_METADATA_URL` | (empty — SAML disabled if unset; if set, fetched and parsed at startup via `samlsp.FetchMetadata`, same trust level as `OIDC_ISSUER_URL`'s discovery fetch) |
|
||||||
| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes |
|
| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes |
|
||||||
| `POST_LOGIN_REDIRECT_URL` | `http://localhost:3000` — where the browser lands after `internal/loginhandler` sets a session cookie |
|
| `POST_LOGIN_REDIRECT_URL` | `http://localhost:3000` — where the browser lands after `internal/loginhandler` sets a session cookie |
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,16 @@
|
|||||||
//
|
//
|
||||||
// Wires session issuance/validation (internal/session), the
|
// Wires session issuance/validation (internal/session), the
|
||||||
// POST /internal/authorize endpoint api/authz.HTTPAuthorizer calls (the
|
// POST /internal/authorize endpoint api/authz.HTTPAuthorizer calls (the
|
||||||
// piece that turns on RBAC enforcement in /api), and -- since
|
// piece that turns on RBAC enforcement in /api), and --
|
||||||
// internal/loginhandler -- the real GET /auth/oidc/login and
|
// internal/loginhandler -- the real GET /auth/{oidc,saml}/login and
|
||||||
// GET /auth/oidc/callback handlers that issue a *human* session after
|
// GET /auth/oidc/callback + POST /auth/saml/acs handlers that issue a
|
||||||
// an actual IdP round trip, resolving tenant/role via internal/rbacstore.
|
// *human* session after an actual IdP round trip, resolving tenant/role
|
||||||
// Still deliberately missing: SAML's equivalent (ACS endpoint) -- same
|
// via internal/rbacstore. Both protocols are now fully wired -- OIDC via
|
||||||
// shape, not yet built, following internal/loginhandler's OIDC pattern
|
// discovery, SAML via fetching+parsing SAML_IDP_METADATA_URL at startup
|
||||||
// once it is. What's fully wired: -mint-service-token (the RoleService
|
// (crewjam/saml's samlsp.FetchMetadata; a trusted operator-supplied URL,
|
||||||
// credential /alerting presents) and, when OIDC_ISSUER_URL is
|
// same trust level as OIDC_ISSUER_URL's discovery fetch, not
|
||||||
// configured, a real human login flow.
|
// end-user-controlled input). Also fully wired: -mint-service-token
|
||||||
|
// (the RoleService credential /alerting presents).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,12 +22,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml/samlsp"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/sentry/sentry/enterprise/internal/authhandler"
|
"github.com/sentry/sentry/enterprise/internal/authhandler"
|
||||||
@@ -34,6 +37,7 @@ import (
|
|||||||
"github.com/sentry/sentry/enterprise/internal/loginhandler"
|
"github.com/sentry/sentry/enterprise/internal/loginhandler"
|
||||||
"github.com/sentry/sentry/enterprise/internal/oidc"
|
"github.com/sentry/sentry/enterprise/internal/oidc"
|
||||||
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
||||||
|
samlpkg "github.com/sentry/sentry/enterprise/internal/saml"
|
||||||
"github.com/sentry/sentry/enterprise/internal/session"
|
"github.com/sentry/sentry/enterprise/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,6 +118,33 @@ func main() {
|
|||||||
logger.Info("OIDC not configured (OIDC_ISSUER_URL unset) -- skipping discovery, /auth/oidc/* routes disabled")
|
logger.Info("OIDC not configured (OIDC_ISSUER_URL unset) -- skipping discovery, /auth/oidc/* routes disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// samlProvider stays nil (loginhandler.RegisterRoutes then registers
|
||||||
|
// nothing) unless SAML is actually configured -- same shape as OIDC
|
||||||
|
// above.
|
||||||
|
var samlProvider *samlpkg.ServiceProvider
|
||||||
|
if cfg.SAML.IDPMetadataURL != "" {
|
||||||
|
metadataURL, err := url.Parse(cfg.SAML.IDPMetadataURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("parsing SAML_IDP_METADATA_URL", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
idpMetadata, err := samlsp.FetchMetadata(ctx, http.DefaultClient, *metadataURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("fetching SAML IdP metadata", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
samlProvider, err = samlpkg.New(samlpkg.Config{
|
||||||
|
EntityID: cfg.SAML.EntityID, ACSURL: cfg.SAML.ACSURL, IDPMetadata: idpMetadata,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("constructing SAML service provider", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.Info("SAML provider configured", "idp_metadata_url", cfg.SAML.IDPMetadataURL)
|
||||||
|
} else {
|
||||||
|
logger.Info("SAML not configured (SAML_IDP_METADATA_URL unset) -- /auth/saml/* routes disabled")
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -123,7 +154,7 @@ func main() {
|
|||||||
SAMLEnabled: cfg.SAML.IDPMetadataURL != "",
|
SAMLEnabled: cfg.SAML.IDPMetadataURL != "",
|
||||||
}
|
}
|
||||||
authhandler.New(logger, sessionManager, features).RegisterRoutes(mux)
|
authhandler.New(logger, sessionManager, features).RegisterRoutes(mux)
|
||||||
loginhandler.New(logger, oidcProvider, sessionManager, rbac, cfg.PostLoginRedirectURL).RegisterRoutes(mux)
|
loginhandler.New(logger, oidcProvider, samlProvider, sessionManager, rbac, cfg.PostLoginRedirectURL).RegisterRoutes(mux)
|
||||||
|
|
||||||
srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: mux}
|
srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: mux}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
@@ -37,13 +37,11 @@ type OIDCConfig struct {
|
|||||||
RedirectURL string
|
RedirectURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAMLConfig is likewise optional. Note this only records *presence* --
|
// SAMLConfig is likewise optional. cmd/enterprise-auth/main.go fetches
|
||||||
// enough for /auth/features (internal/authhandler) to report
|
// and parses IDPMetadataURL into the *saml.EntityDescriptor
|
||||||
// saml_enabled -- it does not itself fetch/parse IDPMetadataURL into the
|
// internal/saml.New requires at startup (crewjam/saml's
|
||||||
// *saml.EntityDescriptor internal/saml.New requires; that fetch (and the
|
// samlsp.FetchMetadata) -- this struct just carries the raw config
|
||||||
// login/ACS HTTP handlers that would use it) is deferred, same as OIDC's
|
// values this package's job (env-var loading) is scoped to.
|
||||||
// login/callback handlers -- see cmd/enterprise-auth/main.go's doc
|
|
||||||
// comment.
|
|
||||||
type SAMLConfig struct {
|
type SAMLConfig struct {
|
||||||
EntityID string
|
EntityID string
|
||||||
ACSURL string
|
ACSURL string
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
// the actual HTTP login/callback flow that issues a *human* session,
|
// the actual HTTP login/callback flow that issues a *human* session,
|
||||||
// not just /alerting's RoleService credential (-mint-service-token) or
|
// not just /alerting's RoleService credential (-mint-service-token) or
|
||||||
// the RBAC-enforcement plumbing that assumes a session already exists.
|
// the RBAC-enforcement plumbing that assumes a session already exists.
|
||||||
// enterprise/internal/oidc does the OAuth2/OIDC protocol mechanics
|
// enterprise/internal/oidc and enterprise/internal/saml do the protocol
|
||||||
// (discovery, the auth-code redirect, code exchange, ID token
|
// mechanics (discovery/AuthnRequest generation, code exchange/assertion
|
||||||
// verification); this package is the two HTTP handlers that drive it
|
// parsing, signature verification); this package is the HTTP handlers
|
||||||
// and decide what happens with a verified identity: look up or create a
|
// that drive them and decide what happens with a verified identity: look
|
||||||
// users row, resolve which tenant/role that user belongs to, and issue
|
// up or create a users row, resolve which tenant/role that user belongs
|
||||||
// a session.Manager-signed session cookie.
|
// to, and issue a session.Manager-signed session cookie. Both protocols
|
||||||
|
// share that decision (resolveIdentity below) -- only how the identity
|
||||||
|
// gets verified differs.
|
||||||
//
|
//
|
||||||
// Deliberately out of scope here: SAML's equivalent (ACS endpoint) --
|
// Multi-tenant users (one identity with memberships in more than one
|
||||||
// same shape, not yet built, following this package's pattern once it
|
// tenant) are refused with a clear error rather than guessing which
|
||||||
// is. Multi-tenant users (one identity with memberships in more than
|
// tenant to log them into -- a tenant-selection step is real, undesigned
|
||||||
// one tenant) are refused with a clear error rather than guessing which
|
// future work, not silently approximated.
|
||||||
// tenant to log them into -- a tenant-selection step is real,
|
|
||||||
// undesigned future work, not silently approximated.
|
|
||||||
package loginhandler
|
package loginhandler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,20 +28,28 @@ import (
|
|||||||
"github.com/sentry/sentry/enterprise/internal/authhandler"
|
"github.com/sentry/sentry/enterprise/internal/authhandler"
|
||||||
"github.com/sentry/sentry/enterprise/internal/oidc"
|
"github.com/sentry/sentry/enterprise/internal/oidc"
|
||||||
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/saml"
|
||||||
"github.com/sentry/sentry/enterprise/internal/session"
|
"github.com/sentry/sentry/enterprise/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stateCookieName carries the CSRF-protection state value between the
|
// oidcStateCookieName carries OIDC's CSRF-protection state value between
|
||||||
// login redirect and the callback -- a short-lived, scoped-to-the-
|
// the login redirect and the callback -- a short-lived, scoped-to-the-
|
||||||
// callback-path cookie (the "double-submit cookie" pattern) rather than
|
// callback-path cookie (the "double-submit cookie" pattern) rather than
|
||||||
// server-side state, since this service otherwise has no per-browser
|
// server-side state, since this service otherwise has no per-browser
|
||||||
// session store to put it in before a session exists.
|
// session store to put it in before a session exists.
|
||||||
const stateCookieName = "sentry_oidc_state"
|
const oidcStateCookieName = "sentry_oidc_state"
|
||||||
|
|
||||||
// stateCookieTTL bounds how long a user has to complete the IdP round
|
// samlRequestCookieName is SAML's analog -- carries the AuthnRequest ID
|
||||||
|
// LoginURL generated, so the ACS handler can pass it back to
|
||||||
|
// ParseResponse's possibleRequestIDs (SAML's actual replay/unsolicited-
|
||||||
|
// response defense -- see saml.ServiceProvider.LoginURL's doc comment).
|
||||||
|
const samlRequestCookieName = "sentry_saml_request"
|
||||||
|
|
||||||
|
// loginCookieTTL bounds how long a user has to complete the IdP round
|
||||||
// trip -- generous enough for a real login form, short enough that a
|
// trip -- generous enough for a real login form, short enough that a
|
||||||
// stale state cookie isn't a long-lived CSRF token sitting in a browser.
|
// stale cookie isn't a long-lived CSRF token sitting in a browser.
|
||||||
const stateCookieTTL = 10 * time.Minute
|
// Shared by both protocols' cookies.
|
||||||
|
const loginCookieTTL = 10 * time.Minute
|
||||||
|
|
||||||
// userStore is the narrow interface Handler depends on -- *rbacstore.Store
|
// userStore is the narrow interface Handler depends on -- *rbacstore.Store
|
||||||
// is the production implementation; tests use a fake, same pattern used
|
// is the production implementation; tests use a fake, same pattern used
|
||||||
@@ -60,9 +68,16 @@ type oidcProvider interface {
|
|||||||
Exchange(ctx context.Context, code string) (*oidc.Claims, error)
|
Exchange(ctx context.Context, code string) (*oidc.Claims, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// samlProvider mirrors oidcProvider's reasoning for SAML.
|
||||||
|
type samlProvider interface {
|
||||||
|
LoginURL(relayState string) (redirectURL, requestID string, err error)
|
||||||
|
ParseResponse(r *http.Request, possibleRequestIDs []string) (*saml.Claims, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
oidc oidcProvider // nil if OIDC isn't configured -- RegisterRoutes registers nothing in that case
|
oidc oidcProvider // nil if OIDC isn't configured -- RegisterRoutes registers nothing in that case
|
||||||
|
saml samlProvider // nil if SAML isn't configured -- same
|
||||||
session *session.Manager
|
session *session.Manager
|
||||||
users userStore
|
users userStore
|
||||||
// postLoginRedirectURL is where the browser lands after a session
|
// postLoginRedirectURL is where the browser lands after a session
|
||||||
@@ -70,36 +85,46 @@ type Handler struct {
|
|||||||
postLoginRedirectURL string
|
postLoginRedirectURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New takes a concrete *oidc.Provider (nilable), not the oidcProvider
|
// New takes concrete *oidc.Provider/*saml.ServiceProvider (both
|
||||||
// interface directly -- a nil *oidc.Provider assigned straight into an
|
// nilable), not the narrower interfaces directly -- assigning a nil
|
||||||
// interface-typed field would produce a non-nil interface wrapping a
|
// pointer straight into an interface-typed field would produce a
|
||||||
// nil pointer (Go's classic typed-nil trap), which would silently break
|
// non-nil interface wrapping a nil pointer (Go's classic typed-nil
|
||||||
// RegisterRoutes'/handleLogin's `h.oidc == nil` checks the moment a
|
// trap), which would silently break RegisterRoutes'/the handlers'
|
||||||
// caller (enterprise-auth's main.go) passes a `var p *oidc.Provider`
|
// `h.oidc == nil`/`h.saml == nil` checks the moment a caller
|
||||||
// that's legitimately still nil because OIDC isn't configured. Checking
|
// (enterprise-auth's main.go) passes a `var p *oidc.Provider` that's
|
||||||
// the concrete pointer here, before it ever becomes the interface
|
// legitimately still nil because that protocol isn't configured.
|
||||||
// field, is what keeps that check meaningful.
|
// Checking the concrete pointers here, before they ever become the
|
||||||
func New(logger *slog.Logger, provider *oidc.Provider, sessionManager *session.Manager, users userStore, postLoginRedirectURL string) *Handler {
|
// interface fields, is what keeps those checks meaningful -- see
|
||||||
|
// loginhandler_test.go's TestRegisterRoutesNoOpWithTypedNilProviderVariable
|
||||||
|
// for the regression test that caught this the first time (OIDC; SAML
|
||||||
|
// follows the same fix from day one).
|
||||||
|
func New(logger *slog.Logger, oidcProvider *oidc.Provider, samlProvider *saml.ServiceProvider, sessionManager *session.Manager, users userStore, postLoginRedirectURL string) *Handler {
|
||||||
h := &Handler{logger: logger, session: sessionManager, users: users, postLoginRedirectURL: postLoginRedirectURL}
|
h := &Handler{logger: logger, session: sessionManager, users: users, postLoginRedirectURL: postLoginRedirectURL}
|
||||||
if provider != nil {
|
if oidcProvider != nil {
|
||||||
h.oidc = provider
|
h.oidc = oidcProvider
|
||||||
|
}
|
||||||
|
if samlProvider != nil {
|
||||||
|
h.saml = samlProvider
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers OIDC's two routes only if OIDC is actually
|
// RegisterRoutes registers each protocol's routes only if that protocol
|
||||||
// configured (h.oidc != nil) -- matches the "absent, not broken" default
|
// is actually configured -- matches the "absent, not broken" default
|
||||||
// every other optional-config path in this codebase follows (e.g.
|
// every other optional-config path in this codebase follows (e.g.
|
||||||
// api/authz.RequireRole's nil-authorizer no-op).
|
// api/authz.RequireRole's nil-authorizer no-op).
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
if h.oidc == nil {
|
if h.oidc != nil {
|
||||||
return
|
mux.HandleFunc("GET /auth/oidc/login", h.handleOIDCLogin)
|
||||||
|
mux.HandleFunc("GET /auth/oidc/callback", h.handleOIDCCallback)
|
||||||
|
}
|
||||||
|
if h.saml != nil {
|
||||||
|
mux.HandleFunc("GET /auth/saml/login", h.handleSAMLLogin)
|
||||||
|
mux.HandleFunc("POST /auth/saml/acs", h.handleSAMLACS)
|
||||||
}
|
}
|
||||||
mux.HandleFunc("GET /auth/oidc/login", h.handleLogin)
|
|
||||||
mux.HandleFunc("GET /auth/oidc/callback", h.handleCallback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
state, err := oidc.NewState()
|
state, err := oidc.NewState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("generating oidc state", "error", err)
|
h.logger.Error("generating oidc state", "error", err)
|
||||||
@@ -107,29 +132,29 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: stateCookieName, Value: state, Path: "/auth/oidc/callback",
|
Name: oidcStateCookieName, Value: state, Path: "/auth/oidc/callback",
|
||||||
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: int(stateCookieTTL.Seconds()),
|
MaxAge: int(loginCookieTTL.Seconds()),
|
||||||
})
|
})
|
||||||
http.Redirect(w, r, h.oidc.AuthCodeURL(state), http.StatusFound)
|
http.Redirect(w, r, h.oidc.AuthCodeURL(state), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearStateCookie is called on every path out of handleCallback --
|
// clearCookie is called on every path out of the two callback handlers
|
||||||
// the state cookie is single-use regardless of whether the login
|
// below -- the state/request cookie is single-use regardless of whether
|
||||||
// ultimately succeeds, same reasoning a CSRF token gets discarded after
|
// the login ultimately succeeds, same reasoning a CSRF token gets
|
||||||
// one use rather than left around for reuse.
|
// discarded after one use rather than left around for reuse.
|
||||||
func clearStateCookie(w http.ResponseWriter, r *http.Request) {
|
func clearCookie(w http.ResponseWriter, r *http.Request, name, path string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: stateCookieName, Value: "", Path: "/auth/oidc/callback",
|
Name: name, Value: "", Path: path,
|
||||||
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
defer clearStateCookie(w, r)
|
defer clearCookie(w, r, oidcStateCookieName, "/auth/oidc/callback")
|
||||||
|
|
||||||
stateCookie, err := r.Cookie(stateCookieName)
|
stateCookie, err := r.Cookie(oidcStateCookieName)
|
||||||
if err != nil || stateCookie.Value == "" {
|
if err != nil || stateCookie.Value == "" {
|
||||||
http.Error(w, "missing or expired login state -- start over at /auth/oidc/login", http.StatusBadRequest)
|
http.Error(w, "missing or expired login state -- start over at /auth/oidc/login", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -156,9 +181,79 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
identity, status, err := h.resolveIdentity(r.Context(), claims)
|
h.finishLogin(w, r, claims.Subject, claims.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleSAMLLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// relayState isn't used to carry anything here (postLoginRedirectURL
|
||||||
|
// is a fixed server-side config, not per-request) -- still generated
|
||||||
|
// fresh per login and round-tripped, since crewjam/saml's API expects
|
||||||
|
// one and an empty/constant value would be a needless deviation from
|
||||||
|
// how a real SP-initiated flow looks.
|
||||||
|
relayState, err := oidc.NewState() // same random-value generator, protocol-agnostic despite the package name
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("resolving identity after oidc login", "error", err, "email", claims.Email)
|
h.logger.Error("generating saml relay state", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURL, requestID, err := h.saml.LoginURL(relayState)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("building saml login url", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SameSiteNoneMode, not Lax like OIDC's state cookie: SAML's
|
||||||
|
// HTTP-POST binding means the browser POSTs to /auth/saml/acs
|
||||||
|
// *from the IdP's origin* -- a cross-site POST, which SameSite=Lax
|
||||||
|
// cookies are never sent on (Lax only exempts top-level GET
|
||||||
|
// navigations, which is what OIDC's redirect-based callback is, but
|
||||||
|
// SAML's response delivery isn't). SameSite=None requires Secure
|
||||||
|
// per the cookie spec, so this genuinely needs the deployment to be
|
||||||
|
// on HTTPS -- realistic for any real SAML IdP integration (they
|
||||||
|
// require it too), but worth stating plainly: unlike OIDC, SAML
|
||||||
|
// login will not work correctly over plain HTTP.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: samlRequestCookieName, Value: requestID, Path: "/auth/saml/acs",
|
||||||
|
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteNoneMode,
|
||||||
|
MaxAge: int(loginCookieTTL.Seconds()),
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleSAMLACS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer clearCookie(w, r, samlRequestCookieName, "/auth/saml/acs")
|
||||||
|
|
||||||
|
requestCookie, err := r.Cookie(samlRequestCookieName)
|
||||||
|
if err != nil || requestCookie.Value == "" {
|
||||||
|
http.Error(w, "missing or expired login state -- start over at /auth/saml/login", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := h.saml.ParseResponse(r, []string{requestCookie.Value})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("parsing saml response", "error", err)
|
||||||
|
http.Error(w, "login failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims.Email == "" {
|
||||||
|
http.Error(w, "identity provider did not return an email attribute", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims.NameID == "" {
|
||||||
|
http.Error(w, "identity provider did not return a NameID", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.finishLogin(w, r, claims.NameID, claims.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishLogin is the point both protocols converge on: a verified
|
||||||
|
// (subject, email) pair, still needing tenant/role resolution and a
|
||||||
|
// session cookie -- everything from here down is protocol-agnostic.
|
||||||
|
func (h *Handler) finishLogin(w http.ResponseWriter, r *http.Request, subject, email string) {
|
||||||
|
identity, status, err := h.resolveIdentity(r.Context(), subject, email)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("resolving identity after login", "error", err, "email", email)
|
||||||
http.Error(w, err.Error(), status)
|
http.Error(w, err.Error(), status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -181,7 +276,7 @@ var (
|
|||||||
// ErrNoMembership and ErrMultipleMemberships are exported so tests
|
// ErrNoMembership and ErrMultipleMemberships are exported so tests
|
||||||
// (and any future caller that wants to distinguish these outcomes,
|
// (and any future caller that wants to distinguish these outcomes,
|
||||||
// e.g. to render a real tenant-picker UI instead of a flat error
|
// e.g. to render a real tenant-picker UI instead of a flat error
|
||||||
// page) don't have to string-match handleCallback's HTTP error body.
|
// page) don't have to string-match the HTTP error body.
|
||||||
ErrNoMembership = errors.New("loginhandler: this identity has no tenant membership -- contact your administrator")
|
ErrNoMembership = errors.New("loginhandler: this identity has no tenant membership -- contact your administrator")
|
||||||
ErrMultipleMemberships = errors.New("loginhandler: this identity belongs to multiple tenants -- tenant selection is not supported yet")
|
ErrMultipleMemberships = errors.New("loginhandler: this identity belongs to multiple tenants -- tenant selection is not supported yet")
|
||||||
)
|
)
|
||||||
@@ -193,12 +288,14 @@ type resolvedIdentity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveIdentity is the policy decision this whole package exists to
|
// resolveIdentity is the policy decision this whole package exists to
|
||||||
// make: given a verified external identity, which tenant/role does it
|
// make: given a verified external identity (subject, email -- OIDC's
|
||||||
// map to. Deliberately conservative -- exactly one tenant_memberships
|
// "sub"/"email" claims or SAML's NameID/email attribute, already
|
||||||
// row is the only case handled; zero or multiple both refuse rather
|
// protocol-normalized by the caller), which tenant/role does it map to.
|
||||||
// than guess (see this package's doc comment).
|
// Deliberately conservative -- exactly one tenant_memberships row is the
|
||||||
func (h *Handler) resolveIdentity(ctx context.Context, claims *oidc.Claims) (resolvedIdentity, int, error) {
|
// only case handled; zero or multiple both refuse rather than guess
|
||||||
user, err := h.users.UpsertUserBySSO(ctx, claims.Subject, claims.Email, claims.Email)
|
// (see this package's doc comment).
|
||||||
|
func (h *Handler) resolveIdentity(ctx context.Context, subject, email string) (resolvedIdentity, int, error) {
|
||||||
|
user, err := h.users.UpsertUserBySSO(ctx, subject, email, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolvedIdentity{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: upserting user: %w", err)
|
return resolvedIdentity{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: upserting user: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func newTestSessionManager(t *testing.T) *session.Manager {
|
|||||||
|
|
||||||
func TestHandleLoginRedirectsAndSetsStateCookie(t *testing.T) {
|
func TestHandleLoginRedirectsAndSetsStateCookie(t *testing.T) {
|
||||||
idp := newTestIdP(t)
|
idp := newTestIdP(t)
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ func TestHandleLoginRedirectsAndSetsStateCookie(t *testing.T) {
|
|||||||
cookies := rec.Result().Cookies()
|
cookies := rec.Result().Cookies()
|
||||||
var stateCookie *http.Cookie
|
var stateCookie *http.Cookie
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
if c.Name == stateCookieName {
|
if c.Name == oidcStateCookieName {
|
||||||
stateCookie = c
|
stateCookie = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ func fullLoginFlow(t *testing.T, h *Handler, idp *testIdP) *httptest.ResponseRec
|
|||||||
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
|
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
|
||||||
var stateCookie *http.Cookie
|
var stateCookie *http.Cookie
|
||||||
for _, c := range loginRec.Result().Cookies() {
|
for _, c := range loginRec.Result().Cookies() {
|
||||||
if c.Name == stateCookieName {
|
if c.Name == oidcStateCookieName {
|
||||||
stateCookie = c
|
stateCookie = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestFullLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
|
|||||||
store := newFakeUserStore()
|
store := newFakeUserStore()
|
||||||
store.memberships["user-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-user-1", Role: rbacstore.RoleEditor}}
|
store.memberships["user-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-user-1", Role: rbacstore.RoleEditor}}
|
||||||
sessionManager := newTestSessionManager(t)
|
sessionManager := newTestSessionManager(t)
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), sessionManager, store, "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, sessionManager, store, "http://web/")
|
||||||
|
|
||||||
idp.setNextIDToken(t, "user-1", "[email protected]", true, time.Now().Add(time.Hour))
|
idp.setNextIDToken(t, "user-1", "[email protected]", true, time.Now().Add(time.Hour))
|
||||||
rec := fullLoginFlow(t, h, idp)
|
rec := fullLoginFlow(t, h, idp)
|
||||||
@@ -220,7 +220,7 @@ func TestFullLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
|
|||||||
|
|
||||||
func TestFullLoginFlowRefusesNoMembership(t *testing.T) {
|
func TestFullLoginFlowRefusesNoMembership(t *testing.T) {
|
||||||
idp := newTestIdP(t)
|
idp := newTestIdP(t)
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
|
||||||
idp.setNextIDToken(t, "user-2", "[email protected]", true, time.Now().Add(time.Hour))
|
idp.setNextIDToken(t, "user-2", "[email protected]", true, time.Now().Add(time.Hour))
|
||||||
rec := fullLoginFlow(t, h, idp)
|
rec := fullLoginFlow(t, h, idp)
|
||||||
@@ -237,7 +237,7 @@ func TestFullLoginFlowRefusesMultipleMemberships(t *testing.T) {
|
|||||||
{TenantID: "acme", UserID: "user-user-3", Role: rbacstore.RoleViewer},
|
{TenantID: "acme", UserID: "user-user-3", Role: rbacstore.RoleViewer},
|
||||||
{TenantID: "globex", UserID: "user-user-3", Role: rbacstore.RoleAdmin},
|
{TenantID: "globex", UserID: "user-user-3", Role: rbacstore.RoleAdmin},
|
||||||
}
|
}
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), store, "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), store, "http://web/")
|
||||||
|
|
||||||
idp.setNextIDToken(t, "user-3", "[email protected]", true, time.Now().Add(time.Hour))
|
idp.setNextIDToken(t, "user-3", "[email protected]", true, time.Now().Add(time.Hour))
|
||||||
rec := fullLoginFlow(t, h, idp)
|
rec := fullLoginFlow(t, h, idp)
|
||||||
@@ -249,12 +249,12 @@ func TestFullLoginFlowRefusesMultipleMemberships(t *testing.T) {
|
|||||||
|
|
||||||
func TestCallbackRejectsStateMismatch(t *testing.T) {
|
func TestCallbackRejectsStateMismatch(t *testing.T) {
|
||||||
idp := newTestIdP(t)
|
idp := newTestIdP(t)
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state=wrong&code=test-code", nil)
|
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state=wrong&code=test-code", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: stateCookieName, Value: "correct"})
|
req.AddCookie(&http.Cookie{Name: oidcStateCookieName, Value: "correct"})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
mux.ServeHTTP(rec, req)
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ func TestCallbackRejectsStateMismatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestCallbackRejectsMissingStateCookie(t *testing.T) {
|
func TestCallbackRejectsMissingStateCookie(t *testing.T) {
|
||||||
idp := newTestIdP(t)
|
idp := newTestIdP(t)
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ func TestCallbackRejectsMissingStateCookie(t *testing.T) {
|
|||||||
|
|
||||||
func TestCallbackRejectsExpiredIDToken(t *testing.T) {
|
func TestCallbackRejectsExpiredIDToken(t *testing.T) {
|
||||||
idp := newTestIdP(t)
|
idp := newTestIdP(t)
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
|
||||||
idp.setNextIDToken(t, "user-4", "[email protected]", true, time.Now().Add(-time.Hour)) // already expired
|
idp.setNextIDToken(t, "user-4", "[email protected]", true, time.Now().Add(-time.Hour)) // already expired
|
||||||
rec := fullLoginFlow(t, h, idp)
|
rec := fullLoginFlow(t, h, idp)
|
||||||
@@ -290,7 +290,7 @@ func TestCallbackRejectsExpiredIDToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterRoutesNoOpWhenOIDCNotConfigured(t *testing.T) {
|
func TestRegisterRoutesNoOpWhenOIDCNotConfigured(t *testing.T) {
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ func TestRegisterRoutesNoOpWhenOIDCNotConfigured(t *testing.T) {
|
|||||||
// the trap, only passing a nil-valued typed variable does.
|
// the trap, only passing a nil-valued typed variable does.
|
||||||
func TestRegisterRoutesNoOpWithTypedNilProviderVariable(t *testing.T) {
|
func TestRegisterRoutesNoOpWithTypedNilProviderVariable(t *testing.T) {
|
||||||
var provider *oidc.Provider // stays nil -- exactly main.go's shape when OIDC_ISSUER_URL is unset
|
var provider *oidc.Provider // stays nil -- exactly main.go's shape when OIDC_ISSUER_URL is unset
|
||||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), provider, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), provider, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
// Mirrors loginhandler_test.go's OIDC approach: exercise the full SAML
|
||||||
|
// login flow against a real fake IdP rather than mocking anything.
|
||||||
|
// crewjam/saml ships samlidp, a genuine SAML identity provider (real XML
|
||||||
|
// signing, real assertion construction) meant for exactly this kind of
|
||||||
|
// testing. To avoid driving its HTML login form, a valid saml.Session is
|
||||||
|
// seeded directly into the IdP's session store and presented via the
|
||||||
|
// `session` cookie GetSession already accepts -- confirmed by reading
|
||||||
|
// samlidp's own GetSession implementation, the same "skip the UI, keep
|
||||||
|
// the crypto real" shortcut oidctest gives the OIDC tests above.
|
||||||
|
package loginhandler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
"github.com/crewjam/saml/samlidp"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
||||||
|
samlpkg "github.com/sentry/sentry/enterprise/internal/saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testSAMLEntityID = "https://sentry-test.example.com/saml/metadata"
|
||||||
|
testSAMLACSURL = "https://sentry-test.example.com/auth/saml/acs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSAMLIdP bundles a real samlidp.Server with the SP key/cert it was
|
||||||
|
// registered against, enough to drive a full SP-initiated login.
|
||||||
|
type testSAMLIdP struct {
|
||||||
|
server *samlidp.Server
|
||||||
|
store *samlidp.MemoryStore
|
||||||
|
spKey *rsa.PrivateKey
|
||||||
|
spCert *x509.Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
func genSelfSignedCert(t *testing.T, commonName string) (*rsa.PrivateKey, *x509.Certificate) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generating RSA key: %v", err)
|
||||||
|
}
|
||||||
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generating serial number: %v", err)
|
||||||
|
}
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating certificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing certificate: %v", err)
|
||||||
|
}
|
||||||
|
return key, cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestSAMLIdP starts a real samlidp.Server and registers Sentry's SP
|
||||||
|
// metadata with it directly via the IdP's own PUT /services/{id}
|
||||||
|
// endpoint -- the same mechanism a real IdP admin uses, not a shortcut
|
||||||
|
// that reaches into samlidp's unexported state.
|
||||||
|
func newTestSAMLIdP(t *testing.T) *testSAMLIdP {
|
||||||
|
t.Helper()
|
||||||
|
idpKey, idpCert := genSelfSignedCert(t, "sentry-test-idp")
|
||||||
|
spKey, spCert := genSelfSignedCert(t, "sentry-test-sp")
|
||||||
|
|
||||||
|
store := &samlidp.MemoryStore{}
|
||||||
|
idpServer, err := samlidp.New(samlidp.Options{
|
||||||
|
Key: idpKey,
|
||||||
|
Certificate: idpCert,
|
||||||
|
Store: store,
|
||||||
|
URL: url.URL{Scheme: "http", Host: "idp.example.com"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("samlidp.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entityIDURL, err := url.Parse(testSAMLEntityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing test entity id: %v", err)
|
||||||
|
}
|
||||||
|
acsURL, err := url.Parse(testSAMLACSURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing test acs url: %v", err)
|
||||||
|
}
|
||||||
|
// A throwaway saml.ServiceProvider built from the same
|
||||||
|
// EntityID/ACSURL/cert samlpkg.New below uses -- Metadata() is a
|
||||||
|
// pure function of those exported fields, so this stays consistent
|
||||||
|
// with the real samlpkg.ServiceProvider without needing access to
|
||||||
|
// its unexported inner sp field.
|
||||||
|
spForRegistration := saml.ServiceProvider{
|
||||||
|
Key: spKey,
|
||||||
|
Certificate: spCert,
|
||||||
|
MetadataURL: *entityIDURL,
|
||||||
|
AcsURL: *acsURL,
|
||||||
|
}
|
||||||
|
spMetadataXML, err := xml.Marshal(spForRegistration.Metadata())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshaling sp metadata: %v", err)
|
||||||
|
}
|
||||||
|
putReq := httptest.NewRequest(http.MethodPut, "/services/sentry-test-sp", strings.NewReader(string(spMetadataXML)))
|
||||||
|
putRec := httptest.NewRecorder()
|
||||||
|
idpServer.ServeHTTP(putRec, putReq)
|
||||||
|
if putRec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("registering sp metadata with fake idp: status = %d, body = %s", putRec.Code, putRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testSAMLIdP{server: idpServer, store: store, spKey: spKey, spCert: spCert}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceProvider builds the samlpkg.ServiceProvider loginhandler uses,
|
||||||
|
// trusting idp's metadata.
|
||||||
|
func (idp *testSAMLIdP) serviceProvider(t *testing.T) *samlpkg.ServiceProvider {
|
||||||
|
t.Helper()
|
||||||
|
sp, err := samlpkg.New(samlpkg.Config{
|
||||||
|
EntityID: testSAMLEntityID,
|
||||||
|
ACSURL: testSAMLACSURL,
|
||||||
|
IDPMetadata: idp.server.IDP.Metadata(),
|
||||||
|
Certificate: &tls.Certificate{
|
||||||
|
Certificate: [][]byte{idp.spCert.Raw},
|
||||||
|
PrivateKey: idp.spKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("samlpkg.New: %v", err)
|
||||||
|
}
|
||||||
|
return sp
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSession pre-authenticates a user directly in the fake IdP's store,
|
||||||
|
// bypassing its login-form HTML entirely. Confirmed viable by reading
|
||||||
|
// samlidp's GetSession: a valid, non-expired saml.Session at
|
||||||
|
// /sessions/<id> plus a matching `session` cookie is exactly what a real
|
||||||
|
// login-form POST would have produced -- this is the IdP's own supported
|
||||||
|
// shortcut, not an abuse of internals.
|
||||||
|
func (idp *testSAMLIdP) seedSession(t *testing.T, nameID, email string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
sessionID := fmt.Sprintf("test-session-%d", time.Now().UnixNano())
|
||||||
|
session := &saml.Session{
|
||||||
|
ID: sessionID,
|
||||||
|
NameID: nameID,
|
||||||
|
CreateTime: saml.TimeNow(),
|
||||||
|
ExpireTime: saml.TimeNow().Add(time.Hour),
|
||||||
|
Index: sessionID,
|
||||||
|
UserEmail: email,
|
||||||
|
}
|
||||||
|
if err := idp.store.Put(fmt.Sprintf("/sessions/%s", sessionID), session); err != nil {
|
||||||
|
t.Fatalf("seeding idp session: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: "session", Value: sessionID}
|
||||||
|
}
|
||||||
|
|
||||||
|
var samlResponseFieldRe = regexp.MustCompile(`name="(SAMLResponse|RelayState)" value="([^"]*)"`)
|
||||||
|
|
||||||
|
// extractSAMLResponseForm pulls the hidden form fields out of the IdP's
|
||||||
|
// auto-submitting HTML response -- what a real browser's inline <script>
|
||||||
|
// reads before POSTing to the SP's ACS endpoint.
|
||||||
|
func extractSAMLResponseForm(t *testing.T, body string) (samlResponse, relayState string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, m := range samlResponseFieldRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
switch m[1] {
|
||||||
|
case "SAMLResponse":
|
||||||
|
samlResponse = html.UnescapeString(m[2])
|
||||||
|
case "RelayState":
|
||||||
|
relayState = html.UnescapeString(m[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if samlResponse == "" {
|
||||||
|
t.Fatalf("no SAMLResponse field found in idp response html: %s", body)
|
||||||
|
}
|
||||||
|
return samlResponse, relayState
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullSAMLLoginFlow drives handleSAMLLogin, the fake IdP's /sso, and
|
||||||
|
// handleSAMLACS end to end, exactly the way a browser + IdP round trip
|
||||||
|
// would, and returns the final response so callers can assert on it.
|
||||||
|
func fullSAMLLoginFlow(t *testing.T, h *Handler, idp *testSAMLIdP, nameID, email string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
loginRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/saml/login", nil))
|
||||||
|
if loginRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("GET /auth/saml/login: status = %d, body = %s", loginRec.Code, loginRec.Body.String())
|
||||||
|
}
|
||||||
|
redirectURL := loginRec.Header().Get("Location")
|
||||||
|
var requestCookie *http.Cookie
|
||||||
|
for _, c := range loginRec.Result().Cookies() {
|
||||||
|
if c.Name == samlRequestCookieName {
|
||||||
|
requestCookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestCookie == nil {
|
||||||
|
t.Fatal("no saml request cookie from /auth/saml/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
ssoReq := httptest.NewRequest(http.MethodGet, redirectURL, nil)
|
||||||
|
ssoReq.AddCookie(idp.seedSession(t, nameID, email))
|
||||||
|
ssoRec := httptest.NewRecorder()
|
||||||
|
idp.server.ServeHTTP(ssoRec, ssoReq)
|
||||||
|
if ssoRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("idp GET /sso: status = %d, body = %s", ssoRec.Code, ssoRec.Body.String())
|
||||||
|
}
|
||||||
|
samlResponse, relayState := extractSAMLResponseForm(t, ssoRec.Body.String())
|
||||||
|
|
||||||
|
form := url.Values{"SAMLResponse": {samlResponse}, "RelayState": {relayState}}
|
||||||
|
acsReq := httptest.NewRequest(http.MethodPost, "/auth/saml/acs", strings.NewReader(form.Encode()))
|
||||||
|
acsReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
acsReq.AddCookie(requestCookie)
|
||||||
|
acsRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(acsRec, acsReq)
|
||||||
|
return acsRec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSAMLLoginRedirectsAndSetsRequestCookie(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/saml/login", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc == "" {
|
||||||
|
t.Fatal("expected a Location header redirecting to the IdP")
|
||||||
|
}
|
||||||
|
var requestCookie *http.Cookie
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == samlRequestCookieName {
|
||||||
|
requestCookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestCookie == nil || requestCookie.Value == "" {
|
||||||
|
t.Fatal("expected a non-empty saml request cookie to be set")
|
||||||
|
}
|
||||||
|
if !requestCookie.HttpOnly {
|
||||||
|
t.Fatal("expected the saml request cookie to be HttpOnly")
|
||||||
|
}
|
||||||
|
if requestCookie.SameSite != http.SameSiteNoneMode {
|
||||||
|
t.Fatalf("SameSite = %v, want SameSiteNoneMode -- the acs POST is cross-site from the idp's origin", requestCookie.SameSite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullSAMLLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
store := newFakeUserStore()
|
||||||
|
store.memberships["user-saml-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-saml-user-1", Role: rbacstore.RoleEditor}}
|
||||||
|
sessionManager := newTestSessionManager(t)
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), sessionManager, store, "http://web/")
|
||||||
|
|
||||||
|
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-1", "[email protected]")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "http://web/" {
|
||||||
|
t.Fatalf("Location = %q, want http://web/", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionCookie *http.Cookie
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "sentry_session" {
|
||||||
|
sessionCookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sessionCookie == nil || sessionCookie.Value == "" {
|
||||||
|
t.Fatal("expected a sentry_session cookie to be set")
|
||||||
|
}
|
||||||
|
claims, err := sessionManager.Validate(sessionCookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validating issued session: %v", err)
|
||||||
|
}
|
||||||
|
if claims.TenantID != "acme" || claims.Role != "editor" || claims.UserID != "user-saml-user-1" {
|
||||||
|
t.Fatalf("unexpected session claims: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullSAMLLoginFlowRefusesNoMembership(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
|
||||||
|
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-2", "[email protected]")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullSAMLLoginFlowRefusesMultipleMemberships(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
store := newFakeUserStore()
|
||||||
|
store.memberships["user-saml-user-3"] = []rbacstore.Membership{
|
||||||
|
{TenantID: "acme", UserID: "user-saml-user-3", Role: rbacstore.RoleViewer},
|
||||||
|
{TenantID: "globex", UserID: "user-saml-user-3", Role: rbacstore.RoleAdmin},
|
||||||
|
}
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), store, "http://web/")
|
||||||
|
|
||||||
|
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-3", "[email protected]")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotImplemented {
|
||||||
|
t.Fatalf("status = %d, want 501; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullSAMLLoginFlowRefusesMissingEmail(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
|
||||||
|
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-4", "") // no email attribute in the assertion
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSAMLACSRejectsMissingRequestCookie(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// No prior GET /auth/saml/login, so no sentry_saml_request cookie --
|
||||||
|
// simulates an attacker POSTing a captured/forged response directly
|
||||||
|
// at the ACS endpoint with no matching request state.
|
||||||
|
form := url.Values{"SAMLResponse": {"irrelevant"}, "RelayState": {""}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/saml/acs", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSAMLACSRejectsWrongRequestID(t *testing.T) {
|
||||||
|
idp := newTestSAMLIdP(t)
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
loginRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/saml/login", nil))
|
||||||
|
redirectURL := loginRec.Header().Get("Location")
|
||||||
|
|
||||||
|
ssoReq := httptest.NewRequest(http.MethodGet, redirectURL, nil)
|
||||||
|
ssoReq.AddCookie(idp.seedSession(t, "saml-user-5", "[email protected]"))
|
||||||
|
ssoRec := httptest.NewRecorder()
|
||||||
|
idp.server.ServeHTTP(ssoRec, ssoReq)
|
||||||
|
samlResponse, relayState := extractSAMLResponseForm(t, ssoRec.Body.String())
|
||||||
|
|
||||||
|
// Present the genuine, correctly-signed response but with a
|
||||||
|
// tampered request cookie -- the InResponseTo check must still
|
||||||
|
// reject it. This is SAML's replay/unsolicited-response defense,
|
||||||
|
// the mechanism samlRequestCookieName exists for.
|
||||||
|
form := url.Values{"SAMLResponse": {samlResponse}, "RelayState": {relayState}}
|
||||||
|
acsReq := httptest.NewRequest(http.MethodPost, "/auth/saml/acs", strings.NewReader(form.Encode()))
|
||||||
|
acsReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
acsReq.AddCookie(&http.Cookie{Name: samlRequestCookieName, Value: "some-other-request-id"})
|
||||||
|
acsRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(acsRec, acsReq)
|
||||||
|
|
||||||
|
if acsRec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401; body=%s", acsRec.Code, acsRec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRoutesNoOpWhenSAMLNotConfigured(t *testing.T) {
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/saml/login", nil))
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 (no routes should be registered when saml is nil)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterRoutesNoOpWithTypedNilSAMLProviderVariable is SAML's
|
||||||
|
// equivalent of TestRegisterRoutesNoOpWithTypedNilProviderVariable in
|
||||||
|
// loginhandler_test.go -- see that test's doc comment for the Go
|
||||||
|
// typed-nil-interface trap this guards against.
|
||||||
|
func TestRegisterRoutesNoOpWithTypedNilSAMLProviderVariable(t *testing.T) {
|
||||||
|
var provider *samlpkg.ServiceProvider // stays nil -- main.go's shape when SAML_IDP_METADATA_URL is unset
|
||||||
|
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, provider, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/saml/login", nil))
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 (a typed-nil *samlpkg.ServiceProvider must still result in saml routes being disabled)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,17 +89,24 @@ func New(cfg Config) (*ServiceProvider, error) {
|
|||||||
// round-trips through the IdP and comes back with the response --
|
// round-trips through the IdP and comes back with the response --
|
||||||
// typically where to send the browser after login completes, validated
|
// typically where to send the browser after login completes, validated
|
||||||
// by the caller the same way OIDC's state parameter is (this package
|
// by the caller the same way OIDC's state parameter is (this package
|
||||||
// doesn't store it).
|
// doesn't store it). Also returns the AuthnRequest's ID: the caller must
|
||||||
func (s *ServiceProvider) LoginURL(relayState string) (string, error) {
|
// persist it (e.g. a short-lived cookie, the same pattern
|
||||||
|
// enterprise/internal/loginhandler uses for OIDC's state) and pass it
|
||||||
|
// back via ParseResponse's possibleRequestIDs -- SAML's actual replay/
|
||||||
|
// unsolicited-response defense, standing in for OIDC's simpler state
|
||||||
|
// check. Skipping this (e.g. passing nil to ParseResponse) is exactly
|
||||||
|
// the "don't validate you asked for this response" mistake that would
|
||||||
|
// let an attacker replay a captured assertion.
|
||||||
|
func (s *ServiceProvider) LoginURL(relayState string) (redirectURL, requestID string, err error) {
|
||||||
req, err := s.sp.MakeAuthenticationRequest(s.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
|
req, err := s.sp.MakeAuthenticationRequest(s.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("saml: building authentication request: %w", err)
|
return "", "", fmt.Errorf("saml: building authentication request: %w", err)
|
||||||
}
|
}
|
||||||
redirectURL, err := req.Redirect(relayState, &s.sp)
|
redirect, err := req.Redirect(relayState, &s.sp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("saml: building redirect URL: %w", err)
|
return "", "", fmt.Errorf("saml: building redirect URL: %w", err)
|
||||||
}
|
}
|
||||||
return redirectURL.String(), nil
|
return redirect.String(), req.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claims is the subset of an assertion Sentry uses -- same "extend
|
// Claims is the subset of an assertion Sentry uses -- same "extend
|
||||||
@@ -114,6 +121,17 @@ type Claims struct {
|
|||||||
// the step that actually establishes trust -- crewjam/saml's
|
// the step that actually establishes trust -- crewjam/saml's
|
||||||
// ParseResponse does the XML signature verification, not this package.
|
// ParseResponse does the XML signature verification, not this package.
|
||||||
func (s *ServiceProvider) ParseResponse(r *http.Request, possibleRequestIDs []string) (*Claims, error) {
|
func (s *ServiceProvider) ParseResponse(r *http.Request, possibleRequestIDs []string) (*Claims, error) {
|
||||||
|
// crewjam/saml's ServiceProvider.ParseResponse reads req.PostForm
|
||||||
|
// directly rather than parsing the body itself -- net/http only
|
||||||
|
// populates PostForm once something calls ParseForm, which the
|
||||||
|
// stdlib server never does on its own. Skipping this turns every
|
||||||
|
// real ACS POST into an empty SAMLResponse (silently failing at the
|
||||||
|
// base64-decode step), a bug this package's own real-fake-IdP test
|
||||||
|
// caught immediately since it drives a genuine POST body.
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return nil, fmt.Errorf("saml: parsing ACS POST body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
assertion, err := s.sp.ParseResponse(r, possibleRequestIDs)
|
assertion, err := s.sp.ParseResponse(r, possibleRequestIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("saml: parsing/validating response: %w", err)
|
return nil, fmt.Errorf("saml: parsing/validating response: %w", err)
|
||||||
@@ -125,7 +143,16 @@ func (s *ServiceProvider) ParseResponse(r *http.Request, possibleRequestIDs []st
|
|||||||
}
|
}
|
||||||
for _, stmt := range assertion.AttributeStatements {
|
for _, stmt := range assertion.AttributeStatements {
|
||||||
for _, attr := range stmt.Attributes {
|
for _, attr := range stmt.Attributes {
|
||||||
if attr.Name == "email" || attr.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" {
|
switch attr.Name {
|
||||||
|
// "email" and the ADFS claims URI are what an IdP admin gets
|
||||||
|
// by explicitly naming the attribute that way in the SAML
|
||||||
|
// app's attribute-statement config. urn:oid:0.9.2342.19200300.100.1.3
|
||||||
|
// is the standard LDAP "mail" OID -- what crewjam's own
|
||||||
|
// DefaultAssertionMaker (and many real IdPs' default
|
||||||
|
// templates) send when nothing more specific was requested,
|
||||||
|
// found by tracing the actual assertion-building code this
|
||||||
|
// test exercises rather than assuming "email" covers it.
|
||||||
|
case "email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "urn:oid:0.9.2342.19200300.100.1.3":
|
||||||
if len(attr.Values) > 0 {
|
if len(attr.Values) > 0 {
|
||||||
claims.Email = attr.Values[0].Value
|
claims.Email = attr.Values[0].Value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,14 @@ func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) {
|
|||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectURL, err := sp.LoginURL("relay-state-123")
|
redirectURL, requestID, err := sp.LoginURL("relay-state-123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoginURL: %v", err)
|
t.Fatalf("LoginURL: %v", err)
|
||||||
}
|
}
|
||||||
if redirectURL == "" {
|
if redirectURL == "" {
|
||||||
t.Fatalf("expected a non-empty redirect URL")
|
t.Fatalf("expected a non-empty redirect URL")
|
||||||
}
|
}
|
||||||
|
if requestID == "" {
|
||||||
|
t.Fatalf("expected a non-empty AuthnRequest ID -- callers need this for ParseResponse's possibleRequestIDs")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user