Documentation
¶
Overview ¶
Package saml is a SAML 2.0 service provider for an identity federation such as RENATER's Fédération Éducation-Recherche or eduGAIN: it reads the federation's signed metadata, sends people to their own identity provider, and reads back what that provider says about them.
fed := &saml.Federation{URL: "https://pub.federation.renater.fr/metadata/fer/idps.xml", Cert: renaterCert}
sp := &saml.SP{EntityID: "https://app.example.org/saml", ACS: "https://app.example.org/saml/acs",
Key: key, Cert: cert, Federation: fed}
url, pending, err := sp.Request(idp, relayState, saml.Options{}) // redirect the browser there
a, err := sp.Accept(r.PostFormValue("SAMLResponse"), pending) // at the ACS
id, from := a.Subject() // subject-id, pairwise-id, eppn...
It is the half of go-authn/bridge that faces the federation; the other half is an OpenID Connect provider. It is usable on its own by anything that wants to be a SAML SP.
Almost all of the work is refusing things, so the refusals are the package:
- ⛔ Metadata not signed by the certificate the caller pinned, past its validUntil, or with none at all.
- ⛔ A response in which neither the Response nor the assertion is signed, or signed with SHA-1.
- ⛔ More than one assertion. The profile allows several; accepting them is how a signed one and an unsigned one arrive together and the wrong one is read.
- ⛔ Anything read from the document as it arrived rather than from what the signature covered: the verified element is rebuilt from the bytes that were digested, and only it is read.
- ⛔ An assertion for another SP, another ACS, another request, an unsolicited one, an expired one, one already used, or one whose conditions it does not understand.
- ⛔ A scoped identifier -- eduPersonPrincipalName, subject-id, pairwise-id, eduPersonScopedAffiliation -- in a scope its IdP's metadata does not grant. Without this any of a federation's hundreds of IdPs could say it is alice@another-university.example.
- ⛔ AES-CBC ciphertext that no verified signature covered: it is not even decrypted, because unauthenticated CBC is a padding oracle. RSA PKCS#1 v1.5 key transport is not accepted at all.
- ⛔ An authentication context other than the one requested, when one was requested: an IdP may ignore the request, and a caller that asked for a second factor must not be told it got one.
Index ¶
Constants ¶
const ( // SubjectID is the scoped, persistent, non-targeted identifier RENATER // recommends for research and education (saml-subject-id-attr-v1.0). SubjectID = "urn:oasis:names:tc:SAML:attribute:subject-id" // PairwiseID is the same but different for every SP, which is what // privacy asks for. PairwiseID = "urn:oasis:names:tc:SAML:attribute:pairwise-id" EduPersonPrincipalName = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6" EduPersonTargetedID = "urn:oid:1.3.6.1.4.1.5923.1.1.1.10" EduPersonAffiliation = "urn:oid:1.3.6.1.4.1.5923.1.1.1.1" EduPersonScopedAffiliation = "urn:oid:1.3.6.1.4.1.5923.1.1.1.9" EduPersonEntitlement = "urn:oid:1.3.6.1.4.1.5923.1.1.1.7" EduPersonAssurance = "urn:oid:1.3.6.1.4.1.5923.1.1.1.11" EduPersonOrcid = "urn:oid:1.3.6.1.4.1.5923.1.1.1.16" Mail = "urn:oid:0.9.2342.19200300.100.1.3" DisplayName = "urn:oid:2.16.840.1.113730.3.1.241" GivenName = "urn:oid:2.5.4.42" Surname = "urn:oid:2.5.4.4" CommonName = "urn:oid:2.5.4.3" UID = "urn:oid:0.9.2342.19200300.100.1.1" SchacHomeOrganization = "urn:oid:1.3.6.1.4.1.25178.1.2.9" IsMemberOf = "urn:oid:1.3.6.1.4.1.5923.1.5.1.1" // SupannEtablissement is the French SupAnn schema's establishment code. // RENATER says not to depend on SupAnn for eduGAIN users. SupannEtablissement = "urn:oid:1.3.6.1.4.1.7135.1.2.1.14" )
Attribute names, as a federation's IdPs send them: the urn:oid form (NameFormat uri), which is what RENATER and eduGAIN use.
Variables ¶
This section is empty.
Functions ¶
func DiscoveryURL ¶
DiscoveryURL sends somebody to a discovery service to choose their IdP, per the SAML IdP Discovery Protocol (sstc-saml-idp-discovery, 2.4.1): entityID is this SP, and the service sends them back to returnURL with the chosen IdP in the "entityID" parameter.
saml.DiscoveryURL("https://discovery.renater.fr/renater", sp.EntityID, back)
RENATER's service lists every IdP of the federation and cannot be restricted; an SP that trusts fewer runs its own page instead, from Metadata.Sorted.
Types ¶
type Assertion ¶
type Assertion struct {
// IdP is who said it.
IdP *IdP
ID string
NameID NameID
AuthnInstant time.Time
// AuthnContext is the class the IdP authenticated with, e.g.
// "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
// or "https://refeds.org/profile/mfa".
AuthnContext string
SessionIndex string
SessionNotOnOrAfter time.Time
NotOnOrAfter time.Time
// Attributes by name (the urn:oid form), with scoped values the IdP was
// not allowed to assert already removed.
Attributes map[string][]string
}
An Assertion is what an IdP said about somebody, after every check.
func (*Assertion) Subject ¶
Subject is the best persistent identifier the IdP released, and which one it was -- in RENATER's order of preference: subject-id, then pairwise-id, then eduPersonPrincipalName, then the (deprecated) eduPersonTargetedID, then a persistent NameID. It is "" when the IdP released none of them, which leaves nothing that identifies the same person twice.
⛔ Mail is never an identifier: it is reassigned and, RENATER notes, can be spoofed.
Scoped identifiers compare case-insensitively (saml-subject-id-attr-v1.0 3.3.1), so they are returned lower-cased.
type Description ¶
type Description struct {
// Names and Descriptions by language: "fr" is required by RENATER, "en"
// by eduGAIN.
Names map[string]string
Descriptions map[string]string
InformationURL string
PrivacyStatement string
// Discovery is where a discovery service may send people back to with
// the IdP they chose (SAML IdP Discovery Protocol 2.4.1). A discovery
// service SHOULD refuse to return anywhere not listed here.
Discovery string
// Technical is the technical contact's e-mail address -- a person, per
// RENATER, not a list.
Technical string
// Attributes are the attributes the SP asks for. RENATER wants each one
// justified at registration; the list here is what goes in the metadata.
Attributes []string
}
Description is what a federation registry asks about an SP. RENATER's technical framework makes a French name and description, a technical contact and (for the Code of Conduct) a privacy statement obligatory.
type Federation ¶
type Federation struct {
// URL is where the metadata is: https://, or file:// for a copy kept by
// something else. Plain http is refused except on loopback, not
// because the signature would not catch a change -- it would -- but
// because somebody on the path could hold back the NEW document and
// keep serving an old one with a revoked key until it expires.
URL string
// Cert is the federation's metadata signing certificate, checked once by
// its fingerprint and kept locally.
Cert *x509.Certificate
// Client fetches it. A client with a two-minute timeout when
// nil: eduGAIN's aggregate is over 50 MB.
Client *http.Client
// MaxSize bounds a download. 256 MB by default.
MaxSize int64
// MaxValidity is the longest validUntil a document may carry, from now:
// 28 days by default (RENATER publishes about two weeks, eduGAIN five
// days); a negative value removes the bound. A document valid for years
// is one a replay keeps alive for years.
MaxValidity time.Duration
// Now is the clock. time.Now by default.
Now func() time.Time
// contains filtered or unexported fields
}
A Federation keeps a federation's metadata fresh: it fetches it, verifies it, and fetches it again before it goes stale.
f := &saml.Federation{
URL: "https://pub.federation.renater.fr/metadata/fer/idps.xml",
Cert: renaterCert,
}
if err := f.Refresh(ctx); err != nil { ... }
go f.Run(ctx, func(err error) { log.Print(err) })
When a refresh fails, the last good copy stays in use -- a federation server being briefly unreachable must not log everybody out -- but only until its own validUntil. After that there is nothing left that anybody vouches for, and every IdP disappears.
func (*Federation) IdP ¶
func (f *Federation) IdP(entityID string) (*IdP, bool)
IdP implements IdPs, from the current metadata if it is still valid.
func (*Federation) Metadata ¶
func (f *Federation) Metadata() *Metadata
Metadata is the current metadata, or nil when there is none that is still valid.
func (*Federation) Refresh ¶
func (f *Federation) Refresh(ctx context.Context) error
Refresh fetches and verifies the metadata once. A document that fails verification does not replace the one in use.
func (*Federation) Run ¶
func (f *Federation) Run(ctx context.Context, errs func(error))
Run refreshes the metadata until ctx ends: at once, then at three quarters of its cacheDuration, which is what Shibboleth does -- never more often than every five minutes nor less often than every twelve hours, and never past half of what remains of its validity. With no valid metadata it tries again within a minute, then backs off to an hour: a federation server down at start must not leave the SP without IdPs for twelve hours. errs, when not nil, is told about every failed refresh.
type IdP ¶
type IdP struct {
// EntityID is the IdP's name in the federation, e.g.
// "https://idp.univ-example.example/idp/shibboleth".
EntityID string
// SSO is the SingleSignOnService location for the HTTP-Redirect binding,
// which is where an AuthnRequest goes.
SSO string
// Keys are the certificates it signs with. A federation's metadata is
// what makes them trusted; see verify.
Keys []*x509.Certificate
// Scopes are the scopes it may assert scoped values in -- the part after
// the "@" of eduPersonPrincipalName, subject-id, pairwise-id and
// eduPersonScopedAffiliation. A value in any other scope is dropped.
Scopes []string
// Names are its display names by language ("fr", "en"), from mdui, or
// failing that from the Organization element. The discovery page shows
// them.
Names map[string]string
// Categories are its entity categories and assurance certifications,
// e.g. "http://refeds.org/category/research-and-scholarship" or
// "https://refeds.org/sirtfi".
Categories []string
// Domains are the mdui:DomainHint values, which let a discovery page
// match somebody typing their e-mail domain.
Domains []string
}
An IdP is one identity provider as its federation describes it: where to send people, which keys sign what comes back, and which scopes it may speak for.
func Chosen ¶
Chosen reads the IdP a discovery service sent back, and refuses one that is not in the federation: the parameter comes through the browser, and anybody can type an entity ID.
type IdPs ¶
IdPs is where an SP looks identity providers up: a *Metadata, or a *Federation that keeps one fresh.
type MemoryReplay ¶
type MemoryReplay struct {
// contains filtered or unexported fields
}
MemoryReplay is a Replay for one process.
func NewMemoryReplay ¶
func NewMemoryReplay() *MemoryReplay
NewMemoryReplay returns an empty MemoryReplay.
type Metadata ¶
type Metadata struct {
// IdPs by entity ID. Only IdPs that speak SAML 2.0 and publish an
// HTTP-Redirect SingleSignOnService are here; the others could not be
// sent anybody.
IdPs map[string]*IdP
// Skipped says what was left out, and why: an entity whose signing
// certificates none parse, one described twice, a certificate that does
// not. ⛔ One member's mistake must not take the federation's other
// IdPs away -- it used to: a single certificate Go refuses (a negative
// serial, since Go 1.23) failed the whole document.
Skipped []string
// ValidUntil is when the signer stops vouching for this document. After
// it, the document is refused, and a server still holding it should
// stop trusting it rather than keep going on stale keys.
ValidUntil time.Time
// CacheDuration is how long the signer suggests keeping it before
// asking again. Zero when the document does not say.
CacheDuration time.Duration
// contains filtered or unexported fields
}
Metadata is a federation's signed list of identity providers.
func ParseMetadata ¶
ParseMetadata verifies a federation's metadata against the certificate its operator publishes, and reads the identity providers out of it.
cert := ... // RENATER's metadata-signature-2026.pem, checked by fingerprint ONCE md, err := saml.ParseMetadata(body, cert, time.Now())
� The certificate is a parameter, never something fetched alongside the metadata: a key that arrives over the same channel as the document it vouches for vouches for nothing. RENATER says the same -- download it once, check its fingerprint, keep a local copy.
Refused:
- a document that is not signed, or not signed by cert, or signed with SHA-1;
- a document past its validUntil, or with none at all -- a signed document that never expires is a key compromise that never ends;
- a document whose signature covers a different element than the root.
type NameID ¶
type NameID struct {
Format, NameQualifier, SPNameQualifier, Value string
}
A NameID is the subject's name as the IdP chose to write it. In a research federation it is usually transient -- a new value every time -- and the person is identified by an attribute instead.
type Options ¶
type Options struct {
// ForceAuthn asks the IdP to authenticate the person again even if it
// has a session for them (OIDC prompt=login).
ForceAuthn bool
// IsPassive asks the IdP not to interact with the person at all (OIDC
// prompt=none). An IdP with no session answers NoPassive.
IsPassive bool
// AuthnContext lists the authentication context classes that are
// acceptable, e.g. "https://refeds.org/profile/mfa". When set, a
// response whose class is not one of them is REFUSED: an IdP may ignore
// the request, and a caller that asked for a second factor must not be
// told it got one when it did not.
AuthnContext []string
}
Options change what is asked of the IdP.
type Pending ¶
type Pending struct {
ID string
IdP string
Issued time.Time
AuthnContext []string
// ForceAuthn is whether the request asked the IdP to authenticate the
// person again rather than reuse its session: then an AuthnInstant
// from before the request is refused, not believed.
ForceAuthn bool
}
Pending is a request sent and not yet answered. The caller keeps it -- in the session, keyed by the RelayState -- and hands it back to Accept.
type Replay ¶
type Replay interface {
// Use records id until expires, and says whether it was new. It must be
// atomic: two requests with the same assertion at the same moment get
// one true and one false.
Use(id string, expires time.Time) bool
}
Replay remembers which assertions have been used.
saml-profiles 4.1.4.5: the SP "MUST ensure that bearer assertions are not replayed, by maintaining the set of used ID values for the length of time for which the assertion would be considered valid". A bearer assertion is exactly that -- whoever holds it is the person -- and a browser's history, a proxy's log or a shoulder is enough to hold one.
type SP ¶
type SP struct {
// EntityID is this SP's name in the federation, as registered.
EntityID string
// ACS is the AssertionConsumerService URL, where IdPs POST responses.
// It is compared exactly with the Destination and Recipient the IdP
// writes, so it must be spelled as it is in the SP's metadata.
ACS string
// Key decrypts assertions and Cert is its certificate, which goes in
// the SP's metadata. A Shibboleth IdP encrypts assertions by default,
// so an SP without a key cannot read most of a federation.
Key *rsa.PrivateKey
Cert *x509.Certificate
// Federation is where IdPs are looked up.
Federation IdPs
// Replay remembers the assertions already used. The default is in
// memory, which is right for one process and wrong for several behind
// a load balancer: each would accept the same assertion once.
Replay Replay
// ClockSkew is how far the IdP's clock may be from this one. Three
// minutes by default.
ClockSkew time.Duration
// Now is the clock. time.Now by default.
Now func() time.Time
// contains filtered or unexported fields
}
An SP is a SAML 2.0 service provider: it sends people to their identity provider and reads what comes back.
func (*SP) Accept ¶
Accept reads the SAMLResponse form field an IdP POSTed to the ACS, as the answer to p, and returns the assertion in it.
Everything that is refused is one error to the caller; the error text is for the server's log, not for the browser.
func (*SP) Metadata ¶
func (sp *SP) Metadata(d Description) ([]byte, error)
Metadata is the SP's own metadata, to register with the federation.
The key is published without a "use", so it serves for both signing and encryption, and with the encryption methods it prefers: GCM first, so that an IdP that honours the list does not fall back to CBC.
func (*SP) Request ¶
Request builds the URL that sends somebody to idp with an AuthnRequest, over the HTTP-Redirect binding.
relayState comes back unchanged with the response; SAML bindings (3.4.3) limit it to 80 bytes, and it is refused beyond that rather than silently truncated by an IdP. Nothing secret belongs in it: it travels through the browser in the clear.
type StatusError ¶
type StatusError struct {
Code, Sub, Message string
}
A StatusError is an IdP saying no: the person could not be authenticated, or would have had to interact when IsPassive forbade it. It is not an attack and is worth telling apart from one -- NoPassive is how OIDC's prompt=none learns to answer login_required.
func (*StatusError) Error ¶
func (e *StatusError) Error() string
func (*StatusError) NoPassive ¶
func (e *StatusError) NoPassive() bool
NoPassive says whether the IdP refused because IsPassive was set and it would have had to ask the person something.