saml

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 39 Imported by: 0

README

saml2-sp

Server-side SAML 2.0 service provider for Go. It sends a signed AuthnRequest on HTTP-Redirect or HTTP-POST, accepts a Response on HTTP-POST, HTTP-Redirect, or HTTP-Artifact, verifies XML signatures, decrypts encrypted assertions, and can poll a Shared Signals transmitter for CAEP and RISC events.

Version 0.1.0. License: MIT. develop and main are one commit. Publishing is not set up.

Usage

sp, err := saml.FromFile("saml.json", nil, nil)
relay := "/home"
request, err := sp.Login(saml.LoginOptions{RelayState: &relay})
who, err := sp.Acs(method, rawURL, body, []string{request.ID})

The module is github.com/rcbj/saml2-sp-go and the package is saml. saml.json uses the same keys as the Python service provider: entityId, acsUrl, privateKey, certificate, idpMetadata, and the optional requestBinding, responseBinding, idpCa, idpEntityId, nameIdFormat, clockSkewSeconds, wantAssertionsSigned, wantAssertionsEncrypted, allowUnsolicited, and signals. A value may be PEM text, a file path, or an env: / file: placeholder. signals omitted or true leaves the Shared Signals receiver available and is omitted from the resolved configuration. false turns it off, does not require transmitter metadata, and is stored as signals: false. Any other value is a configuration error: signals must be true or false. SAML does not send OAuth scopes, so the flag does not change the AuthnRequest.

Login returns a redirect URL or PostFormHTML fields. Acs accepts a POST body as a string or a map. Artifact resolution is a signed SOAP ArtifactResolve. HTTP-Redirect query signatures use RSA-SHA256 (http://www.w3.org/2001/04/xmldsig-more#rsa-sha256). Success is urn:oasis:names:tc:SAML:2.0:status:Success.

docker compose build
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$PWD/demo:/app/demo" \
  saml2-sp-go-demo \
  saml2-sp generate-keys --config demo/saml.json
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$PWD/demo:/app/demo" \
  saml2-sp-go-demo \
  saml2-sp metadata --config demo/saml.json

Shared Signals

receiver, err := saml.NewSignalsReceiver(saml.ReceiverOptions{
    Transmitter: "https://idp.example",
    OnEndSessions: func(ids []string, event *saml.SignalEvent) error { return nil },
})
receiver.Track(localID, login, nil)
err = receiver.Start()
receiver.Stop()

The receiver discovers the transmitter, keeps one poll stream (urn:ietf:rfc:8936), and verifies each Security Event Token against the published JWKS. Stop waits until a poll already running has finished. By default a session ends on session-revoked, account-disabled, account-purged, credential-compromise, and sessions-revoked.

Tests

docker build --target test .

The suite runs in a container.

Signatures and decryption are real (crypto/rsa, exclusive XML canonicalization). The stub identity provider is in the test files. No live iya-sts. Keys are generated in the test.

python3 scripts/live_iya_sts.py is the live half. It builds this demo image, starts the latest main branch of iya-sts in memory mode with persistence and clustering off when IYA_STS_URL is unset (IYA_STS_IMAGE uses an image already built), copies the checkout (without demo/generated) to a scratch build context, builds the demo image there, runs scripts/configure_iya_sts.py inside it (which writes the trust chain where idpCa names it), builds the demo image again so it carries that chain, checks the image's copy matches, runs the demo with only the key pair and the IdP metadata mounted, signs in, and requires a credential-change event and a session-revoked event on GET /live-status. The second event ends the local session. It needs only Docker and the host's standard-library python3; Go never runs on the host. Its ports are 18522 (iya-sts) and 18622 (the demo), replaced by LIVE_STS_PORT and LIVE_DEMO_PORT; it never binds 3000, 8081, or 18481 to 18495. Always use a fresh iya-sts: a second registration of the same SP fails. The nightly workflow (.github/workflows/nightly.yml, 07:26 UTC) runs the unit suite and this script once every 24 hours. Push CI stays the unit suite.

Demo

docker compose up --build. GET /healthz is 200 with body ok and does not dial an identity provider. Pages: sign-in, /login (redirect or POST form), /saml/acs, and logout. GET /live-status is JSON: signedIn (true when any local session exists), subject, the receiver state, and the Shared Signals events this process has accepted (type and jti only). No assertions. If the assertion SessionIndex is not the CAEP session id, the event handler ends this demo's one session and says so, so session-revoked leaves signedIn false. Loopback entityID and assertion-consumer URLs follow PORT (default 3000). Other hosts stay as written. PUBLIC_URL or --public-url replaces the origin and keeps the path. The default http://localhost:3000 is not an explicit public URL. demo/saml.json uses file: placeholders and omits signals. Do not commit demo/generated/.

scripts/configure_iya_sts.py is standard-library Python. It calls the saml2-sp binary on PATH (the demo image has one, plus python3 and openssl) for keys, metadata, and the identity-provider summary, and falls back to go run ./cmd/saml2-sp. A generated test-user password is never printed: it goes to demo/generated/<user>.password (mode 0600) and the script prints only that path. In the image: docker run --rm --network host -v "$PWD/demo:/app/demo" -e ADMIN_API_CLIENT_SECRET -e IYA_STS_URL saml2-sp-go-demo python3 scripts/configure_iya_sts.py. The container PORT wins over the shell PORT.

iya-sts trust chain (idpCa)

idpCa names iya-sts's TLS listener CA trust chain; in demo/saml.json it is file:generated/idp-ca.pem (relative to demo/). The library adds every certificate in that file to its x509.CertPool as a root, so a whole chain works, and Go's TLS client still checks that the listener's certificate names the host (DNS or IP SAN); a forged leaf in front of the real CA certificates is refused. The library reads it for the metadata fetch, artifact resolution, and the Shared Signals receiver.

Plain http. Identity-provider metadata from a URL, artifact resolution and every Shared Signals transmitter call (configuration, JWKS, stream and poll) use https. A plain http:// URL is refused before any DNS lookup or connection unless its host is loopback (127.0.0.0/8, ::1, localhost), because metadata carries the signing keys, an ArtifactResolve returns the assertion, and Shared Signals carry SETs and the bearer token. A redirect to plain http off loopback is refused the same way (Go follows https to http, so this covers an https URL too), a URL with userinfo is never loopback, and a URL containing whitespace or a control character is refused before it is parsed, so two parsers cannot disagree about its host. The error keeps the fetch's code (metadata, artifact, ssf) and names the scheme and host only. There is no opt-out. The service provider's own entityID and ACS URLs may stay http: they are never fetched.

scripts/configure_iya_sts.py writes it on every run, through scripts/trust_chain.py: the whole chain iya-sts publishes at GET /tls/server-certificate (leaf, TLS Issuing CA, Intermediate, Root), or the chain the TLS handshake presents when that endpoint is missing. It prints the path, the trust anchor's subject, and its SHA-256; compare the anchor with the Root CA on iya-sts's Server configuration, PKI page. The fetch itself is not verified (trust on first use). python3 scripts/trust_chain.py --config demo/saml.json --idp <url> refreshes the file alone.

The demo image build copies that file in: .dockerignore admits only demo/generated/idp-ca.pem from demo/generated/ (the key pair and the metadata stay out), and COPY demo demo, after the Go build, puts it at /app/demo/generated/idp-ca.pem, where idpCa names it. A build with no chain file yet (a fresh clone, CI) still succeeds and carries none. Run the install script, then build the image; after running it against a different iya-sts, rebuild (docker compose up --build). Compose also mounts demo/generated (for the key pair and the metadata), which holds the same file.

Documentation

Index

Constants

View Source
const DefaultSubjectAttribute = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"

Variables

View Source
var Event = map[string]string{
	"session_revoked":                    caepPrefix + "session-revoked",
	"token_claims_change":                caepPrefix + "token-claims-change",
	"credential_change":                  caepPrefix + "credential-change",
	"assurance_level_change":             caepPrefix + "assurance-level-change",
	"device_compliance_change":           caepPrefix + "device-compliance-change",
	"session_established":                caepPrefix + "session-established",
	"session_presented":                  caepPrefix + "session-presented",
	"risk_level_change":                  caepPrefix + "risk-level-change",
	"account_credential_change_required": riscPrefix + "account-credential-change-required",
	"account_purged":                     riscPrefix + "account-purged",
	"account_disabled":                   riscPrefix + "account-disabled",
	"account_enabled":                    riscPrefix + "account-enabled",
	"identifier_changed":                 riscPrefix + "identifier-changed",
	"identifier_recycled":                riscPrefix + "identifier-recycled",
	"credential_compromise":              riscPrefix + "credential-compromise",
	"opt_in":                             riscPrefix + "opt-in",
	"opt_out_initiated":                  riscPrefix + "opt-out-initiated",
	"opt_out_cancelled":                  riscPrefix + "opt-out-cancelled",
	"opt_out_effective":                  riscPrefix + "opt-out-effective",
	"recovery_activated":                 riscPrefix + "recovery-activated",
	"recovery_information_changed":       riscPrefix + "recovery-information-changed",
	"sessions_revoked":                   riscPrefix + "sessions-revoked",
	"verification":                       ssfPrefix + "verification",
	"stream_updated":                     ssfPrefix + "stream-updated",
}

Event URIs for CAEP, RISC, and SSF.

Functions

func CAEPEvents

func CAEPEvents() []string

CAEPEvents is every CAEP event type, in declaration order.

func DefaultEndingEvents

func DefaultEndingEvents() []string

DefaultEndingEvents end the local sessions they name.

func DefaultEventsRequested

func DefaultEventsRequested() []string

DefaultEventsRequested is the receiver's default subscription.

func MetadataDocument

func MetadataDocument(cfg *Config) string

MetadataDocument is this service provider's metadata, without contacting the identity provider.

func PostFormHTML

func PostFormHTML(action string, fields map[string]string, nonce, title string) string

PostFormHTML is the browser form that carries an HTTP-POST SAML message.

func RISCEvents

func RISCEvents() []string

RISCEvents is every RISC event type, in declaration order.

func SubjectOf

func SubjectOf(subID any, issuer string) map[string]string

SubjectOf reads who an event's sub_id names.

func VerifyCompactJWS

func VerifyCompactJWS(token string, keys []map[string]any) (map[string]any, map[string]any, error)

VerifyCompactJWS checks a Security Event Token against the transmitter's keys.

Types

type Config

type Config struct {
	EntityID                string
	AcsURL                  string
	PrivateKey              *rsa.PrivateKey
	PrivateKeyPEM           string
	Certificate             *x509.Certificate
	CertificatePEM          string
	IdpMetadata             string
	RequestBinding          string
	ResponseBinding         string
	ClockSkewMS             int64
	WantAssertionsSigned    bool
	WantAssertionsEncrypted bool
	AllowUnsolicited        bool
	Signals                 *bool
	IdpCA                   string
	IdpEntityID             string
	NameIDFormat            string
}

Config is the configuration with its files read and its defaults filled in. Signals is present only when CAEP and RISC are off. A nil Signals means on.

func ReadConfigFile

func ReadConfigFile(path string, overrides map[string]any) (*Config, error)

ReadConfigFile loads a JSON configuration. Nil overrides are ignored.

func ResolveConfig

func ResolveConfig(config map[string]any, baseDir string) (*Config, error)

ResolveConfig reads files named by the configuration and fills in defaults.

func (*Config) Has

func (c *Config) Has(key string) bool

type IdpMetadata

type IdpMetadata struct {
	EntityID                   string
	SSO                        map[string]string
	ArtifactResolutionServices map[int]string

	Certs                   []*x509.Certificate
	WantAuthnRequestsSigned bool
	// contains filtered or unexported fields
}

IdpMetadata is the identity provider material this service provider needs.

func ReadIDPMetadata

func ReadIDPMetadata(xml string) (*IdpMetadata, error)

ReadIDPMetadata reads an identity provider's metadata document.

type KeyPair

type KeyPair struct {
	PrivateKeyPEM  string
	CertificatePEM string
	Certificate    *x509.Certificate
	PrivateKey     *rsa.PrivateKey
}

KeyPair is a self-signed certificate for a service provider's signing key.

func CreateSelfSignedKeyPair

func CreateSelfSignedKeyPair(commonName string, days, bits int) (*KeyPair, error)

CreateSelfSignedKeyPair writes an RSA key and a self-signed certificate.

func (KeyPair) Fingerprint256

func (k KeyPair) Fingerprint256() string

func (KeyPair) ValidTo

func (k KeyPair) ValidTo() string

type Logger

type Logger interface {
	Info(message string)
	Warn(message string)
	Error(message string)
}

Logger receives the receiver's operational messages.

type LoginOptions

type LoginOptions struct {
	RelayState        *string
	ForceAuthn        bool
	IsPassive         bool
	SubjectHint       *string
	SubjectHintFormat *string
	RequestBinding    string
	ResponseBinding   string
	Nonce             string
}

LoginOptions selects how an AuthnRequest is built. A nil RelayState is omitted.

type LoginRequest

type LoginRequest struct {
	ID              string
	Binding         string
	ResponseBinding string
	URL             string
	XML             string
	Fields          map[string]string
	HTML            string
}

LoginRequest is the signed AuthnRequest, as a redirect or a POST form.

type MemoryReplayCache

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

MemoryReplayCache remembers which assertions have been accepted.

func NewMemoryReplayCache

func NewMemoryReplayCache(maxEntries int) *MemoryReplayCache

func (*MemoryReplayCache) CheckAndRecord

func (c *MemoryReplayCache) CheckAndRecord(assertionID string, expiresAt time.Time) (bool, error)

CheckAndRecord reports whether assertionID was already accepted.

type MemorySessionIndex

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

MemorySessionIndex remembers which local sessions belong to which person.

func NewMemorySessionIndex

func NewMemorySessionIndex(maxEntries int) *MemorySessionIndex

func (*MemorySessionIndex) Add

func (s *MemorySessionIndex) Add(session SessionRecord)

func (*MemorySessionIndex) ByEmail

func (s *MemorySessionIndex) ByEmail(email string) []string

func (*MemorySessionIndex) ByIdPSession

func (s *MemorySessionIndex) ByIdPSession(idpSession string) []string

func (*MemorySessionIndex) ByPerson

func (s *MemorySessionIndex) ByPerson(person string) []string

func (*MemorySessionIndex) Remove

func (s *MemorySessionIndex) Remove(localID string)

type ReceiverOptions

type ReceiverOptions struct {
	Transmitter      string
	OnEndSessions    func(ids []string, event *SignalEvent) error
	TLS              *TLSOptions
	Headers          func() map[string]string
	EventsRequested  []string
	EndOn            []string
	SessionIndex     *MemorySessionIndex
	SubjectAttribute string
	Description      string
	PollIntervalMS   int
	MaxEvents        int
	Log              Logger
}

ReceiverOptions configures a Shared Signals poll receiver.

type SamlError

type SamlError struct {
	Code    string
	Message string
	Status  *SamlStatus
	Cause   error
}

SamlError is every failure this package reports.

func (*SamlError) Error

func (e *SamlError) Error() string

func (*SamlError) Unwrap

func (e *SamlError) Unwrap() error

type SamlLogin

type SamlLogin struct {
	NameID                 string
	NameIDFormat           string
	NameQualifier          string
	SPNameQualifier        string
	SessionIndex           string
	SessionNotOnOrAfter    *time.Time
	AuthnInstant           time.Time
	AuthnContextClassRef   string
	Attributes             map[string][]string
	AttributeFriendlyNames map[string]string
	Issuer                 string
	InResponseTo           *string
	RelayState             *string
	Binding                string
	Encrypted              bool
	ResponseID             string
	AssertionID            string
}

SamlLogin is a person the assertion consumer accepted.

type SamlStatus

type SamlStatus struct {
	Code    string
	SubCode string
	Message string
}

SamlStatus is a non-success status an identity provider answered with.

func (SamlStatus) String

func (s SamlStatus) String() string

type ServiceProvider

type ServiceProvider struct {
	Config *Config
	Idp    *IdpMetadata
	// contains filtered or unexported fields
}

ServiceProvider is the SAML 2.0 service provider.

func Create

func Create(config map[string]any, baseDir string, replay *MemoryReplayCache) (*ServiceProvider, error)

Create resolves config and loads the identity provider's metadata.

func FromFile

func FromFile(path string, overrides map[string]any, replay *MemoryReplayCache) (*ServiceProvider, error)

FromFile loads saml.json. Nil override values are ignored.

func (*ServiceProvider) Acs

func (sp *ServiceProvider) Acs(method, rawURL string, body any, requestIDs []string) (*SamlLogin, error)

Acs consumes a Response on POST, redirect, or artifact.

func (*ServiceProvider) Login

func (sp *ServiceProvider) Login(opt LoginOptions) (*LoginRequest, error)

Login builds a signed AuthnRequest.

func (*ServiceProvider) Metadata

func (sp *ServiceProvider) Metadata() string

Metadata is this service provider's metadata.

func (*ServiceProvider) RefreshMetadata

func (sp *ServiceProvider) RefreshMetadata() (*IdpMetadata, error)

RefreshMetadata reads the identity provider metadata again.

type SessionRecord

type SessionRecord struct {
	LocalID    string
	Person     string
	Email      string
	IdPSession string
	ExpiresAt  *time.Time
}

SessionRecord is one local session the receiver can end.

func TrackedFrom

func TrackedFrom(localID string, login *SamlLogin, subjectAttribute string, expiresAt *time.Time) SessionRecord

TrackedFrom builds the index record for a SAML login.

type SignalEvent

type SignalEvent struct {
	Type     string
	Payload  map[string]any
	Subject  map[string]string
	JTI      string
	Sessions []string
	Ended    bool
	Iat      any
	Txn      *string
}

SignalEvent is one event inside a Security Event Token.

type SignalsReceiver

type SignalsReceiver struct {
	Stream map[string]any
	// contains filtered or unexported fields
}

SignalsReceiver polls one RFC 8936 stream of CAEP and RISC events.

func NewSignalsReceiver

func NewSignalsReceiver(opt ReceiverOptions) (*SignalsReceiver, error)

NewSignalsReceiver builds a receiver. It does not contact the transmitter until Start.

func (*SignalsReceiver) On

func (r *SignalsReceiver) On(eventType string, handler func(*SignalEvent) error) *SignalsReceiver

On registers a handler for one event type, or "*" for every type.

func (*SignalsReceiver) PollOnce

func (r *SignalsReceiver) PollOnce() (bool, error)

PollOnce fetches one batch. Concurrent callers share that single request.

func (*SignalsReceiver) Start

func (r *SignalsReceiver) Start() error

Start discovers the transmitter, ensures one poll stream, and polls once before returning.

func (*SignalsReceiver) Stop

func (r *SignalsReceiver) Stop()

Stop cancels the next poll and waits until a poll already running has finished.

func (*SignalsReceiver) Track

func (r *SignalsReceiver) Track(localID string, login *SamlLogin, expiresAt *time.Time)

Track remembers a local session so a later event can end it.

func (*SignalsReceiver) Untrack

func (r *SignalsReceiver) Untrack(localID string)

Untrack forgets a local session.

func (*SignalsReceiver) Verify

func (r *SignalsReceiver) Verify(state string) error

Verify asks the transmitter to deliver a verification event.

type TLSOptions

type TLSOptions struct {
	CA   string
	Cert string
	Key  string
}

TLSOptions is the back channel's trust and client certificate.

Directories

Path Synopsis
cmd
demo command
saml2-sp command

Jump to

Keyboard shortcuts

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