saml

package
v0.5.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 12, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package saml is the SAML 2.0 Web Browser SSO Profile implementation of the auth.Provider interface defined in pkg/auth.

The package wraps crewjam/saml's low-level ServiceProvider directly (NOT the high-level samlsp middleware) because cmd/api issues its own JWT cookies after the SAML round-trip — see cmd/api/handlers/auth_jwt.go. Matches the shape of the OIDC provider in pkg/auth/oidc.

Security: every operation rejects malformed XML, unsigned assertions, audience mismatches, NotBefore/NotOnOrAfter window violations, and replays. See docs/proposals/osctrl-auth-providers-v0.1-spec.md §"SAML 2.0 provider design" — threat catalogue S1–S11.

Index

Constants

View Source
const (
	// DefaultUsernameAttribute is the value of Config.UsernameAttribute
	// when the operator hasn't configured one. Empty means "use the
	// assertion's NameID element verbatim." A non-empty value means
	// "look for a <saml:Attribute Name=...> in the AttributeStatement
	// and use its value."
	//
	// NameID is the SAML spec's primary identity field; most IdPs
	// emit a clean string there (email, employeeID, etc). Operators
	// who need a different identity field (e.g. "uid" from LDAP-
	// backed IdPs) override via --saml-username-attribute.
	DefaultUsernameAttribute = ""

	// DefaultGroupsAttribute is consulted by hasRequiredGroup when
	// the operator hasn't set --saml-groups-attribute. Empty means
	// "no groups gate" — when paired with empty RequiredGroups, the
	// gate is disabled. When RequiredGroups is non-empty but
	// GroupsAttribute is empty, Validate() rejects the config (the
	// operator clearly forgot one of the two).
	DefaultGroupsAttribute = ""

	// DefaultReplayWindow is the maximum clock skew tolerated on
	// assertion NotBefore / NotOnOrAfter validation. 5 minutes
	// matches the SAML spec recommendation. Configurable via
	// Config.ReplayWindow.
	DefaultReplayWindow = 5
)

Default values applied when a Config field is left at the zero value.

Variables

View Source
var (
	// ErrStateMismatch — the RelayState param the IdP echoed back
	// doesn't match the state cookie's nonce. Threat S10 (RelayState
	// injection) and the SAML analogue of OIDC CSRF.
	ErrStateMismatch = errors.New("saml: state mismatch")

	// ErrParseResponse wraps any failure in crewjam's ParseResponse —
	// signature verification, audience check, NotBefore/NotOnOrAfter,
	// InResponseTo, recipient. Threats S1, S2, S4, S5, S6, S7.
	ErrParseResponse = errors.New("saml: assertion validation failed")

	// ErrMissingSAMLResponse — the POST body has no SAMLResponse form
	// field. Most likely a misconfigured IdP or a stale link; reject
	// with a clean log.
	ErrMissingSAMLResponse = errors.New("saml: SAMLResponse missing from request")

	// ErrGroupNotAllowed — RequiredGroups gate denial. SAML equivalent
	// of OIDC's T17.
	ErrGroupNotAllowed = errors.New("saml: user not in required group")

	// ErrUsernameInvalid — sanitizeUsername rejected the resolved
	// username. Threats S-equivalent of T23 + audit-log poisoning.
	ErrUsernameInvalid = errors.New("saml: username failed character validation")

	// ErrReplay — the assertion's ID has been seen before within the
	// replay window. Threat S3 (assertion replay). The crewjam library
	// rejects expired assertions but not replayed-within-window ones;
	// we add a small in-memory ring buffer.
	ErrReplay = errors.New("saml: assertion replay detected")
)

Errors returned by HandleCallback. Callers map these to HTTP status codes. Strings are stable across versions; logs may use them, but client responses should be a single generic "authentication failed" (timing-oracle defense, threat S1-related).

Functions

This section is empty.

Types

type Config

type Config struct {
	// IDPMetadataURL points at the IdP's published SAML metadata
	// document (XML). The Provider fetches this once at startup,
	// caches the certs and endpoints from it, and refreshes
	// periodically (crewjam handles the cache lifecycle). One of
	// IDPMetadataURL OR IDPMetadataXML must be set.
	IDPMetadataURL string

	// IDPMetadataXML is an alternative to IDPMetadataURL when the
	// operator has the metadata file on disk (e.g. air-gapped
	// deployments). Mutually exclusive with IDPMetadataURL.
	IDPMetadataXML string

	// EntityID is the SP's entity identifier — what the IdP knows
	// us by. Conventionally the metadata URL, e.g.
	// "http://192.168.31.239:8088/api/v1/auth/saml/metadata".
	// Required.
	EntityID string

	// ACSURL is the Assertion Consumer Service URL — where the
	// IdP POSTs the signed SAMLResponse. Must match the value the
	// IdP has registered. Required.
	ACSURL string

	// SignOnURL is the IdP's SSO endpoint we redirect users to.
	// When IDPMetadataURL is provided, this is auto-discovered
	// and the operator-provided value (if any) is ignored. Kept
	// as a config field for operators who hand-paste metadata
	// (IDPMetadataXML) and don't want to embed a full bindings
	// section in the XML.
	SignOnURL string

	// UsernameAttribute names the <saml:Attribute> whose value
	// becomes AdminUser.Username. Empty means "use NameID."
	UsernameAttribute string

	// GroupsAttribute names the <saml:Attribute> whose
	// AttributeValue children carry the user's group memberships.
	// Empty disables the groups gate.
	GroupsAttribute string

	// RequiredGroups is the list of groups at least one of which
	// must be present in the user's assertion for login to succeed.
	// Empty disables the gate.
	RequiredGroups []string

	// JITProvision enables Just-In-Time AdminUser row creation on
	// first successful login. Matches the OIDC field semantics.
	JITProvision bool

	// SigningCertPath + SigningKeyPath are PEM paths to the SP's
	// signing certificate + private key. When both are set, the
	// provider signs every outbound AuthnRequest with RSA-SHA256,
	// advertises AuthnRequestsSigned="true" in SP metadata, and
	// includes the public cert in the metadata's KeyDescriptor.
	//
	// Threat closed by signing: an attacker who can MITM the
	// browser→IdP redirect chain (malicious extension, hostile
	// network) cannot tamper with the AuthnRequest's fields
	// (ForceAuthn, AssertionConsumerServiceURL, etc) without
	// invalidating the signature — the IdP's verification rejects
	// the mutated request. Without signing the realistic attack
	// surface is narrow (the HMAC state cookie binds the eventual
	// response back to the same browser, so the attacker can't
	// redirect responses to themselves) but downgrade attacks on
	// the request itself become impossible.
	//
	// Empty values disable signing (D5 in the spec — v1 default
	// for operators who don't want to manage an SP signing key).
	// Production deployments should set them.
	SigningCertPath string
	SigningKeyPath  string

	// ForceAuthn, when true, sets ForceAuthn="true" on every
	// AuthnRequest we emit. Keycloak / Auth0 / Okta will then
	// re-prompt the user for credentials even if their IdP-side SSO
	// cookie is still alive. Without this, clicking "Continue with
	// SAML" after a logout silently re-authenticates against the
	// existing IdP session — which feels like the logout didn't
	// work even though the SP session was properly cleared.
	//
	// This is the v1 pragmatic substitute for proper SAML SLO (which
	// is deferred to v2). Operators who want the silent-reauth UX
	// can flip this off, with the trade-off that logout will only
	// affect the SP session.
	ForceAuthn bool

	// RequireAssertionSigned MUST be true for production deployments.
	// crewjam's default is true; we expose the field to make the
	// invariant visible in config files. Setting false disables S2
	// defense and is rejected by Validate().
	RequireAssertionSigned bool

	// ReplayWindow is the maximum clock skew tolerated on NotBefore /
	// NotOnOrAfter checks, in minutes. Defaults to DefaultReplayWindow.
	ReplayWindow int

	// LegacyPermissiveUsername bypasses the strict username regex
	// the same way the OIDC config field does. Set true ONLY by
	// the legacy cmd/admin code path which has pre-existing
	// AdminUser rows with email-format usernames. cmd/api leaves
	// it false.
	LegacyPermissiveUsername bool
}

Config bundles the parameters cmd/api passes to NewSAMLProvider. Decoupled from viper / YAML so the package can be exercised from tests without dragging in the config tree.

func (Config) Validate

func (c Config) Validate() error

Validate enforces the structural invariants on Config. Called once at startup by NewSAMLProvider before any IdP interaction.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider is the concrete SAML 2.0 implementation of auth.Provider. Constructed once at startup, safe for concurrent use. The underlying crewjam ServiceProvider is configured during NewSAMLProvider; we never mutate its fields after construction.

func NewSAMLProvider

func NewSAMLProvider(ctx context.Context, cfg Config) (*Provider, error)

NewSAMLProvider constructs a Provider from the given Config. The context bounds the IdP-metadata fetch (when Config.IDPMetadataURL is set); pass a context with a deadline so a hung IdP at init time doesn't wedge startup.

Returns a non-nil error and a nil Provider on:

  • Config validation failure
  • IdP metadata fetch / parse failure
  • SP URL parse failure

The returned Provider's ServiceProvider is configured to require signed assertions (S2 defense) and the default crewjam clock-skew allowance.

func (*Provider) HandleCallback

func (p *Provider) HandleCallback(_ context.Context, r *http.Request, state auth.State) (auth.ResolvedIdentity, error)

HandleCallback consumes the SAML ACS POST and returns a ResolvedIdentity. Validates, in order:

  1. POST has a SAMLResponse form field (ErrMissingSAMLResponse)
  2. RelayState echoes state.Nonce (ErrStateMismatch — S10)
  3. crewjam ParseResponse validates: signature, issuer, audience, NotBefore/NotOnOrAfter, recipient, InResponseTo, signed assertion enforcement (ErrParseResponse — S1, S2, S4, S5, S6, S7)
  4. Assertion ID has not been seen recently (ErrReplay — S3)
  5. Required-groups gate, if configured (ErrGroupNotAllowed)
  6. Resolved username passes sanitizeUsername (ErrUsernameInvalid)

Implementations MUST NOT trust the caller to pre-verify any of the above. This is the security perimeter.

func (*Provider) LoginURL

func (p *Provider) LoginURL(_ context.Context, state auth.State) (string, error)

LoginURL builds the IdP SSO URL the user's browser should be redirected to. state.OAuthState travels as RelayState — the IdP echoes it back on the ACS POST verbatim, and HandleCallback validates the echo against the state cookie. This is the SAML equivalent of the OAuth2 state parameter.

SAML has no protocol slot equivalent to the OIDC nonce, so state.Nonce is unused here. We still REQUIRE it to be present so the State invariant (Nonce and OAuthState are independent random values) holds uniformly across providers — a caller that fills only OAuthState would mask a subtle bug if the deployment later added a second provider.

state.Verifier is unused for SAML (no PKCE equivalent in the SAML Web Browser SSO profile); we accept it for interface uniformity but ignore it.

func (*Provider) LoginURLWithRequestID

func (p *Provider) LoginURLWithRequestID(state auth.State) (loginURL string, requestID string, err error)

LoginURLWithRequestID is the SAML-specific variant of LoginURL that ALSO returns the AuthnRequest ID. Callers (cmd/api SAMLLoginHandler) must store this ID in the state cookie (auth.State.SAMLRequestID) so HandleCallback can pass it back to ParseResponse as the expected InResponseTo. crewjam.ParseResponse with possibleRequestIDs=nil REJECTS responses carrying any InResponseTo at all — which is every Keycloak/Auth0 SP-initiated response, since IdPs always echo the AuthnRequest ID. Without this dance, the only options are reject-all (status quo before May 2026 fix) or accept-any (S7 defense gone).

Round-tripping the ID through the HMAC-signed state cookie ties the expected InResponseTo to this specific browser session — an attacker without the cookie cannot forge a response that satisfies the check.

func (*Provider) Metadata

func (p *Provider) Metadata() ([]byte, error)

Metadata returns the SP metadata XML bytes that should be served at the SP metadata endpoint. The IdP fetches this to learn the SP's EntityID and ACS URL.

func (*Provider) Type

func (p *Provider) Type() string

Type identifies this provider as SAML.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL