saml

package
v0.1.23 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Exclusive XML Canonicalization (exc-c14n) per https://www.w3.org/TR/xml-exc-c14n/

This is the byte-exact canonical form that an XML signature signs. Getting it wrong means valid signatures appear invalid and - worse - an implementation that gets it mostly right will pass in testing against its own signatures while rejecting real IdP responses.

The rules that matter for SAML:

  • Namespace declarations are rendered only if visibly utilised by the element or its attributes.
  • Inherited namespaces that an element uses but did not declare are rendered as if declared on that element.
  • Attributes are sorted: namespace declarations first (sorted by prefix), then other attributes (sorted by expanded name: namespace URI then local name).
  • Empty elements use start-tag/end-tag, never self-closing.
  • Text nodes are output verbatim; CDATA is replaced by its content with entity escaping.
  • Processing instructions and comments outside the document element are excluded.

Package saml implements a SAML 2.0 Service Provider.

Written against the standard library for the same reason as Perfuse's OIDC implementation: authentication is the one place where an unaudited dependency is least acceptable, and the attack surface of a SAML library is larger than most because SAML's security rests on XML canonicalization and signature verification, both of which have produced CVEs in every major implementation.

The scope is deliberately narrow: verify that an IdP signed an assertion, that the assertion is fresh and for us, and report what it said about the person. The SP does not implement single logout, artifact binding, or encrypted assertions - those are added when a deployment needs them, not speculatively.

Security rules:

  • Never trust a certificate embedded in the SAML response. Only the configured IdP certificate is used.
  • Verify the signature first, then read identity from the SAME signed node (XSW protection).
  • Reject expired or not-yet-valid assertions (30 second clock skew tolerance).
  • Reject replayed assertion IDs within their validity window.
  • Reject XML with DOCTYPE/ENTITY declarations (billion laughs prevention).

Signature and condition validation for SAML assertions.

The security model is: verify the signature first, then read identity from the same signed node. Never from a different node, never from a certificate embedded in the response, and never from an assertion whose conditions have expired.

Safe XML parsing for SAML responses.

SAML uses XML, which is a remarkable attack surface. The parser here limits entity expansion to prevent the billion laughs attack (exponential entity expansion that consumes all memory), limits document depth to prevent stack exhaustion, and refuses external entity references entirely.

None of Go's encoding/xml processes external entities or DTDs in the dangerous way that libxml2 or Java's SAX do by default, but we add explicit guards because relying on a negative (a thing the library happens not to do today) is fragile, and the guards cost nothing.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func SignDocument

func SignDocument(bodyWithPlaceholder, refID string, key *rsa.PrivateKey) (string, error)

SignDocument produces an XML signature over a document, for testing and for standing in as an identity provider.

Exported reluctantly and for one reason: producing a valid XML signature requires the same canonicalisation the verifier uses, and that is unexported. Anything outside this package that needs to build a signed response - the API layer's sign-in tests, a local identity provider used to try the flow by hand - would otherwise have to reimplement exclusive canonicalisation, and a second implementation would drift from the first. When it drifted, the tests would go on passing while agreeing about the wrong bytes.

bodyWithPlaceholder must contain the literal {SIGNATURE} where the signature belongs. The digest is taken over the document with that placeholder removed, which is what the enveloped-signature transform means.

This signs. It verifies nothing and grants nothing, so it is not a way around any check in this package: a document signed with a key the service provider is not configured to trust is rejected exactly as before.

Types

type Assertion

type Assertion struct {
	// NameID is the subject identifier the IdP assigned.
	NameID string

	// SessionIndex is the IdP's session reference, used for logout.
	SessionIndex string

	// Attributes are the SAML attribute statements: attribute name → list of values.
	Attributes map[string][]string

	// NotBefore is the earliest time this assertion is valid.
	NotBefore time.Time

	// NotOnOrAfter is the time after which this assertion must not be accepted.
	NotOnOrAfter time.Time
}

Assertion is the verified identity information from a SAML response.

Every field here was extracted from a signed node whose signature was verified against the configured IdP certificate, whose conditions passed, and whose assertion ID has not been seen before.

type Config

type Config struct {
	// EntityID is this SP's entity identifier, used as the Issuer in AuthnRequests and as the Audience for
	// AudienceRestriction validation.
	EntityID string

	// ACSPath is the path portion of the Assertion Consumer Service URL where the IdP posts responses.
	ACSPath string

	// IdPMetadataURL is the IdP's metadata endpoint. Used to discover the SSO URL.
	// If IdPSSOURL is set directly, this is not required.
	IdPMetadataURL string

	// IdPSSOURL is the IdP's Single Sign-On URL (HTTP-Redirect binding).
	IdPSSOURL string

	// IdPCertPEM is the PEM-encoded X.509 certificate of the IdP used to verify response signatures.
	// This is the ONLY certificate trusted for verification - never a cert embedded in the response.
	IdPCertPEM string

	// AllowUnsolicited permits a response that answers no request this server issued.
	//
	// Off by default, and the default is the safe one. An unsolicited response is how identity-provider-initiated sign-on works -
	// somebody clicks Perfuse in a portal and arrives already authenticated - so it is a real feature that real sites want. It is
	// also the thing that makes a captured response reusable against a different browser, because nothing ties the response to a
	// login that browser began.
	//
	// Turning it on is therefore a decision with a cost, and it should be made deliberately rather than inherited from a default.
	AllowUnsolicited bool

	// SignRequests indicates whether AuthnRequests should be signed.
	SignRequests bool
}

Config describes how the service provider connects to an IdP.

type FileConfig

type FileConfig struct {
	EntityID string `yaml:"entity_id"`
	ACSURL   string `yaml:"acs_url"`

	IdPSSOURL string `yaml:"idp_sso_url"`

	// IdPCertPEM is the certificate inline. Multi-line YAML, usually written with a block scalar.
	IdPCertPEM string `yaml:"idp_cert_pem,omitempty"`

	// IdPCertFile reads the certificate from a separate file.
	//
	// Offered because a certificate is rotated on the identity provider's schedule rather than Perfuse's, and a separate file can be
	// replaced by whatever already manages certificates on the host without editing this one.
	IdPCertFile string `yaml:"idp_cert_file,omitempty"`

	// GroupsAttribute names the assertion attribute carrying group membership.
	//
	// Required, with no default, and that is deliberate. Entra sends "groups", Okta sends whatever the administrator named in the
	// application, ADFS sends a claim URI. A default would be wrong for two of the three and would produce a sign-in that succeeds
	// and grants nobody anything - which reads as a broken product rather than as a setting somebody has not filled in.
	GroupsAttribute string `yaml:"groups_attribute"`

	Label       string `yaml:"label,omitempty"`
	CreateUsers bool   `yaml:"create_users,omitempty"`

	// AllowUnsolicited accepts responses that answer no request this server sent.
	//
	// Off unless written down. It is how a portal tile works, and it is also what lets a response captured anywhere be posted into
	// somebody else's browser, so it is a trade a site should make on purpose.
	AllowUnsolicited bool `yaml:"allow_unsolicited,omitempty"`

	Roles struct {
		Platform      []string `yaml:"platform,omitempty"`
		Admin         []string `yaml:"admin,omitempty"`
		Editor        []string `yaml:"editor,omitempty"`
		Operator      []string `yaml:"operator,omitempty"`
		Viewer        []string `yaml:"viewer,omitempty"`
		Monitor       []string `yaml:"monitor,omitempty"`
		CaseSensitive bool     `yaml:"case_sensitive,omitempty"`

		// ChannelGroups limits a directory group to these channel groups; absent or empty is every channel.
		ChannelGroups map[string][]string `yaml:"channel_groups,omitempty"`
	} `yaml:"roles"`
}

FileConfig is the on-disk form of SAML sign-in settings.

A file rather than flags, for the same reasons as OIDC: the certificate is multi-line and the group mapping is nested, and neither survives a command line intact. There is no secret here - a signing certificate is public by construction, which is the one way SAML is easier to handle than OIDC.

func LoadConfig

func LoadConfig(path string) (*FileConfig, error)

LoadConfig reads and validates a SAML configuration file.

Everything is checked here rather than at somebody's first sign-in, including parsing the certificate. A certificate that is truncated, PEM-wrapped twice or actually a private key is a common enough mistake, and the moment to find out is when the file is saved rather than when a person cannot get in.

func (*FileConfig) Certificate

func (c *FileConfig) Certificate() (string, error)

Certificate returns the PEM certificate, from the file when one is named.

func (*FileConfig) RoleMapping

func (c *FileConfig) RoleMapping() (platform, admin, editor, viewer []string, caseSensitive bool)

RoleMapping is the group-to-role mapping in the form the sign-in code wants.

func (*FileConfig) Validate

func (c *FileConfig) Validate(fromPath string) error

Validate reports everything wrong with a configuration, or nil.

dir is used to resolve a relative certificate file, and may be the configuration file's own path.

type IDPMetadata

type IDPMetadata struct {
	// EntityID is the provider's own identifier, which every assertion it issues carries as the Issuer.
	EntityID string

	// SSOURL is where a sign-on request is sent, for HTTP-Redirect binding.
	SSOURL string

	// SSOPostURL is the same for HTTP-POST binding, empty when the provider does not offer it.
	SSOPostURL string

	// Certificates are the signing certificates, in document order.
	//
	// More than one is normal and is not a problem to be resolved here: a provider rotating keys publishes both, and an assertion may be
	// signed with either. Keeping them all is what makes a rotation invisible rather than an outage.
	Certificates []*x509.Certificate
}

IDPMetadata is what an identity provider's metadata document says about it.

func ParseIDPMetadata

func ParseIDPMetadata(doc []byte) (*IDPMetadata, error)

ParseIDPMetadata reads a SAML 2.0 metadata document and returns what Perfuse needs to trust the provider.

Strict about the two things that would otherwise fail later and unhelpfully: a document with no signing certificate cannot verify anything, and one with no sign-on URL leaves nowhere to send anybody. Both are refused here, where the message can name the document, rather than at somebody's first attempt to sign in.

func (*IDPMetadata) CertPEM

func (m *IDPMetadata) CertPEM() string

CertPEM returns the certificates as PEM, which is the form the configuration file holds.

type ServiceProvider

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

ServiceProvider processes SAML authentication.

func New

func New(cfg Config) (*ServiceProvider, error)

New creates a ServiceProvider from the given configuration.

It parses the IdP certificate at configuration time rather than at every request, so a misconfigured certificate is reported immediately rather than at someone's first login attempt.

func (*ServiceProvider) BuildAuthnRequest

func (sp *ServiceProvider) BuildAuthnRequest(relayState string) (redirectURL, requestID string, err error)

BuildAuthnRequest creates the redirect URL for sending the user to the IdP, and the id of the request it built.

Uses HTTP-Redirect binding with deflate encoding per the SAML 2.0 bindings spec.

The caller has to keep the id and recognise it again when the response arrives, which is what ParseResponseFor is for. It used to be generated and dropped, so no response could be tied to a request even in principle.

func (*ServiceProvider) ParseResponse

func (sp *ServiceProvider) ParseResponse(samlResponse string) (*Assertion, error)

ParseResponse decodes, validates, and extracts identity from a base64-encoded SAML response.

The validation order is deliberate:

  1. Decode and check XML safety (entity expansion, depth)
  2. Parse the tree for signature verification
  3. Verify the XML signature against the configured IdP cert
  4. XSW protection: confirm the signed node is the one we read from
  5. Validate conditions: time bounds, audience, replay
  6. Extract identity from the verified assertion

func (*ServiceProvider) ParseResponseFor

func (sp *ServiceProvider) ParseResponseFor(samlResponse string, outstanding func(id string) bool) (*Assertion, error)

ParseResponseFor parses a response and requires it to answer a request this server issued.

outstanding is asked whether an InResponseTo value names a request that is still waiting, and should consume it so the same response cannot be presented twice. Pass nil only for identity-provider-initiated sign-on, which needs AllowUnsolicited.

This is the check that ties a response to a login somebody began in this browser. Without it a response is a bearer token for whoever holds it: an attacker completes a login as themselves, keeps the response, and posts it into a victim's browser, and the victim is now inside the attacker's account - reading and writing as them, with the audit log showing the attacker's name.

func (*ServiceProvider) VerifyAt

func (sp *ServiceProvider) VerifyAt(raw []byte, now time.Time, outstanding func(id string) bool) (*Assertion, error)

VerifyAt verifies a response as at a given instant, which is what makes a captured document testable.

Exported for tests in other packages that need a real provider's assertion to work with. Deliberately not an unverified parser: an entry point that skipped the signature would be the wrong shape to offer, and a caller wanting attributes from a document should still have to prove the document is genuine. The clock is a parameter because a saved assertion's validity window is in the past, and the alternative - accepting an expired document - is not something to build a seam for.

Jump to

Keyboard shortcuts

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