feat(auth): sign in with the deployment's SAML identity providers - #318
feat(auth): sign in with the deployment's SAML identity providers#318jeroenrinzema wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a first-party SAML 2.0 single sign-on driver to the platform’s existing federated-auth framework (stacked on the prior OIDC work), including server-side flow storage, replay protection, metadata handling, management API endpoints, and console UI support.
Changes:
- Implement SAML verifier/set with Redis-backed flow storage + assertion replay protection, and SAML IdP metadata fetching/parsing with caching.
- Add management API endpoints for SAML (
/providers,/start,/acs,/metadata) and wire them into the v1 management controller/OpenAPI clients. - Update console login UX to collapse OIDC+SAML into a single “single sign-on” choice and merge provider lists, plus documentation/deployment config updates.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents SAML SSO configuration, endpoints, and operational/security constraints. |
| internal/sso/singleuse.go | Introduces shared Redis single-use read/consume helper for OIDC/SAML flows. |
| internal/sso/saml_metadata.go | Implements SAML metadata fetching, strict parsing, caching, and endpoint/cert extraction. |
| internal/sso/saml_metadata_test.go | Tests metadata binding, endpoint validation, bundle refusal, and certificate parsing. |
| internal/sso/saml_flow.go | Adds SAML flow store and assertion replay store (SETNX + TTL). |
| internal/sso/flow.go | Refactors OIDC flow store to use the shared single-use Redis helper. |
| internal/http/controllers/v1/management/oapi/resources.yml | Extends OpenAPI spec with SAML auth endpoints and schemas. |
| internal/http/controllers/v1/management/oapi/resources_gen.go | Regenerates Go OpenAPI client/server bindings to include SAML routes. |
| internal/http/controllers/v1/management/controller.go | Wires SAML dependencies (flows, assertions, metadata cache) into verifier deps. |
| internal/http/controllers/v1/management/auth.go | Switches from OIDC-only provider set to a combined federated set (OIDC+SAML). |
| internal/http/controllers/v1/management/auth_saml.go | Adds SAML start/ACS/providers/metadata handlers and error mapping. |
| internal/http/controllers/v1/management/auth_saml_test.go | End-to-end SAML controller tests with real signed assertions and edge cases. |
| internal/http/controllers/v1/management/auth_oidc.go | Adapts OIDC handlers to the new combined federated provider container. |
| internal/http/auth/verifiers/verifier.go | Extends verifier build/wiring to support SAML alongside OIDC via Federated. |
| internal/http/auth/verifiers/saml.go | Implements SAML authorize/complete/verify, request binding, proof checks, identity mapping. |
| internal/http/auth/verifiers/saml_test.go | Unit tests for SAML provider construction and identity extraction behavior. |
| internal/http/auth/verifiers/saml_set.go | Builds ordered SAML provider sets and validates IDs/duplicates at boot. |
| internal/http/auth/auth.go | Adds SAML browser-binding cookie helpers (SameSite=None, Secure, __Host-). |
| internal/config/defaults_test.go | Adds tests for SAML config form resolution and form-mixing refusal. |
| internal/config/config.go | Introduces SAML config structures (auth.saml) and env/YAML mapping. |
| go.mod | Adds SAML/XML deps (crewjam/saml, goxmldsig, etree, xml-roundtrip-validator). |
| go.sum | Records checksums for newly added dependencies. |
| docker-compose.yml | Adds env var wiring/comments for SAML configuration in compose deployments. |
| console/src/views/auth/Login.tsx | Collapses OIDC+SAML into one SSO choice and fetches/providers per protocol. |
| console/src/types.ts | Adds saml auth driver and SsoDriver union used by the login UI. |
| console/src/oapi/management.generated.ts | Regenerates TS OpenAPI types for SAML endpoints/schemas. |
| console/src/api.ts | Extends console API helpers to list/start SSO per protocol (/auth/{driver}/...). |
| console/public/locales/en.json | Adds/renames SSO-related i18n keys, including transient NameID error text. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
internal/http/auth/verifiers/saml.go:269
- When configuring a provider via explicit fields (sso_url + certificate), the SSO URL is currently not validated (scheme/host). This allows an insecure http:// (or otherwise invalid) IdP endpoint to be accepted, contradicting the stated requirement that plaintext should be refused and making /start a potential open redirect to a non-HTTPS destination.
This issue also appears on line 291 of the same file.
if settings.MetadataURL != "" {
if err := opts.Metadata.ValidateMetadataURL(settings.MetadataURL); err != nil {
return nil, err
}
return provider, nil
}
descriptor, err := sso.DescriptorFromFields(settings.EntityID, settings.SSOURL, settings.Certificate)
if err != nil {
return nil, err
}
provider.descriptor = descriptor
return provider, nil
internal/sso/saml_metadata.go:355
- ParseCertificates stops silently when pem.Decode returns nil, so trailing non-PEM garbage after a valid certificate bundle is ignored. This can hide configuration mistakes (e.g., pasting a rotated cert plus extra text) and leave the deployment using fewer certificates than intended.
for len(rest) > 0 {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
console/src/views/auth/Login.tsx:160
- SSO provider fetching uses the fixed SSO_DRIVERS order (oidc, saml) rather than the operator-declared driver order returned by /auth/methods. This means provider buttons may be shown in a different order than the configured driver list (e.g., saml before oidc).
useEffect(() => {
if (!drivers) return
const enabled = SSO_DRIVERS.filter((driver) => drivers.includes(driver))
if (enabled.length === 0) return
internal/http/auth/verifiers/saml.go:301
- missingSAMLCollaborators checks opts.Config.MetadataURL without trimming; if MetadataURL is whitespace it will incorrectly require a metadata cache and refuse to build even though withSAMLProviderDefaults() trims it to empty.
func missingSAMLCollaborators(opts SAMLOptions) []string {
var missing []string
if opts.Flows == nil {
missing = append(missing, "Redis, which holds outstanding authentication requests")
}
if opts.Assertions == nil {
missing = append(missing, "Redis, which records the assertions already redeemed")
}
if opts.Metadata == nil && opts.Config.MetadataURL != "" {
missing = append(missing, "a metadata cache")
}
|
Working through Copilot's suppressed findings from the second pass. All four held up; fixed in ff5df08. A typed A truncated PEM bundle was read as a bundle of one ( The console asked for providers in a fixed protocol order (
Tests added for the first two; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
internal/http/controllers/v1/management/controller.go:85
SAMLMetadatauses an internal fetch timeout of 15s (internal/sso/saml_metadata.go), but here it’s wired to the sharedproviderHTTP client built withoidcProviderTimeout(10s). In practice the HTTP client timeout will win, so SAML metadata fetches will time out earlier than the SAML cache expects.
Consider either using a larger shared timeout for the federated HTTP client, or splitting the OIDC and SAML outbound clients so the SAML metadata fetch can use >=15s consistently.
SAMLFlows: sso.NewSAMLFlowStore(rdb, cfg.Redis.KeyPrefix),
Assertions: sso.NewAssertionReplayStore(rdb, cfg.Redis.KeyPrefix),
SAMLMetadata: sso.NewSAMLMetadata(provider, ssrf.Policy{}, 0),
BaseURL: cfg.PublicBaseURL(),
internal/http/controllers/v1/management/oapi/resources.yml:6632
- The ACS endpoint expects both
SAMLResponseandRelayStateto be present for a successful login, but the OpenAPI schema marks both fields as optional. This propagates into generated clients/types and makes it easier to produce invalid requests unintentionally.
Mark both fields as required in the form schema (keep the properties as-is).
schema:
type: object
properties:
SAMLResponse:
type: string
|
Second pass on the suppressed findings from the fourth review. Both are misreadings, but both landed on something that was genuinely unexplained, so 6619a0f writes down what was already true rather than changing behaviour. The 15s metadata fetch timeout is not in conflict with the 10s client ( The ACS form fields stay optional ( Marking them required would make a missing field a schema violation, and the person on the other end of that navigation needs a page. A response with no The description now says both of those things, so the next reader has the reasoning rather than an omission. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
console/src/views/auth/Login.tsx:273
- The SSO provider button list uses
key={provider.id}. If both OIDC and SAML are enabled, it’s valid for both lists to contain the same id (e.g.default), which will create duplicate React keys and can lead to incorrect rendering/updating. Include the driver in the key.
{ssoProviders?.map((provider) => (
<Button
key={provider.id}
type="button"
A driver like the others: AUTH_DRIVER=basic,saml, configured by the operator, offered beside the password login or on its own. It is for the directories that do not speak OpenID Connect; where a provider offers both, the OpenID Connect driver remains the one to prefer. The verifier stops at the proof. auth.Exchanger resolves the admin, provisions membership and mints the session, so an admin provisioned through this driver lands exactly where one provisioned through any other does. admin_identities is keyed on (issuer, subject) and already permits the saml provider, so there is no migration. Signature verification, InResponseTo, Recipient, Destination, audience and the assertion's validity window are crewjam/saml's. What this adds around it is the flow: a single-use RelayState held in Redis, a browser binding, and an assertion replay guard. The binding cookie is SameSite=None rather than Lax. A browser returns from a SAML provider by cross-site form POST, which a Lax cookie is not sent on, so the OpenID Connect binding would have been absent from every assertion. SameSite=None requires Secure, so a deployment whose PUBLIC_URL is not https is refused the driver at boot rather than run without the binding. Redeeming the flow is not enough on its own: the provider does not sign RelayState, so a captured assertion could be replayed inside a login the attacker starts themselves. Assertion IDs are recorded for as long as the assertion would otherwise stay valid. Identity-provider-initiated sign-on is refused. An unsolicited response answers no request this deployment issued, so the RelayState, the browser binding and InResponseTo all have nothing to check. SAML has no email_verified, so the attestation is the operator's: configuring a provider says it is authoritative for the addresses it asserts. trust_email defaults to true and allowed_domains bounds it, as it does for OpenID Connect.
crewjam/saml v0.5.1 requires goxmldsig v1.4.0, which carries a signature bypass (GHSA-qpr9-2mgv-qcjp, fixed in v1.6.0) on exactly the path a SAML response is proved on. Nothing in the API changed, so the requirement is raised here rather than waiting for an upstream release.
An unset sp_entity_id means the provider's metadata URL. crewjam/saml applies that default itself, at the audience an assertion is validated against as well as at the issuer on an AuthnRequest, so the id is passed through verbatim rather than filled in on the way past. Nothing said so.
Three configurations the flow tests did not reach. The harness is parameterised rather than copied, so each is the same login through a different setup. A provider configured by metadata URL resolves its sign-on endpoint and signing certificate from a document served over loopback, and an assertion signed by anything else is still refused -- the certificate came out of the document, so that is what the check is against. A provider advertising only HTTP-POST gets the self-submitting form instead of a redirect. Reading it back needs an HTML unescape the browser would do for us: html/template escapes `+` and `=` in an attribute, and base64 uses both. A deployment holding a key pair signs its AuthnRequest, and the signature is verified here the way the provider would -- over the query string up to the signature itself -- against the certificate the published metadata carries. The same deployment accepts an encrypted assertion, and still refuses one that is signed by another key or not signed at all: an envelope this deployment can open is not an assertion it has proved.
A descriptor built from metadata had its sign-on endpoint checked against the outbound policy; one built from AUTH_SAML_SSO_URL did not. Nothing downstream tells the two apart, so a plaintext endpoint was accepted at boot and every AuthnRequest was carried over it. Both forms now run through the same check, which is why the metadata cache -- what holds the policy -- is required whichever form configured the provider. Trailing bytes after the last certificate in a PEM bundle are refused rather than ignored, so a bundle truncated on its way into the environment is not read as a bundle of one and discovered at the rotation the second key was there for. The console asks for providers in the operator's declared driver order rather than a fixed one, which is the order the buttons come out in and what driverChoices already honours.
All() returns a clone, so calling it for the length and again for the range built the slice twice.
It is a backstop on a context detached from every caller, not a target, and the outbound client's shorter timeout normally fires first. The OpenID Connect constant says so; this one read as a number that disagreed with the client. Same for the assertion consumer service's two form fields, which the identity provider supplies and the handler refuses on absence with a page rather than a problem document.
The skip list names both halves of an OpenID Connect login for a reason it spelled out -- /start is where the browser leaves carrying the legacy cookie, so upgrading there signs somebody in before they authenticate -- and this driver added two more halves without adding the prefix. SAML's window is the wider one: the browser is away at the provider for as long as signing in there takes. The provider buttons key on the protocol as well as the id. Ids are unique within a set and not across two, and each driver's single-provider form calls itself "default".
6619a0f to
1e95844
Compare
|
Also fixed the suppressed finding from that pass, and rebased. Duplicate React keys across protocols ( Rebased onto
|
One client carries it now, so oidcProviderTimeout named half of what it bounds. What it is sized for is the same either way: a login is a person waiting at a redirect. ValidateDescriptor's comment claimed every endpoint in the document. It checks the sign-on endpoint the login would use, which is the one that gets dialled -- what is advertised under a binding this deployment does not send is never reached.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/sso/saml_metadata.go:97
- NewSAMLMetadata accepts a nil *http.Client, but Descriptor()/fetch() unconditionally call m.client.Do(...), which will panic if a caller wires SAMLMetadata without a client (e.g., in future tests or alternate wiring paths). Consider defensively defaulting to a policy-enforcing client (or explicitly rejecting nil) so miswiring fails with an error instead of a nil-pointer panic at login time.
func NewSAMLMetadata(client *http.Client, policy ssrf.Policy, ttl time.Duration) *SAMLMetadata {
if ttl <= 0 {
ttl = samlMetadataTTL
}
return &SAMLMetadata{
Descriptor dereferenced the client unconditionally, so wiring the cache without one panicked at the first login rather than anywhere near the mistake. It now defaults the way the ttl beside it already did -- to a client dialling under the policy it was given, never http.DefaultClient, which would dial anywhere and is the opposite of what this type is for.
|
Suppressed finding from the seventh pass, fixed in 5f74130. A clientless metadata cache panicked at the first login ( It defaults now, the way the ttl on the line above it already did. Deliberately not One unrelated thing worth flagging: |
Sign in to the console with the deployment's own SAML 2.0 identity providers.
It is a driver like the others:
AUTH_DRIVER=basic,saml, configured by theoperator, offered beside the password login or on its own.
Stacked on #311. Review that first; this diff is against it. #311's own body
says SAML was considered and dropped — this is that decision revisited, and it
turned out to need no seams that were not already there.
Where a directory speaks both protocols, OpenID Connect is still the one to
reach for: it has discovery, so a key rotation is picked up on its own, and it
carries an
email_verifiedclaim the platform can act on. This is for thedirectories that only speak SAML.
What it reuses, and what it could not
The identity half was free.
auth.Exchangerresolves the admin, provisionsmembership and mints the session, so a SAML login lands exactly where every
other driver's does.
admin_identitiesis keyed on(issuer, subject)and itsCHECKconstraint already permits'saml', so there is no migration.Nothing in
internal/rbacorinternal/http/auth/exchange.gochanges.Signature verification is
crewjam/saml's, and so areInResponseTo,Recipient,Destination, the audience restriction and the assertion'svalidity window. Rolling any of that by hand is how XML signature wrapping bugs
get written.
Three things did not carry over from #311.
The browser binding cookie had to change
This is the one piece of #311's design that breaks outright. Its binding cookie
is
SameSite=Lax, with a comment explaining that Strict would strip it from thetop-level navigation back from the provider. That works because OpenID Connect
returns by GET. SAML returns by the HTTP-POST binding — a cross-site
top-level form POST, which Lax cookies are not sent on. A Lax cookie here
would have been absent from every assertion the deployment ever received.
So the SAML binding is
SameSite=None, which browsers only accept on aSecurecookie. A deployment whose
PUBLIC_URLis not https is therefore refused thedriver at boot, rather than run with a binding that silently never arrives.
There is no unprefixed development fallback for the same reason.
Redeeming the flow is not enough
The identity provider does not sign
RelayState. Making it single-use stops areplay of the same POST, but not a captured assertion replayed inside a login
the attacker starts themselves, which would carry a RelayState that has never
been spent. Assertion IDs are recorded in Redis for as long as the assertion
would otherwise stay valid — its own
NotOnOrAfter, not a fixed window, so thekeyspace stays bounded.
There is no
email_verifiedNothing an assertion can carry attests an address the way an OpenID Connect
claim does. So the attestation is the operator's: configuring a provider says it
is authoritative for the addresses it asserts, which is true of a corporate
directory and is why
trust_emaildefaults totrue.allowed_domainsis whatbounds it, exactly as in #311.
trust_email: falseis there for a directorywhose users can edit their own address; logins through it then only ever reach
an account it provisioned itself.
This is the one place where the SAML driver is weaker than the OpenID Connect
one, and it is worth a reviewer's attention. #311 is explicit that
EmailVerifiedmust come from the provider's own claim because that is whatlinkByEmailacts on. SAML has no such claim, so the choice is betweenverification-by-configuration and never linking a SAML login to an existing
admin — which would silently create duplicate accounts for people who already
have one.
Configuration
One provider from the environment:
Or, where the deployment cannot reach the metadata — an egress policy, or a
provider that only offers a file — the two fields it would have read from it:
Setting both forms is refused rather than merged, at both levels: metadata URL
versus explicit fields, and
AUTH_SAML_*versusauth.saml.providers.The entity id is taken verbatim and compared exactly. It is an opaque URI —
Entra publishes an
httpsURL, others publish aurn:with no host — so unlikean OpenID Connect issuer it is never parsed, and there is no origin to bind the
metadata document to. What binds it instead: the operator chose the URL, it must
pass the outbound policy (which refuses plaintext), and the document must name
the entity id the operator configured.
Several providers are declared in the configuration file, as in #311. The
service provider key material sits on
auth.samlrather than on a provider,because a deployment is one service provider however many directories it
federates with.
Endpoints
GET /api/auth/saml/{provider}/start→ provider →POST .../acs.GET .../metadatapublishes this deployment's own service provider metadata, soan operator registers a URL rather than copying an entity id, an ACS URL and a
certificate between two screens.
The redirect binding is preferred for the request; a provider advertising only
HTTP-POST gets a self-submitting form, built from this deployment's own
AuthnRequest under a CSP that permits exactly that.
Refused deliberately
unsolicited response answers no request this deployment issued, so RelayState,
the browser binding and
InResponseToall have nothing to check. Supporting itwould mean accepting assertions with none of those guarantees.
as an identity would provision a new admin on every sign-in and strand the
real account. Refused with a message that says what to ask the administrator
for.
EntitiesDescriptor). Choosing one entity out of afederation bundle means choosing which provider signs this deployment's
logins; that is the operator's decision to write down.
Console
OpenID Connect and SAML collapse into one "Continue with single sign-on" choice
on the method picker, and the providers behind them into one list of buttons
carrying their own protocol. "Sign in with OpenID Connect" versus "sign in with
SAML" is not a choice anybody signing in wants to make.
Verification
Both build tags compile and vet clean,
make lintclean (the console's 60prettier warnings are pre-existing and in untouched files),
make generatenodiff, console typechecks, vitest 83 passed.
Tests use a stub identity provider that mints real signed assertions with a
throwaway key, so signature verification is exercised rather than stubbed, and
the AuthnRequest ID is inflated back out of the redirect exactly as a real
provider would read it. Nothing reaches the network beyond loopback.
The login flow is covered end to end — real endpoints, real Postgres and Redis —
across every configuration the driver supports:
sso_url+ certificateTestSAMLLogin,TestSAMLLoginRefusalsmetadata_url, document served over loopbackTestSAMLLoginThroughPublishedMetadataTestSAMLLoginOverThePostRequestBindingTestSAMLSignsItsAuthnRequest,TestSAMLAcceptsAnEncryptedAssertionRefusals: unsigned, signed-by-another-key, wrong
audience/issuer/destination/
InResponseTo, expired, RelayState redeemed twice,assertion replayed under a fresh RelayState, IdP-initiated, wrong-browser
binding, transient NameID, no address. Plus a binding shared across two tabs.
The signed-request test verifies the signature the way the provider would —
over the query string up to the signature itself — against the certificate the
published metadata carries. The encrypted-assertion test also checks that an
envelope this deployment can open, carrying an assertion signed by somebody
else or not signed at all, is still refused: decryption is not authentication.
One pre-existing failure in this package,
TestUpsertOrganizationScheduledRecurring,is date-dependent and fails identically on
feat/admin-oidc-ssowith none ofthis branch applied. Not touched here.
Reviewer notes
crewjam/samlv0.5.1 bringsgoxmldsig,etreeandmattermost/xml-roundtrip-validator. Onlysaml.ServiceProvideris used —samlspwants to own session handling, which is whatauth.Exchangeris for.goxmldsigis pinned to v1.6.0, above whatcrewjam/samlrequires. Itsv1.4.0 carries a loop-variable signature bypass (GHSA-qpr9-2mgv-qcjp) on
exactly the path a response is proved on;
govulncheckflags it as reachablefrom
SAML.Complete. The API is unchanged, so the requirement is raised hererather than waiting for an upstream release.
govulncheckreports nothingagainst the SAML dependencies after the bump.
internal/sso/flow.go's Redis mechanics are factored into an unexportedsingleUsethat both protocols' flow stores share. The OpenID Connect store'sbehaviour and its key prefix are unchanged; the keyspaces stay separate so a
value issued for one protocol is not redeemable in the other.
verifiers.Buildnow returns a*Federatedholding both sets rather than a*OIDCSet. That is the only change to feat(auth): sign in with the deployment's OpenID Connect providers #311's Go surface.the WS-Federation claim URI, the X.500 OID and the bare words, in that order.
A name the operator sets is used on its own — falling back would mean a
deployment that pointed at a specific attribute silently reading another.
same for all of them, because the ACS URL in it is not.
has not been rendered in a browser.