Documentation
¶
Overview ¶
Package sso provides optional organization-bound OpenID Connect and SAML 2.0 single sign-on for PostgreSQL and SQLite. Each organization's owner configures its own identity-provider connections and verifies its email domains; a connection signs in only accounts linked through that exact connection, and only while they are members of its organization. It depends on the organization plugin. See docs/sso.md.
Index ¶
- Constants
- func ConnectionOrganization(tx storage.Tx, connectionID string) (string, error)
- func Contract() plugin.Definition
- func DomainVerified(tx storage.Tx, organizationID, domain string) (bool, error)
- func Identity(connectionID, subject string) storage.ExternalIdentity
- func Method(connectionID string) sigil.AuditMethod
- func PruneExpired(ctx context.Context, store storage.Store, before time.Time, limit int) (int64, error)
- type AttributeRequirement
- type Callback
- type ClaimRequirement
- type Completed
- type Config
- type Connection
- type ConnectionInput
- type Domain
- type IdPMetadata
- type Linking
- type Membership
- type Plugin
- func (p *Plugin) Bind(a *sigil.Auth) error
- func (p *Plugin) ClaimDomain(ctx context.Context, organizationID, domain string) (Domain, error)
- func (p *Plugin) Complete(ctx context.Context, ip netip.Addr, c Callback) (Completed, error)
- func (p *Plugin) CompleteLink(ctx context.Context, ip netip.Addr, c Callback) (string, error)
- func (p *Plugin) CompleteSAML(ctx context.Context, ip netip.Addr, c SAMLCallback) (Completed, error)
- func (p *Plugin) CompleteSAMLLink(ctx context.Context, ip netip.Addr, c SAMLCallback) (string, error)
- func (p *Plugin) Connections(ctx context.Context, organizationID string) ([]Connection, error)
- func (p *Plugin) CreateConnection(ctx context.Context, organizationID string, in ConnectionInput) (Connection, error)
- func (p *Plugin) CreateSAMLConnection(ctx context.Context, organizationID string, in SAMLConnectionInput) (SAMLConnection, error)
- func (p *Plugin) Definition() plugin.Definition
- func (p *Plugin) DeleteConnection(ctx context.Context, organizationID, id string) error
- func (p *Plugin) Domains(ctx context.Context, organizationID string) ([]Domain, error)
- func (p *Plugin) RemoveDomain(ctx context.Context, organizationID, domain string) error
- func (p *Plugin) Rotate(ctx context.Context, cursor string, limit int) (sigil.Rotation, error)
- func (p *Plugin) SAMLConnections(ctx context.Context, organizationID string) ([]SAMLConnection, error)
- func (p *Plugin) SAMLRelay(api string) (http.Handler, error)
- func (p *Plugin) Start(ctx context.Context, ip netip.Addr, connectionID, returnURL string) (Started, error)
- func (p *Plugin) StartForEmail(ctx context.Context, ip netip.Addr, email, returnURL string) (Started, error)
- func (p *Plugin) StartLink(ctx context.Context, ip netip.Addr, connectionID, returnURL string) (Started, error)
- func (p *Plugin) UpdateConnection(ctx context.Context, organizationID, id string, revision int, ...) (Connection, error)
- func (p *Plugin) UpdateSAMLConnection(ctx context.Context, organizationID, id string, revision int, ...) (SAMLConnection, error)
- func (p *Plugin) VerifyDomain(ctx context.Context, organizationID, domain string) (Domain, error)
- type SAMLCallback
- type SAMLCertificate
- type SAMLConnection
- type SAMLConnectionInput
- type Started
Constants ¶
const ( MaxCertificates = 3 // per connection, to overlap an IdP's signing-key rollover MaxRequiredAttrs = 8 )
Limits on SAML configuration.
const ( CreateOperation plugin.Operation = "sso.connection.create" UpdateOperation plugin.Operation = "sso.connection.update" DeleteOperation plugin.Operation = "sso.connection.delete" DomainClaimOperation plugin.Operation = "sso.domain.claim" DomainVerifyOperation plugin.Operation = "sso.domain.verify" DomainRemoveOperation plugin.Operation = "sso.domain.remove" // ProvisionOperation records just-in-time membership on first sign-in. ProvisionOperation plugin.Operation = "sso.membership.provision" // CompleteOperation records a rejected callback or SAML response. CompleteOperation plugin.Operation = "sso.complete" )
Operations recorded in the audit trail. Successful sign-in and linking are recorded by the core as plugin.SessionIssue and plugin.AccountLink with the connection's method.
const ( ReasonClaims sigil.AuditReason = "claims" // a required claim did not match ReasonMembership sigil.AuditReason = "membership" // not a member, and membership is not provisioned ReasonReplay sigil.AuditReason = "replay" // a SAML assertion already accepted once )
Rejection reasons on sso.complete failures, beside sigil.ReasonInvalidProof (transaction, provider response or token) and sigil.ReasonUnknownIdentity (an identity that is neither linked nor linkable under the policy).
const ( MaxConnections = 10 // per organization MaxDomains = 20 // per organization and per connection MaxRequiredClaims = 8 MaxClaimValues = 16 )
Limits on configuration.
const ClockSkew = 3 * time.Minute
ClockSkew is the tolerance applied to every SAML time bound.
const DefaultDomainVerificationPrefix = "sigil"
DefaultDomainVerificationPrefix is the verification record prefix when Config.DomainVerificationPrefix is empty: "_sigil-verification.<domain>".
const ID = "sso"
const Lifetime = 5 * time.Minute
Lifetime bounds a sign-in or link transaction.
Variables ¶
This section is empty.
Functions ¶
func ConnectionOrganization ¶
ConnectionOrganization returns the organization that owns an OIDC or SAML connection, inside a trusted transaction, holding the connection against deletion through commit. An unknown connection is storage.ErrNotFound. It is for plugins that bind their own records to a connection, such as a SCIM directory that links the accounts it provisions.
func Contract ¶
func Contract() plugin.Definition
Contract is the offline declaration for tooling: routes, schema names and migrations are independent of configuration. Its handlers fail closed.
func DomainVerified ¶
DomainVerified reports, inside a trusted transaction, whether organizationID verified domain through DNS, holding the verification through commit so a concurrent RemoveDomain waits. A domain is verified by at most one organization.
func Identity ¶
func Identity(connectionID, subject string) storage.ExternalIdentity
Identity is the external identity a connection signs in for subject: the connection's own namespace, so it can never sign in through another.
func Method ¶
func Method(connectionID string) sigil.AuditMethod
Method names a connection on audit events and sessions: "sso.<id>".
func PruneExpired ¶
func PruneExpired(ctx context.Context, store storage.Store, before time.Time, limit int) (int64, error)
PruneExpired deletes at most limit (1..1000) sign-in transactions, then SAML assertion replay records, whose expiry, clamped to database time, is at or before cutoff. Expired transactions are never redeemable, and an expired assertion is refused on its own time bounds. It needs only the store, so the table stays bounded while the plugin is disabled.
Types ¶
type AttributeRequirement ¶
AttributeRequirement requires a SAML attribute of the verified assertion to carry one of Values. Use it to bind a connection to one directory tenant (Entra ID's tenantid claim) or group.
type Callback ¶
type Callback struct {
Connection, RedirectURL, Issuer string
State, BrowserBinding, Code sigil.Secret
Error string
}
Callback contains the provider response as delivered to the fixed RedirectURL. Error denotes denial. Issuer is the RFC 9207 iss parameter.
type ClaimRequirement ¶
ClaimRequirement requires an ID-token claim to be a string equal to one of Values, or an array of strings containing one of them. Use it to bind a connection to one directory tenant (Entra ID "tid", Google Workspace "hd") or group.
type Completed ¶
type Completed struct {
Session sigil.IssuedSession
ReturnURL string
OrganizationID string
}
Completed carries the issued session and the organization whose connection authenticated it; the host may select that organization as active.
type Config ¶
type Config struct {
Store storage.Store
Protector sigil.Protector
// RedirectURL is the one fixed HTTPS callback registered with every
// provider; the host page there posts the callback to Complete.
RedirectURL string
// ReturnURLs are exact HTTPS destinations, without query or fragment.
ReturnURLs []string
// HTTPClient, when set, carries discovery, token and key requests and the
// host owns its network policy. Otherwise requests dial only public
// unicast addresses and use no proxy, because issuers are tenant input.
// Either way the plugin imposes a timeout, refuses redirects and bounds
// response bodies.
HTTPClient *http.Client
// ManageAssurance raises the assurance connection and domain management
// need above sigil.Config.SensitiveAssurance; it never lowers it.
ManageAssurance sigil.AssuranceLevel
// LookupTXT resolves domain verification records. Nil uses the system
// resolver.
LookupTXT func(ctx context.Context, name string) ([]string, error)
// DomainVerificationPrefix brands the TXT record that proves a domain:
// "_<prefix>-verification.<domain>" carrying
// "<prefix>-verification=<token>". Hosts set it to their product's name,
// for example "brewgenix"; the leading underscore and "-verification" are
// fixed. Lowercase letters, digits and inner hyphens, at most 49
// characters; empty means DefaultDomainVerificationPrefix. Changing it
// changes the record pending claims must publish; verified domains stay
// verified.
DomainVerificationPrefix string
// SAMLACSURL is the one fixed HTTPS assertion consumer service URL
// registered with every SAML identity provider. The IdP posts its response
// there cross-site; the host serves SAMLRelay at it, which hands the
// response to /sso/saml/complete from a same-site page. Empty disables SAML.
SAMLACSURL string
// SAMLEntityID prefixes each SAML connection's service-provider entity ID,
// "<SAMLEntityID>/<connection id>", which is also the audience its IdP must
// restrict assertions to. An HTTPS URL without query, fragment or trailing
// slash; required with SAMLACSURL.
SAMLEntityID string
}
Config resources belong to the Auth passed to Bind.
type Connection ¶
type Connection struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
AuthorizationEndpoint string `json:"authorizationEndpoint"`
TokenEndpoint string `json:"tokenEndpoint"`
JWKSURI string `json:"jwksUri"`
Domains []string `json:"domains"`
Linking Linking `json:"linking"`
Membership Membership `json:"membership"`
RequiredClaims []ClaimRequirement `json:"requiredClaims"`
Enabled bool `json:"enabled"`
Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
Connection is a credential-free view of an organization's provider. The endpoints come from discovery at configuration time and are never taken from a callback.
type ConnectionInput ¶
type ConnectionInput struct {
Issuer, ClientID string
ClientSecret sigil.Secret
Domains []string
Linking Linking
Membership Membership
RequiredClaims []ClaimRequirement
Enabled bool
}
ConnectionInput is the configuration an owner submits. Issuer is fixed at creation; on update it must be empty or unchanged. ClientSecret is required at creation; an empty secret on update keeps the current one.
type Domain ¶
type Domain struct {
Domain string `json:"domain"`
OrganizationID string `json:"organizationId"`
RecordName string `json:"recordName"`
RecordValue string `json:"recordValue"`
CreatedAt time.Time `json:"createdAt"`
VerifiedAt *time.Time `json:"verifiedAt"`
}
Domain is an organization's domain claim. Publish RecordValue as a TXT record at RecordName, then call VerifyDomain.
type IdPMetadata ¶
IdPMetadata is what ParseIdPMetadata reads from an IdP's metadata.
func ParseIdPMetadata ¶
func ParseIdPMetadata(raw string) (IdPMetadata, error)
ParseIdPMetadata reads an IdP's SAML 2.0 metadata: its entity ID, its HTTP-Redirect single sign-on endpoint and its signing certificates. The document is the owner's input, like the individual fields; a metadata signature is neither required nor checked, and nothing is fetched.
type Linking ¶
type Linking string
Linking decides how an identity that is not yet linked may be linked.
const ( // LinkExplicit links only through StartLink from a recently authenticated // session of the account, as the OAuth plugin does. It is the default. LinkExplicit Linking = "explicit" // LinkVerifiedDomain also links on sign-in when the ID token carries // email_verified true for an address in one of the connection's domains // that this organization verified through DNS, and exactly one account has // that address, verified. No other email is trusted. LinkVerifiedDomain Linking = "verified_domain" )
type Membership ¶
type Membership string
Membership decides whether a signed-in account must already be a member.
const ( // MembershipExisting refuses accounts that are not members. The default. MembershipExisting Membership = "existing" // MembershipProvision adds a missing membership with the member role. // It never changes an existing role. MembershipProvision Membership = "provision" )
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin binds to exactly one Auth. Methods are safe for concurrent use.
func (*Plugin) ClaimDomain ¶
ClaimDomain starts proving the organization controls domain and returns the TXT record to publish. Claiming again returns the same record. A claim grants nothing until VerifyDomain succeeds.
func (*Plugin) Complete ¶
Complete consumes the transaction, exchanges the code and signs in the account linked to the identity through this connection, applying the connection's claim, linking and membership policy. Every rejection is unauthenticated and recorded as sso.complete.
func (*Plugin) CompleteLink ¶
CompleteLink consumes the transaction and links the identity to the initiating session's account. It never signs in, switches users, merges accounts or refreshes recency, and ignores the email policy.
func (*Plugin) CompleteSAML ¶
func (p *Plugin) CompleteSAML(ctx context.Context, ip netip.Addr, c SAMLCallback) (Completed, error)
CompleteSAML consumes the transaction the RelayState names, verifies the IdP's response for exactly that AuthnRequest, and signs in the account linked to the asserted subject through this connection, under the same linking and membership policy as OIDC. Every rejection is unauthenticated and recorded as sso.complete.
func (*Plugin) CompleteSAMLLink ¶
func (p *Plugin) CompleteSAMLLink(ctx context.Context, ip netip.Addr, c SAMLCallback) (string, error)
CompleteSAMLLink consumes the transaction and links the asserted subject to the initiating session's account, as CompleteLink does for OIDC.
func (*Plugin) Connections ¶
Connections lists the organization's connections, oldest first, for its live owner. Secrets are never included.
func (*Plugin) CreateConnection ¶
func (p *Plugin) CreateConnection(ctx context.Context, organizationID string, in ConnectionInput) (Connection, error)
CreateConnection adds an enabled or disabled OIDC connection for the organization. The caller must be its live owner, recently authenticated at Config.ManageAssurance. Ownership is checked before the issuer's discovery document is fetched, and again with the write; the client secret is sealed under the protector bound to the new connection and never returned.
func (*Plugin) CreateSAMLConnection ¶
func (p *Plugin) CreateSAMLConnection(ctx context.Context, organizationID string, in SAMLConnectionInput) (SAMLConnection, error)
CreateSAMLConnection adds an enabled or disabled SAML 2.0 connection for the organization, under the same owner-only, recently authenticated authorization as CreateConnection. Nothing is fetched: the IdP's entity ID, HTTP-Redirect single sign-on URL and signing certificates are given, for example from ParseIdPMetadata. It needs Config.SAMLACSURL.
func (*Plugin) Definition ¶
func (p *Plugin) Definition() plugin.Definition
func (*Plugin) DeleteConnection ¶
DeleteConnection removes an OIDC or SAML connection with its pending transactions and SAML replay records. Its linked identities can never be used again, because connection IDs are not reused; they remain listed on each account until unlinked or deleted. Sessions it already issued are ordinary account sessions and are not revoked: remove the membership, or revoke the account's sessions.
func (*Plugin) RemoveDomain ¶
RemoveDomain withdraws a claim or verification. Verified-domain linking and email routing stop at once; identities already linked stay linked.
func (*Plugin) Rotate ¶
Rotate re-encrypts up to limit connection client secrets sealed under a key other than the protector's active key, scanning connection IDs after cursor. Repeat from "" until a pass reports nothing rewrapped and nothing unreadable, then wait out Lifetime for sign-in transactions before retiring the old key. Replacement is compare-and-swap on the stored envelope, so a concurrent reconfiguration is never overwritten; revisions do not change.
func (*Plugin) SAMLConnections ¶
func (p *Plugin) SAMLConnections(ctx context.Context, organizationID string) ([]SAMLConnection, error)
SAMLConnections lists the organization's SAML connections, oldest first, for its live owner.
func (*Plugin) SAMLRelay ¶
SAMLRelay returns the handler to serve at Config.SAMLACSURL. The IdP's HTTP-POST binding submits there cross-site, which the auth API refuses by design (Origin, Sec-Fetch-Site and CSRF checks) and which carries no SameSite=Lax cookie. The handler therefore only relays: it answers the form POST with a no-store page whose script posts the two values, from this same-site page with a CSRF proof and the browser-binding cookie, to api+"/sso/saml/complete" or api+"/sso/saml/link/complete". It verifies nothing itself and never sets a cookie. api is where Auth.Handler is mounted: an absolute HTTPS URL, or a root-relative path on the ACS origin.
func (*Plugin) Start ¶
func (p *Plugin) Start(ctx context.Context, ip netip.Addr, connectionID, returnURL string) (Started, error)
Start begins sign-in through an enabled connection.
func (*Plugin) StartForEmail ¶
func (p *Plugin) StartForEmail(ctx context.Context, ip netip.Addr, email, returnURL string) (Started, error)
StartForEmail routes sign-in by the address's domain: to the oldest enabled connection, OIDC or SAML, that lists it, of the one organization that verified it. An address with no such connection is a bad request. The address becomes an OIDC provider's login_hint; it proves nothing.
func (*Plugin) StartLink ¶
func (p *Plugin) StartLink(ctx context.Context, ip netip.Addr, connectionID, returnURL string) (Started, error)
StartLink binds explicit linking to the current recently authenticated session. No email claim authorizes it. It never signs in.
func (*Plugin) UpdateConnection ¶
func (p *Plugin) UpdateConnection(ctx context.Context, organizationID, id string, revision int, in ConnectionInput) (Connection, error)
UpdateConnection replaces a connection's mutable configuration at the expected revision, under the same authorization as CreateConnection. The issuer cannot change: create a new connection instead, since the issuer scopes every linked subject. An enabled result refreshes the endpoints from discovery first; disabling never contacts the provider, so an owner can always switch off a failing or compromised provider. Every update advances the revision, which invalidates sign-ins and links already in flight.
func (*Plugin) UpdateSAMLConnection ¶
func (p *Plugin) UpdateSAMLConnection(ctx context.Context, organizationID, id string, revision int, in SAMLConnectionInput) (SAMLConnection, error)
UpdateSAMLConnection replaces a SAML connection's mutable configuration at the expected revision. The IdP entity ID cannot change. Rotate the IdP's signing key by adding its new certificate (up to MaxCertificates), then removing the old one once the IdP has switched. Every update advances the revision, which fails attempts in flight.
func (*Plugin) VerifyDomain ¶
VerifyDomain resolves the claim's TXT record and, when it carries the exact value, marks the domain verified for this organization. A domain is verified by at most one organization; a missing record, or a domain another organization verified, is forbidden. Verification lasts until RemoveDomain.
type SAMLCallback ¶
type SAMLCallback struct {
// Response is the SAMLResponse form value, base64 encoded.
Response, RelayState string
BrowserBinding sigil.Secret
}
SAMLCallback is an IdP's HTTP-POST binding response, relayed from the ACS by the same browser that started the attempt.
type SAMLCertificate ¶
type SAMLCertificate struct {
// Certificate is the base64 DER encoding.
Certificate string `json:"certificate"`
SHA256 string `json:"sha256"`
NotAfter time.Time `json:"notAfter"`
}
SAMLCertificate is one IdP signing certificate a connection trusts.
type SAMLConnection ¶
type SAMLConnection struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
IdPEntityID string `json:"idpEntityId"`
SSOURL string `json:"ssoUrl"`
Certificates []SAMLCertificate `json:"certificates"`
SubjectAttribute string `json:"subjectAttribute"`
EmailAttribute string `json:"emailAttribute"`
// EmailVerifiedAttribute, when set, names an attribute whose single value
// must be "true" before the email is trusted for verified-domain linking.
EmailVerifiedAttribute string `json:"emailVerifiedAttribute"`
Domains []string `json:"domains"`
Linking Linking `json:"linking"`
Membership Membership `json:"membership"`
RequiredAttributes []AttributeRequirement `json:"requiredAttributes"`
Enabled bool `json:"enabled"`
Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
EntityID string `json:"entityId"`
ACSURL string `json:"acsUrl"`
Metadata string `json:"metadata"`
}
SAMLConnection is an organization's SAML 2.0 identity provider, and the service-provider values to register there: EntityID, ACSURL or Metadata.
type SAMLConnectionInput ¶
type SAMLConnectionInput struct {
IdPEntityID, SSOURL string
Certificates []string
SubjectAttribute, EmailAttribute string
EmailVerifiedAttribute string
Domains []string
Linking Linking
Membership Membership
RequiredAttributes []AttributeRequirement
Enabled bool
}
SAMLConnectionInput is the configuration an owner submits. IdPEntityID is fixed at creation; on update it must be empty or unchanged. Certificates are PEM or base64 DER. SubjectAttribute, when set, names the attribute whose single value identifies the user instead of the NameID; EmailAttribute, when set, names the attribute holding the email address instead of an emailAddress-format NameID. EmailVerifiedAttribute, when set, names an attribute that must be "true" for the email to be trusted; set it for an IdP that lets users change their own address.