sso

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 40 Imported by: 0

README

github.com/ubgo/auth/plugin/sso

Enterprise single sign-on: OpenID Connect and SAML 2.0 identity providers registered at run time, per organization or email domain, the shape of better-auth's sso plugin. A customer's admin connects their Okta, Entra ID, Google Workspace, ADFS or Keycloak without a deploy; a person signs in by typing their work email, and the plugin routes them to their company's IdP.

Install

go get github.com/ubgo/auth/plugin/sso

This is its own module (Go 1.25) because SAML needs an XML-signature library (github.com/russellhaering/gosaml2 and goxmldsig, with beevik/etree) that no app without SAML should inherit. OpenID Connect runs on plugin/oauth from the core module.

Wire it

auth.New(
	auth.WithDB(db, sqlb.PostgresDialect{}),
	auth.WithBaseURL("https://app.example.com"),
	auth.WithSecret(secret), // required: provider secrets are sealed at rest
	auth.WithPlugins(
		email.New(email.Login()),
		sso.New(
			sso.WithDomainVerification(sso.DomainVerification{}),
			sso.WithOrganizations(orgAdapter{org}, sso.OrganizationProvisioning{}),
		),
	),
)

auth.New fails with ErrKeyDerivation without auth.WithSecret, and with ErrNoCallbackURL without auth.WithBaseURL (or WithCallbackURL and WithACSURL).

Routes

Under the plugin mount (/auth/sso with authhttp.Prefix("/auth")):

Route Who What
POST /register signed in Register an OIDC and/or SAML provider for yourself, or for an organization you own or administer
GET /providers signed in The providers you may manage, sanitized
GET /get-provider?providerId= signed in One of them
POST /update-provider signed in Change it; a domain change resets verification; an identity-boundary change is refused while accounts are linked
POST /delete-provider signed in Delete it and its linked accounts
POST /sign-in public Start SSO by providerId, organizationSlug, domain or email; answers {url, redirect}
GET /callback public The shared OIDC redirect URI (?providerId= for an IdP-initiated sign-in)
GET /saml2/sp/metadata?providerId= public SP metadata, as {"metadata": "<xml>"}
POST /saml2/sp/acs public, cross-site form The SAML assertion consumer service
POST /request-domain-verification signed in The DNS TXT token (with WithDomainVerification)
POST /verify-domain signed in Check the TXT record (with WithDomainVerification)
POST /saml2/logout signed in SP-initiated SAML single logout (with SAMLOptions.EnableSingleLogout)
GET | POST /saml2/sp/slo public (POST: cross-site form) The SP's SLO endpoint: the IdP's LogoutRequest or LogoutResponse, redirect or POST binding

By default the OIDC callback, the ACS and the SLO endpoint are shared by every provider: the provider travels in the OIDC state or the SAML RelayState, and an IdP-initiated SAML sign-in names it as its RelayState (Okta's "Default RelayState"). With sso.PerProviderPaths() every provider gets its own URLs (/callback/<id>, /saml2/sp/acs/<id>, /saml2/sp/slo/<id>, better-auth's shape); the core routes exact paths, so mount p.ProviderPaths("/auth/sso", authhttp.Handler(a, authhttp.Prefix("/auth"))), which rewrites those URLs onto the shared routes with the id as a second check, and also serves GET /saml2/sp/metadata?providerId= as raw application/xml.

What it checks

OpenID Connect: authorization code with PKCE (S256), state bound to the browser by the flow cookie, the ID token's signature against the IdP's JWKS, its iss, aud (the client id), exp, nonce (the PKCE challenge) and azp, userinfo's sub equal to the token's, RFC 9207 iss. Discovery requires issuer, authorization_endpoint, token_endpoint and jwks_uri, refuses an issuer mismatch, and follows no redirect. A public client (tokenEndpointAuthentication: "none") must keep PKCE on: registration refuses one without it, since no sign-in could complete.

SAML (CheckAssertion, InspectResponse): exactly one assertion anywhere in the document (the defence in depth against signature wrapping), a signature by one of the IdP's certificates over the Response or the Assertion (the Assertion's own with WantAssertionsSigned), issuer, destination, audience, bearer recipient, InResponseTo equal to the request this browser started (consumed once), NotBefore/NotOnOrAfter with ClockSkew, assertion-ID replay protection until expiry, no DTD, size limits, and the SHA-1 algorithm policy.

Encrypted assertions (samlConfig.decryptionKey + decryptionCertificate, or privateKey + spCertificate) are decrypted (RSA-OAEP or RSA-1.5 key transport, AES-GCM or AES-CBC data) and then pass every check above; the content must still be signed, an assertion encrypted to another key is refused, and the encryption algorithms follow the same allow-lists and SHA-1/RSA-1.5/3DES policy.

Single logout (SAMLOptions.EnableSingleLogout): an SP-initiated LogoutRequest is signed with the SP key when one is set; the IdP's LogoutRequest and LogoutResponse must be signed (AllowUnsignedLogoutRequests/AllowUnsignedLogoutResponses relax this; better-auth accepts unsigned by default), by the provider's certificate, with its issuer and, when present, this SP's SLO URL as Destination. A LogoutResponse must answer a pending request, once.

Network: every IdP call goes through a client that refuses redirects and refuses loopback, private, link-local (cloud metadata) and carrier-grade-NAT addresses at connect time, after DNS. WithTrustedOrigins lets an on-premises IdP through.

Accounts and linking

auth_sso_account keys an IdP account on (provider_id, subject): the OIDC sub or the SAML NameID. A first sign-in links to an existing user with the same email only when the provider is trusted (a default provider, a domain-verified one, or TrustProviders), the address is in one of the provider's domains, and the local identity is verified; otherwise it is refused with sso.account_exists. WithResolveUser decides first when the app knows better.

Options

Option Effect
WithDefaultProviders(...) Providers in code (better-auth defaultSSO), treated as verified, never editable
ProvidersLimit(n) / WithProvidersLimit(fn) Providers per user (default 10; 0 turns registration off)
WithDomainVerification(DomainVerification{TokenPrefix, Resolver}) DNS proof of each domain before sign-in
WithOrganizations(o, OrganizationProvisioning{...}) Org owner/admin checks, organizationSlug routing, joining on sign-in
TrustEmailVerified() Take the IdP's email_verified
TrustProviders(ids...) Link by verified email without domain verification
DisableImplicitSignUp() Create users only when the sign-in sent requestSignUp
DefaultOverrideUserInfo() Copy name and image at every sign-in
WithProvisionUser(fn), ProvisionUserOnEveryLogin() A hook inside the sign-in transaction
WithResolveUser(fn) Link a first sign-in to a user the app names, or refuse it
WithMutationGuard(fn) Refuse an update or delete
WithPrivateKeyResolver(fn) private_key_jwt keys from an HSM or KMS
WithSAMLOptions(SAMLOptions{...}) IdP-initiated, request TTL, clock skew, required timestamps, algorithm policy, size limits
WithSAMLEngine(e) Replace the XML-signature engine
WithHTTPClient(hc), WithTrustedOrigins(...) The IdP client and its SSRF escape hatch
WithCallbackURL, WithACSURL, WithErrorURL, WithStateTTL, ReserveProviderIDs URLs and names
PerProviderPaths() + p.ProviderPaths(mount, handler) One callback, ACS and SLO URL per provider
samlConfig.spMetadata.binding: "post" AuthnRequest by HTTP-POST (sign-in answers a form); digestAlgorithm applies only to its signature and must match the signature hash
RequireTrustedOrigins() Only IdP origins WithTrustedOrigins lists (sso.discovery_untrusted_origin)
WithProviderFields(...), p.SetProviderFields App-defined provider fields (NoInput ones settable from Go only)
p.AssignByVerifiedDomain, p.OAuthAssignment() Join a verified domain's organization on a social sign-in (oauth.OnResolved)

Organizations

The plugin names no organization module. An adapter over plugin/organization (or any other) implements sso.Organizations: Role(ctx, orgID, userID), BySlug(ctx, slug) and AddMember(ctx, orgID, userID, role).

Tables

auth_sso_provider (one row per provider; the OIDC client secret and SAML keys sealed with a key derived from auth.WithSecret; the pending domain token and its expiry; the provider fields as JSON) and auth_sso_account. auth.RenameTable(sso.TableProvider, name) renames a table. No foreign keys; DeleteUser removes a user's sso accounts, and a provider outlives the admin who registered it.

Testing

plugin/sso/ssotest runs a fake OIDC provider (discovery, JWKS, a token endpoint that checks PKCE, userinfo) and a fake SAML IdP that signs responses, for an app's own tests.

Go API

Every route is also a typed method that runs through the same pipeline: RegisterProvider, SignIn, Callback, ACS, ListProviders, GetProvider, UpdateProvider, DeleteProvider, SPMetadata, RequestDomainVerification, VerifyDomain, InitiateSLO, SLO. Helpers: ProviderByID, ProviderForDomain, Accounts, Discover, DomainToken, DeriveIdPEntityID, DeriveSPPolicy, CheckAssertion, InspectResponse, DecodeLogout.

Not built

Passphrase-protected PEM keys (sealed at rest instead), a relay-state cookie (a cross-site form post never carries one), column renames, and the hosted onboarding dashboard. See docs/plugins/sso.md.

Documentation

Overview

Package sso is enterprise single sign-on: OpenID Connect and SAML 2.0 identity providers that an app registers at run time, one per customer organization or email domain, the shape of better-auth's sso plugin.

plugin/oauth signs people in with providers fixed at build time (Google, GitHub, one Okta). This plugin keeps providers as rows in auth_sso_provider, so a customer's admin can connect their own Okta, Entra ID or ADFS without a deploy, and routes a sign-in to the right one by provider id, email domain, domain or organization. OpenID Connect runs on plugin/oauth's OAuth2 type (PKCE, ID-token signature, issuer and nonce checks); SAML runs on the SAMLEngine interface, gosaml2 by default, with every assertion rule that decides who signs in (audience, recipient, time window, InResponseTo, replay) checked here, in code this package's tests break one by one.

A provider account is linked to a user in auth_sso_account, keyed by the provider and the IdP's subject (the OIDC sub or the SAML NameID). An existing user with the same email is linked automatically only when the provider's domain is verified and the address is in it; otherwise the sign-in is refused, because linking would hand the account to whoever runs that IdP.

Index

Constants

View Source
const (
	RoleOwner  = "owner"
	RoleAdmin  = "admin"
	RoleMember = "member"
)

Organization roles the plugin names. Only an owner or admin may register or manage an organization's provider.

View Source
const (
	KindLogoutRequest  = "LogoutRequest"
	KindLogoutResponse = "LogoutResponse"

	// StatusSuccess is the SAML success status.
	StatusSuccess = "urn:oasis:names:tc:SAML:2.0:status:Success"
)

Logout message kinds.

View Source
const (
	BindingRedirect   = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
	BindingPOST       = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
	NameIDEmail       = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
	NameIDUnspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
	NameIDPersistent  = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
)

SAML bindings and formats.

View Source
const (
	AlgRSASHA1     = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
	AlgRSASHA256   = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
	AlgRSASHA384   = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384"
	AlgRSASHA512   = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
	AlgECDSASHA256 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"
	AlgECDSASHA384 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384"
	AlgECDSASHA512 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
	DigestSHA1     = "http://www.w3.org/2000/09/xmldsig#sha1"
	DigestSHA256   = "http://www.w3.org/2001/04/xmlenc#sha256"
	DigestSHA384   = "http://www.w3.org/2001/04/xmldsig-more#sha384"
	DigestSHA512   = "http://www.w3.org/2001/04/xmlenc#sha512"
)

Signature and digest algorithm URIs.

View Source
const (
	KeyEncRSA15      = "http://www.w3.org/2001/04/xmlenc#rsa-1_5"
	KeyEncRSAOAEP    = "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"
	KeyEncRSAOAEP11  = "http://www.w3.org/2009/xmlenc11#rsa-oaep"
	DataEnc3DESCBC   = "http://www.w3.org/2001/04/xmlenc#tripledes-cbc"
	DataEncAES128CBC = "http://www.w3.org/2001/04/xmlenc#aes128-cbc"
	DataEncAES192CBC = "http://www.w3.org/2001/04/xmlenc#aes192-cbc"
	DataEncAES256CBC = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
	DataEncAES128GCM = "http://www.w3.org/2009/xmlenc11#aes128-gcm"
	DataEncAES192GCM = "http://www.w3.org/2009/xmlenc11#aes192-gcm"
	DataEncAES256GCM = "http://www.w3.org/2009/xmlenc11#aes256-gcm"
)

Key-transport and data-encryption algorithms of an EncryptedAssertion.

View Source
const (
	AuthnBindingRedirect = "redirect"
	AuthnBindingPOST     = "post"
)

Bindings an AuthnRequest may use (SPMetadata.Binding).

View Source
const (
	// PurposeSAMLSession finds an app session from (provider, NameID,
	// SessionIndex); Meta is the JSON samlSession.
	PurposeSAMLSession auth.Purpose = "sso.saml_session"
	// PurposeSAMLSessionByID finds the SAML session of an app session id.
	PurposeSAMLSessionByID auth.Purpose = "sso.saml_session_id"
	// PurposeSAMLLogout is an SP-initiated LogoutRequest's ID, consumed by
	// the IdP's LogoutResponse.
	PurposeSAMLLogout auth.Purpose = "sso.saml_logout"
)

Verification purposes of single logout.

View Source
const (
	EndpointLogout  auth.EndpointID = "sso.saml-logout"
	EndpointSLO     auth.EndpointID = "sso.saml-slo"
	EndpointSLOPost auth.EndpointID = "sso.saml-slo-post"
	PathLogout                      = "/saml2/logout"
	PathSLO                         = "/saml2/sp/slo"
)

Operation ids and routes of single logout.

View Source
const (
	// PurposeState is an OIDC flow's state; Identifier is the provider id,
	// Meta the JSON flowMeta.
	PurposeState auth.Purpose = "sso.state"
	// PurposeSAMLRequest is an SP-initiated AuthnRequest; the token is the
	// request ID, so the response's InResponseTo consumes it exactly once.
	PurposeSAMLRequest auth.Purpose = "sso.saml_request"
	// PurposeSAMLRelay is the RelayState of an SP-initiated SAML flow; Meta
	// is the JSON flowMeta.
	PurposeSAMLRelay auth.Purpose = "sso.saml_relay"
	// PurposeSAMLAssertion reserves an accepted assertion's ID until it
	// expires: a second response carrying the same ID is a replay.
	PurposeSAMLAssertion auth.Purpose = "sso.saml_assertion"
)

Verification purposes. Each is a single-use core verification row.

View Source
const (
	EndpointRegister      auth.EndpointID = "sso.register"
	EndpointSignIn        auth.EndpointID = "sso.sign-in"
	EndpointCallback      auth.EndpointID = "sso.callback"
	EndpointProviders     auth.EndpointID = "sso.providers"
	EndpointGetProvider   auth.EndpointID = "sso.get-provider"
	EndpointUpdate        auth.EndpointID = "sso.update-provider"
	EndpointDelete        auth.EndpointID = "sso.delete-provider"
	EndpointSPMetadata    auth.EndpointID = "sso.saml-metadata"
	EndpointACS           auth.EndpointID = "sso.saml-acs"
	EndpointRequestDomain auth.EndpointID = "sso.request-domain-verification"
	EndpointVerifyDomain  auth.EndpointID = "sso.verify-domain"
)

Operation ids.

View Source
const (
	PathRegister      = "/register"
	PathSignIn        = "/sign-in"
	PathCallback      = "/callback"
	PathProviders     = "/providers"
	PathGetProvider   = "/get-provider"
	PathUpdate        = "/update-provider"
	PathDelete        = "/delete-provider"
	PathSPMetadata    = "/saml2/sp/metadata"
	PathACS           = "/saml2/sp/acs"
	PathRequestDomain = "/request-domain-verification"
	PathVerifyDomain  = "/verify-domain"
)

Routes, relative to the plugin's mount ("/auth/sso" by default). The OIDC callback and the SAML ACS are shared by every provider: the core routes exact paths, so the provider travels in the state, the RelayState or (for an IdP-initiated sign-in) a providerId query parameter.

View Source
const (
	// DefaultProvidersLimit is how many providers one user may register.
	DefaultProvidersLimit = 10
	// DefaultStateTTL bounds an OIDC flow and an SP-initiated SAML request.
	DefaultStateTTL = 10 * time.Minute
	// DefaultSAMLRequestTTL is how long an AuthnRequest's ID is accepted
	// as InResponseTo.
	DefaultSAMLRequestTTL = 5 * time.Minute
	// DefaultClockSkew is the tolerance on every SAML timestamp.
	DefaultClockSkew = 5 * time.Minute
	// DefaultMaxResponseSize bounds a base64-decoded SAML response.
	DefaultMaxResponseSize = 256 * 1024
	// DefaultMaxMetadataSize bounds IdP or SP metadata XML.
	DefaultMaxMetadataSize = 100 * 1024
	// DefaultReplayWindow is how long an assertion ID stays reserved when
	// the assertion states no NotOnOrAfter.
	DefaultReplayWindow = 15 * time.Minute
	// DefaultDomainTokenTTL is how long a domain-verification token is
	// accepted (better-auth: one week).
	DefaultDomainTokenTTL = 7 * 24 * time.Hour
	// DefaultDomainTokenPrefix names the DNS TXT record of a domain
	// verification: _<prefix>-<providerId>.<domain>.
	DefaultDomainTokenPrefix = "ubgo-auth-token"
	// DefaultHTTPTimeout bounds every call to an identity provider.
	DefaultHTTPTimeout = 10 * time.Second
)

Defaults, each better-auth's.

View Source
const (
	TableNameProvider  = "auth_sso_provider"
	TableAliasProvider = "ssop"
	TableNameAccount   = "auth_sso_account"
	TableAliasAccount  = "ssoa"

	ColID             = "id"
	ColProviderID     = "provider_id"
	ColIssuer         = "issuer"
	ColDomain         = "domain"
	ColOrganizationID = "organization_id"
	ColUserID         = "user_id"
	// ColOIDCConfig and ColSAMLConfig hold JSON. The OIDC client secret
	// inside is sealed (auth.KeyRing, additional data the provider id), so
	// a database dump does not hand out every customer's IdP credential.
	ColOIDCConfig     = "oidc_config"
	ColSAMLConfig     = "saml_config"
	ColDomainVerified = "domain_verified"
	ColCreatedAt      = "created_at"
	ColUpdatedAt      = "updated_at"
	ColSubject        = "subject"
	// ColDomainToken and ColDomainTokenExpires hold the pending
	// domain-verification token (” and NULL when none).
	ColDomainToken        = "domain_token"
	ColDomainTokenExpires = "domain_token_expires_at"
	// ColFields holds the WithProviderFields values as a JSON object, NULL
	// when none is set.
	ColFields = "fields"
	ColEmail  = "email"
)

Table labels and columns. auth_sso_provider holds the registered providers; auth_sso_account links an IdP subject to a user. Neither has a foreign key (ADR-0002): DeleteUser removes a user's sso accounts through UserTables, and a provider outlives the admin who registered it.

View Source
const DefaultLogoutRequestTTL = 5 * time.Minute

DefaultLogoutRequestTTL bounds an SP-initiated LogoutRequest.

View Source
const DefaultMountPrefix = "/auth"

DefaultMountPrefix is where authhttp mounts plugins by default; the callback and ACS URLs are built under it unless WithCallbackURL or WithACSURL say otherwise.

View Source
const PluginName = "sso"

PluginName is the plugin's permanent identity and its route segment.

Variables

View Source
var (
	ErrFieldNotAllowed = auth.NewError("sso.field_not_allowed", http.StatusBadRequest, "A provider field may not be set here.")
	ErrBadFields       = errors.New("sso: a provider field name is empty, repeated, or a built-in key")
)

Errors of additional fields.

View Source
var (
	ErrSLOUnsupported        = auth.NewError("sso.idp_slo_unsupported", http.StatusBadRequest, "This identity provider does not support single logout.")
	ErrSAMLSessionNotFound   = auth.NewError("sso.saml_session_not_found", http.StatusNotFound, "This session did not start with SAML single sign-on.")
	ErrInvalidLogoutRequest  = auth.NewError("sso.invalid_logout_request", http.StatusBadRequest, "The logout request was not accepted.")
	ErrInvalidLogoutResponse = auth.NewError("sso.invalid_logout_response", http.StatusBadRequest, "The logout response was not accepted.")
	ErrLogoutFailedAtIdP     = auth.NewError("sso.logout_failed_at_idp", http.StatusBadGateway, "The identity provider could not sign you out.")
)

Errors of single logout.

View Source
var (
	ErrProviderNotFound   = auth.NewError("sso.provider_not_found", http.StatusNotFound, "No sign-in provider was found for this account.")
	ErrProviderExists     = auth.NewError("sso.provider_exists", http.StatusUnprocessableEntity, "A sign-in provider with this id already exists.")
	ErrProviderIDReserved = auth.NewError("sso.provider_id_reserved", http.StatusUnprocessableEntity, "This provider id is reserved.")
	ErrBadProvider        = auth.NewError("sso.bad_provider", http.StatusBadRequest, "The provider configuration is not valid.")
	ErrRegistrationOff    = auth.NewError("sso.registration_disabled", http.StatusForbidden, "Sign-in provider registration is disabled.")
	ErrProvidersLimit     = auth.NewError("sso.providers_limit", http.StatusForbidden, "You have reached the maximum number of sign-in providers.")
	ErrForbidden          = auth.NewError("sso.forbidden", http.StatusForbidden, "You cannot manage this sign-in provider.")
	ErrNotMember          = auth.NewError("sso.not_member", http.StatusBadRequest, "You are not a member of this organization.")
	ErrSignInTarget       = auth.NewError("sso.target_required", http.StatusBadRequest, "Give an email, a domain, an organization or a provider id.")
	ErrDomainUnverified   = auth.NewError("sso.domain_unverified", http.StatusUnauthorized, "This provider's domain has not been verified.")
	ErrProtocol           = auth.NewError("sso.protocol_unavailable", http.StatusBadRequest, "This provider does not offer that sign-in protocol.")
	ErrBadParams          = auth.NewError("sso.bad_params", http.StatusBadRequest, "A sign-in parameter is not allowed.")
	ErrBadReturnTo        = auth.NewError("sso.bad_return_to", http.StatusBadRequest, "The return address must be a path on this site or a trusted origin.")
	ErrStateInvalid       = auth.NewError("sso.state_invalid", http.StatusBadRequest, "This sign-in link has expired. Start again.")
	ErrProviderChanged    = auth.NewError("sso.provider_changed", http.StatusConflict, "The sign-in provider changed during sign-in. Start again.")
	ErrProviderDenied     = auth.NewError("sso.denied", http.StatusBadRequest, "Sign-in was cancelled at the provider.")
	ErrTokenNotVerified   = auth.NewError("sso.token_not_verified", http.StatusUnauthorized, "The provider's sign-in token was not accepted.")
	ErrNoIDToken          = auth.NewError("sso.token_response_not_found", http.StatusBadGateway, "The provider's answer carried no sign-in token.")
	ErrNoPrivateKey       = auth.NewError("sso.no_private_key_available", http.StatusInternalServerError, "The sign-in key for this provider is not available.")
	ErrTokenExchange      = auth.NewError("sso.token_response_error", http.StatusBadGateway, "The provider did not complete the sign-in. Try again.")
	ErrProviderFailed     = auth.NewError("sso.provider_failed", http.StatusBadGateway, "The sign-in provider could not be reached. Try again.")
	ErrAccountExists      = auth.NewError("sso.account_exists", http.StatusConflict, "An account with this email already exists. Sign in, then link this provider.")
	ErrLinkedElsewhere    = auth.NewError("sso.linked_elsewhere", http.StatusConflict, "This provider account is linked to another user.")
	ErrSignUpDisabled     = auth.NewError("sso.sign_up_disabled", http.StatusForbidden, "No account exists for you here. Ask for an invitation or sign up first.")
	ErrUserRejected       = auth.NewError("sso.user_rejected", http.StatusForbidden, "This account cannot sign in here.")
	ErrBoundaryLocked     = auth.NewError("sso.boundary_locked", http.StatusConflict, "Cannot change a provider's identity fields while accounts are linked to it.")
	ErrMutationRejected   = auth.NewError("sso.mutation_rejected", http.StatusConflict, "This change to the sign-in provider was refused.")
	ErrSAMLInvalid        = auth.NewError("sso.saml_invalid", http.StatusBadRequest, "The sign-in response was not accepted.")
	ErrSAMLReplay         = auth.NewError("sso.saml_replay", http.StatusBadRequest, "This sign-in response was already used.")
	ErrSAMLUnsolicited    = auth.NewError("sso.saml_unsolicited", http.StatusBadRequest, "Sign-in must start on this site.")
	ErrSAMLTooLarge       = auth.NewError("sso.saml_too_large", http.StatusBadRequest, "The sign-in response is too large.")
	ErrNoIdentity         = auth.NewError("sso.no_identity", http.StatusBadRequest, "The provider sent no user id or email.")
	ErrDomainVerified     = auth.NewError("sso.domain_verified", http.StatusConflict, "This domain is already verified.")
	ErrDomainNotProven    = auth.NewError("sso.domain_not_proven", http.StatusBadGateway, "The domain's DNS record was not found. It can take a while to appear.")
	ErrNoPendingDomain    = auth.NewError("sso.no_pending_verification", http.StatusNotFound, "No domain verification is pending for this provider. Request a new token.")
	ErrIdentifierTooLong  = auth.NewError("sso.identifier_too_long", http.StatusBadRequest, "The provider id is too long for a DNS verification record.")
)

Errors a caller sees. Codes are permanent.

View Source
var (
	ErrNoCallbackURL = errors.New("sso: callback URLs need auth.WithBaseURL or sso.WithCallbackURL")
	ErrBadDefault    = errors.New("sso: a default provider is not valid")
	ErrPrefixTooLong = errors.New("sso: the domain token prefix leaves no room for a provider id in a DNS label (63 bytes)")
	// ErrNoSAMLEngine was WithSAMLEngine(nil).
	//
	// Deprecated: no longer returned. WithSAMLEngine(nil) fails auth.New
	// with auth.ErrNilOption naming the option.
	ErrNoSAMLEngine    = errors.New("sso: SAML needs a SAMLEngine (sso.WithSAMLEngine)")
	ErrKeyDerivation   = errors.New("sso: provider secrets need auth.WithSecret, so they are sealed at rest")
	ErrBadAlgorithmSet = errors.New("sso: an allowed SAML algorithm is not one this plugin knows")
)

Configuration errors, returned by auth.New.

View Source
var (
	TableProvider          = sqlb.Table{Name: TableNameProvider, Alias: TableAliasProvider}
	ProviderRowID          = sqlb.Col[string](TableProvider, ColID)
	ProviderProviderID     = sqlb.Col[string](TableProvider, ColProviderID)
	ProviderIssuer         = sqlb.Col[string](TableProvider, ColIssuer)
	ProviderDomain         = sqlb.Col[string](TableProvider, ColDomain)
	ProviderOrganizationID = sqlb.Col[string](TableProvider, ColOrganizationID)
	ProviderUserID         = sqlb.Col[string](TableProvider, ColUserID)
	ProviderOIDCConfig     = sqlb.Col[string](TableProvider, ColOIDCConfig)
	ProviderSAMLConfig     = sqlb.Col[string](TableProvider, ColSAMLConfig)
	ProviderDomainVerified = sqlb.Col[bool](TableProvider, ColDomainVerified)
	ProviderCreatedAt      = sqlb.Col[time.Time](TableProvider, ColCreatedAt)
	ProviderUpdatedAt      = sqlb.Col[time.Time](TableProvider, ColUpdatedAt)
	ProviderDomainToken    = sqlb.Col[string](TableProvider, ColDomainToken)
	ProviderDomainTokenExp = sqlb.Col[*time.Time](TableProvider, ColDomainTokenExpires)
	ProviderFields         = sqlb.Col[*string](TableProvider, ColFields)

	TableAccount      = sqlb.Table{Name: TableNameAccount, Alias: TableAliasAccount}
	AccountID         = sqlb.Col[string](TableAccount, ColID)
	AccountUserID     = sqlb.Col[string](TableAccount, ColUserID)
	AccountProviderID = sqlb.Col[string](TableAccount, ColProviderID)
	AccountIssuer     = sqlb.Col[string](TableAccount, ColIssuer)
	AccountSubject    = sqlb.Col[string](TableAccount, ColSubject)
	AccountEmail      = sqlb.Col[string](TableAccount, ColEmail)
	AccountCreatedAt  = sqlb.Col[time.Time](TableAccount, ColCreatedAt)
)

Typed tables and columns, for an app that joins them.

AssignmentValues is every Assignment.

DataEncryptionAlgorithms are the data-encryption algorithms this plugin knows; 3DES is deprecated. AES-192-CBC is listed so a response naming it is refused as unsupported by the engine rather than as unknown.

DeprecatedPolicyValues is every DeprecatedPolicy.

DigestAlgorithms are the digest algorithms this plugin knows; SHA-1 is deprecated.

DiscoveryCodeValues is every DiscoveryCode.

View Source
var ErrSPMetadataMismatch = auth.NewError("sso.sp_metadata_mismatch", http.StatusBadRequest, "The SP metadata does not match the provider's settings.")

ErrSPMetadataMismatch is custom SP metadata that contradicts the config.

View Source
var KeyEncryptionAlgorithms = []string{KeyEncRSA15, KeyEncRSAOAEP, KeyEncRSAOAEP11}

KeyEncryptionAlgorithms are the key-transport algorithms this plugin knows; RSA-1.5 is deprecated.

View Source
var MutationActionValues = []MutationAction{MutationUpdate, MutationDelete}

MutationActionValues is every MutationAction.

View Source
var ProtocolValues = []Protocol{ProtocolOIDC, ProtocolSAML}

ProtocolValues is every Protocol.

View Source
var ResolveActionValues = []ResolveAction{ResolveContinue, ResolveLink}

ResolveActionValues is every ResolveAction.

SignatureAlgorithms are the signature algorithms this plugin knows; RSA-SHA1 is deprecated (SAMLOptions.OnDeprecated).

Functions

func DeriveIdPEntityID

func DeriveIdPEntityID(cfg SAMLConfig, maxMetadata int) (string, error)

DeriveIdPEntityID is the IdP entity id of a config, by the parser sign-in uses (better-auth's deriveSAMLIdentityProviderEntityID).

func DiscoveryURL

func DiscoveryURL(issuer string, cfg OIDCConfig) string

DiscoveryURL is where the document of cfg is read: DiscoveryEndpoint, or issuer + /.well-known/openid-configuration.

func DomainMatches

func DomainMatches(domains []string, d string) bool

DomainMatches reports whether email domain d is one of domains or a subdomain of one.

func DomainRecordName

func DomainRecordName(prefix, providerID, domain string) string

DomainRecordName is where the TXT record of a provider's domain lives.

func LogoutDocument

func LogoutDocument(kind, id, issuer, destination, nameID, nameIDFormat string, sessionIndexes []string, inResponseTo, status string, now time.Time) *etree.Document

LogoutDocument builds a LogoutRequest (nameID set) or LogoutResponse (inResponseTo and status set) from this SP.

func NeedsRuntimeDiscovery

func NeedsRuntimeDiscovery(cfg OIDCConfig) bool

NeedsRuntimeDiscovery reports whether an endpoint the flow needs is missing, so discovery runs again at sign-in.

func NormalizeEndpoint

func NormalizeEndpoint(issuer, endpoint string) (string, error)

NormalizeEndpoint resolves a discovered endpoint against the issuer: an absolute URL is kept, a path is joined to the issuer's origin and path ("/oauth/token" under "https://idp/tenant" is "https://idp/tenant/oauth/token").

func PublicAddr

func PublicAddr(ip netip.Addr) bool

PublicAddr reports whether ip is publicly routable: not loopback, private (RFC 1918, fc00::/7), link-local (169.254.0.0/16, which holds the cloud metadata address, and fe80::/10), carrier-grade NAT, multicast or unspecified.

func RecordHoldsToken

func RecordHoldsToken(records []string, label, token string) bool

RecordHoldsToken reports whether a TXT record set proves the token: a record that is the token, or "<label>=<token>". Compared in constant time, like every secret.

func RedirectURL

func RedirectURL(dest, param string, msg *etree.Document, relay string, key crypto.Signer, sigAlg string) (string, error)

RedirectURL encodes a message for the HTTP-Redirect binding at dest, signing the query when key is set.

func SPMetadataXML

func SPMetadataXML(sp SPSettings) ([]byte, error)

SPMetadataXML renders this app's metadata for one provider: entity id, POST ACS, the NameID format, the signing and encryption certificate when set, and the two signing flags.

func SelectTokenAuth

func SelectTokenAuth(methods []string) (oauth.TokenAuth, error)

SelectTokenAuth picks the token-endpoint authentication from the methods a discovery document lists: client_secret_basic, then client_secret_post, then private_key_jwt; an empty list means client_secret_basic, the specification's default. A list naming none of the three is DiscoveryUnsupportedAuth.

func ValidateDiscoveryDocument

func ValidateDiscoveryDocument(doc DiscoveryDocument, issuer string) error

ValidateDiscoveryDocument checks a document against the configured issuer: the same issuer (trailing slash aside), and the authorization, token and JWKS endpoints present.

Types

type ACSRequest

type ACSRequest struct {
	SAMLResponse string `json:"SAMLResponse"`
	RelayState   string `json:"RelayState,omitempty"`
	// ProviderID is set by ProviderPaths from a per-provider ACS URL.
	ProviderID string `json:"providerId,omitempty"`
}

ACSRequest is the IdP's POST to the assertion consumer service.

type Account

type Account struct {
	ID         string
	UserID     string
	ProviderID string
	// Issuer is the ID token's iss or the IdP entity id at link time.
	Issuer string
	// Subject is the OIDC sub or SAML NameID, compared exactly.
	Subject   string
	Email     string
	CreatedAt time.Time
}

Account is one IdP account linked to a user: a row of auth_sso_account.

type Assignment

type Assignment string

Assignment is the outcome of AssignByVerifiedDomain, better-auth's organization-assignment outcomes.

const (
	AssignAssigned          Assignment = "assigned"
	AssignAlreadyMember     Assignment = "already-member"
	AssignAmbiguous         Assignment = "ambiguous-target"
	AssignIneligibleSource  Assignment = "ineligible-source"
	AssignInvitationPending Assignment = "invitation-pending"
	AssignNoTarget          Assignment = "no-target"
	AssignDisabled          Assignment = "provisioning-disabled"
	AssignUnverified        Assignment = "unverified-identity"
)

type BindingMessage

type BindingMessage struct {
	// Redirect is true for HTTP-Redirect (deflated, query-signed), false
	// for HTTP-POST (enveloped XML signature).
	Redirect   bool
	Value      string
	Param      string
	RelayState string
	SigAlg     string
	Signature  string
}

BindingMessage is a SAML message as it arrived: the redirect binding's query values, or the POST binding's form values.

type CallbackRequest

type CallbackRequest struct {
	Code             string `json:"code,omitempty"`
	State            string `json:"state,omitempty"`
	Error            string `json:"error,omitempty"`
	ErrorDescription string `json:"error_description,omitempty"`
	// Iss is RFC 9207's issuer parameter.
	Iss string `json:"iss,omitempty"`
	// ProviderID names the provider of an IdP-initiated callback, which has
	// no state: register the redirect URI with ?providerId=<id> at the IdP.
	ProviderID string `json:"providerId,omitempty"`
}

CallbackRequest is the OIDC callback's query.

type CallbackResult

type CallbackResult struct {
	User       auth.UserView `json:"user"`
	Token      auth.Token    `json:"-"`
	RedirectTo string        `json:"redirectTo"`
	// Created is true when the sign-in created the user.
	Created              bool `json:"created"`
	SecondFactorRequired bool `json:"secondFactorRequired,omitempty"`
}

CallbackResult is a completed sign-in.

type CheckRequest

type CheckRequest struct {
	IdPEntityID string
	Audience    string
	ACSURL      string
	// RequestID is the AuthnRequest ID of an SP-initiated sign-in; "" for
	// an IdP-initiated one, whose InResponseTo must then be empty.
	RequestID            string
	WantAssertionsSigned bool
	Now                  time.Time
	ClockSkew            time.Duration
	RequireTimestamps    bool
}

CheckRequest is what CheckAssertion compares a response against.

type Decision

type Decision struct {
	Action ResolveAction
	// UserID is the user to link to, for ResolveLink.
	UserID string
	// UpdateProfile copies the IdP's name and image onto that user.
	UpdateProfile bool
}

Decision is what a WithResolveUser function decided.

type DeprecatedPolicy

type DeprecatedPolicy string

DeprecatedPolicy is what to do with a SHA-1 signature or digest.

const (
	DeprecatedWarn   DeprecatedPolicy = "warn"
	DeprecatedReject DeprecatedPolicy = "reject"
	DeprecatedAllow  DeprecatedPolicy = "allow"
)

type DiscoveryCode

type DiscoveryCode string

DiscoveryCode names why discovery failed; each is better-auth's code.

const (
	DiscoveryIssuerMismatch   DiscoveryCode = "issuer_mismatch"
	DiscoveryIncomplete       DiscoveryCode = "discovery_incomplete"
	DiscoveryNotFound         DiscoveryCode = "discovery_not_found"
	DiscoveryTimeout          DiscoveryCode = "discovery_timeout"
	DiscoveryInvalidURL       DiscoveryCode = "discovery_invalid_url"
	DiscoveryPrivateHost      DiscoveryCode = "discovery_private_host"
	DiscoveryInvalidJSON      DiscoveryCode = "discovery_invalid_json"
	DiscoveryRedirect         DiscoveryCode = "oidc_endpoint_redirect"
	DiscoveryUnsupportedAuth  DiscoveryCode = "unsupported_token_auth_method"
	DiscoveryUnexpectedFailed DiscoveryCode = "discovery_unexpected_error"
	DiscoveryUntrustedOrigin  DiscoveryCode = "discovery_untrusted_origin"
)

type DiscoveryDocument

type DiscoveryDocument struct {
	Issuer                string   `json:"issuer"`
	AuthorizationEndpoint string   `json:"authorization_endpoint"`
	TokenEndpoint         string   `json:"token_endpoint"`
	UserinfoEndpoint      string   `json:"userinfo_endpoint"`
	JWKSURI               string   `json:"jwks_uri"`
	TokenAuthMethods      []string `json:"token_endpoint_auth_methods_supported"`
}

DiscoveryDocument is the part of an OpenID Connect discovery document the plugin reads.

type DiscoveryError

type DiscoveryError struct {
	Code DiscoveryCode
	Err  error
}

DiscoveryError is a failed discovery: the code, and the cause for logs.

func (*DiscoveryError) Error

func (e *DiscoveryError) Error() string

func (*DiscoveryError) Unwrap

func (e *DiscoveryError) Unwrap() []error

Unwrap returns the answer for the code, so errors.Is(err, the *auth.Error) holds and the transport answers it.

type DomainTokenResult

type DomainTokenResult struct {
	DomainVerificationToken string `json:"domainVerificationToken"`
	// RecordName is the TXT record's name for the first domain.
	RecordName string `json:"recordName"`
}

DomainTokenResult is the token to publish.

type DomainVerification

type DomainVerification struct {
	// TokenPrefix names the record; empty is DefaultDomainTokenPrefix.
	TokenPrefix string
	// Resolver looks the record up; nil is net.DefaultResolver.
	Resolver TXTResolver
}

DomainVerification turns on proving a provider's domains through DNS: registration returns a token (DomainToken), the customer publishes it as a TXT record at _<prefix>-<providerId>.<domain>, and verify-domain checks it. Until then the provider cannot sign anyone in, and only a verified provider links to existing users by email.

type Endpoint

type Endpoint struct {
	Binding  string `json:"Binding"`
	Location string `json:"Location"`
}

Endpoint is a SAML binding and location.

type GoSAML2

type GoSAML2 struct{}

GoSAML2 is the default SAMLEngine, on github.com/russellhaering/gosaml2 and its XML-signature library goxmldsig.

Why this library: it keeps every setting on a per-provider struct (no package-level clock or skew, which crewjam/saml has), it returns only the content its signature check covered (the unverified element is replaced by the verified one, the defence against signature wrapping), it bounds decompression and XML token counts, and it is fuzzed in OSS-Fuzz. It is built per call from the provider's settings and holds no state.

func (GoSAML2) AuthnRequestPOST

func (g GoSAML2) AuthnRequestPOST(sp SPSettings, idp IdPSettings, requestID, relayState string) (PostForm, error)

AuthnRequestPOST builds the POST-binding form of an AuthnRequest whose ID is requestID, with an enveloped signature when asked.

func (GoSAML2) AuthnRequestURL

func (g GoSAML2) AuthnRequestURL(sp SPSettings, idp IdPSettings, requestID, relayState string) (string, error)

AuthnRequestURL builds the HTTP-Redirect URL of an AuthnRequest whose ID is requestID; the library signs the query when asked.

func (GoSAML2) VerifyResponse

func (g GoSAML2) VerifyResponse(sp SPSettings, idp IdPSettings, samlResponse string, now time.Time) (*SAMLResponse, error)

VerifyResponse verifies the signature and returns the verified content. The library's own audience and time checks are warnings it leaves to the caller; CheckAssertion applies them.

type IdPMetadata

type IdPMetadata struct {
	// Metadata is the IdP's metadata XML; its entity id, signing
	// certificates and SSO endpoint are read from it.
	Metadata string `json:"metadata,omitempty"`
	// EntityID is required when Metadata is empty.
	EntityID            string     `json:"entityID,omitempty"`
	Cert                string     `json:"cert,omitempty"`
	SingleSignOnService []Endpoint `json:"singleSignOnService,omitempty"`
	// SingleLogoutService is the IdP's SLO endpoint (HTTP-Redirect).
	SingleLogoutService []Endpoint `json:"singleLogoutService,omitempty"`
}

IdPMetadata is the IdP's side: its metadata XML, or the fields written out.

type IdPSettings

type IdPSettings struct {
	EntityID string
	SSOURL   string
	// SLOURL is the IdP's single logout URL (HTTP-Redirect); "" when it has
	// none.
	SLOURL string
	Certs  []*x509.Certificate
}

IdPSettings are the IdP's settings.

type InvitationChecker

type InvitationChecker interface {
	PendingInvitation(ctx context.Context, orgID, userID string) (bool, error)
}

InvitationChecker is an optional Organizations upgrade: an organization system that knows pending invitations implements it, and joining at sign-in then leaves a user with a pending invitation to accept it, as better-auth does, so the invited role is not overwritten by the default.

type LogoutMessage

type LogoutMessage struct {
	Kind           string
	ID             string
	InResponseTo   string
	Issuer         string
	Destination    string
	NameID         string
	NameIDFormat   string
	SessionIndexes []string
	NotOnOrAfter   time.Time
	Status         string
	Signed         bool
}

LogoutMessage is a LogoutRequest or LogoutResponse, after its signature was checked (Signed says whether it carried one).

func DecodeLogout

func DecodeLogout(m BindingMessage, certs []*x509.Certificate, now time.Time, maxSize int) (*LogoutMessage, error)

DecodeLogout reads a logout message and checks its signature against the IdP's certificates: the query signature for the redirect binding, the enveloped XML signature for POST, whose verified element is the only content read. An unsigned message comes back with Signed false; the caller decides whether that is acceptable. maxSize bounds the XML.

func PeekLogout

func PeekLogout(m BindingMessage, maxSize int) (*LogoutMessage, error)

PeekLogout reads a logout message without checking its signature, only to learn which provider's certificates must check it.

type LogoutRequest

type LogoutRequest struct {
	// CallbackURL is where the browser lands after the IdP answers.
	CallbackURL string `json:"callbackURL,omitempty"`
}

LogoutRequest starts an SP-initiated single logout.

type LogoutResult

type LogoutResult struct {
	URL      string `json:"url"`
	Redirect bool   `json:"redirect"`
}

LogoutResult is where to send the browser: the IdP's SLO URL.

type MetadataResult

type MetadataResult struct {
	XML string `json:"metadata"`
}

MetadataResult is SP metadata XML.

type MutationAction

type MutationAction string

MutationAction is what a MutationGuard is asked about.

const (
	MutationUpdate MutationAction = "update"
	MutationDelete MutationAction = "delete"
)

type MutationGuard

type MutationGuard func(ctx context.Context, c *auth.Call, in MutationInput) error

MutationGuard is the WithMutationGuard hook.

type MutationInput

type MutationInput struct {
	Action   MutationAction
	Provider Provider
	// Next is the provider after an update; zero for a delete.
	Next Provider
	// BoundaryChange is true when an update changes an authentication
	// boundary field (issuer, endpoints, client id, entity ids, certs).
	BoundaryChange bool
}

MutationInput describes a provider update or delete.

type OIDCConfig

type OIDCConfig struct {
	ClientID string `json:"clientId"`
	// ClientSecret is required unless TokenEndpointAuthentication is
	// private_key_jwt or none. Stored sealed; never returned by a read.
	ClientSecret          string `json:"clientSecret,omitempty"`
	AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
	TokenEndpoint         string `json:"tokenEndpoint,omitempty"`
	UserInfoEndpoint      string `json:"userInfoEndpoint,omitempty"`
	JWKSEndpoint          string `json:"jwksEndpoint,omitempty"`
	// DiscoveryEndpoint defaults to <issuer>/.well-known/openid-configuration.
	DiscoveryEndpoint string `json:"discoveryEndpoint,omitempty"`
	// SkipDiscovery never fetches the discovery document: the endpoints
	// above must all be given (JWKS included, since the ID token is
	// verified).
	SkipDiscovery bool `json:"skipDiscovery,omitempty"`
	// TokenEndpointAuthentication is client_secret_basic,
	// client_secret_post, private_key_jwt or none; empty takes the best
	// method the discovery document lists (basic, then post, then
	// private_key_jwt), and client_secret_basic without discovery.
	TokenEndpointAuthentication oauth.TokenAuth `json:"tokenEndpointAuthentication,omitempty"`
	// PrivateKeyID and PrivateKeyAlgorithm are handed to the
	// WithPrivateKeyResolver function for private_key_jwt; the key itself
	// never lives in the row.
	PrivateKeyID        string `json:"privateKeyId,omitempty"`
	PrivateKeyAlgorithm string `json:"privateKeyAlgorithm,omitempty"`
	// Scopes default to openid email profile offline_access.
	Scopes []string `json:"scopes,omitempty"`
	// PKCE defaults to true; false only for an IdP that refuses it.
	PKCE *bool `json:"pkce,omitempty"`
	// OverrideUserInfo copies the IdP's name and image onto the user at
	// every sign-in, not only at creation.
	OverrideUserInfo bool        `json:"overrideUserInfo,omitempty"`
	Mapping          OIDCMapping `json:"mapping,omitempty"`
	// AllowIdPInitiated answers a callback with a code and no state (an
	// IdP dashboard tile) by starting a fresh flow with new state and PKCE;
	// the code it carried is never redeemed.
	AllowIdPInitiated bool `json:"allowIdpInitiated,omitempty"`
}

OIDCConfig is an OpenID Connect provider's settings. Every endpoint left empty is filled from the issuer's discovery document at registration (and again at sign-in if still missing); a value set here wins.

type OIDCMapping

type OIDCMapping struct {
	ID            string `json:"id,omitempty"`
	Email         string `json:"email,omitempty"`
	EmailVerified string `json:"emailVerified,omitempty"`
	Name          string `json:"name,omitempty"`
	Image         string `json:"image,omitempty"`
	// ExtraFields copies claims into UserInfo.Extra, keyed by the map's
	// key: {"department": "dept"}.
	ExtraFields map[string]string `json:"extraFields,omitempty"`
}

OIDCMapping names the claims read for each profile field; an empty name takes the standard claim (sub, email, email_verified, name, picture).

type Option

type Option func(*Plugin)

Option configures the plugin.

func DefaultOverrideUserInfo

func DefaultOverrideUserInfo() Option

DefaultOverrideUserInfo turns OIDCConfig.OverrideUserInfo on for every provider: the IdP's name and image are copied at every sign-in.

func DisableImplicitSignUp

func DisableImplicitSignUp() Option

DisableImplicitSignUp refuses to create a user unless the sign-in asked for it (SignInRequest.RequestSignUp).

func PerProviderPaths

func PerProviderPaths() Option

PerProviderPaths gives every provider its own OIDC redirect URI, ACS and SLO URL, better-auth's /sso/callback/:providerId shape: the shared URL plus "/<providerId>". The core routes exact paths, so the app mounts Plugin.ProviderPaths in front of its auth handler, which turns each such URL into the shared route with the provider id as a parameter. The id is then a second check: a callback or response for one provider arriving at another's URL is refused.

func ProvidersLimit

func ProvidersLimit(n int) Option

ProvidersLimit caps how many providers one user may register; 0 turns registration off (ErrRegistrationOff). The default is DefaultProvidersLimit.

func ProvisionUserOnEveryLogin

func ProvisionUserOnEveryLogin() Option

ProvisionUserOnEveryLogin runs the WithProvisionUser function at every sign-in, not only the first.

func RequireTrustedOrigins

func RequireTrustedOrigins() Option

RequireTrustedOrigins accepts only IdP URLs whose origin WithTrustedOrigins lists, better-auth's trustedOrigins rule for discovery: the discovery URL and every discovered endpoint. For an app whose customers' IdPs are known in advance.

func ReserveProviderIDs

func ReserveProviderIDs(ids ...string) Option

ReserveProviderIDs refuses these provider ids at registration, besides every plugin name and every plugin/oauth provider name (always reserved, so an SSO provider can never shadow another sign-in method).

func TrustEmailVerified

func TrustEmailVerified() Option

TrustEmailVerified takes the IdP's email_verified claim (or the SAML mapping's emailVerified attribute) as proof the person controls the address. Off by default, as in better-auth: an enterprise IdP often asserts an address it never verified.

func TrustProviders

func TrustProviders(ids ...string) Option

TrustProviders lets these providers link to an existing user with the same verified email in one of their domains even when domain verification is off. A default provider and a domain-verified one are trusted already.

func WithACSURL

func WithACSURL(u string) Option

WithACSURL sets the SAML assertion consumer service URL. The default is BaseURL + DefaultMountPrefix + "/sso/saml2/sp/acs".

func WithCallbackURL

func WithCallbackURL(u string) Option

WithCallbackURL sets the OIDC redirect URI registered at every IdP. The default is BaseURL + DefaultMountPrefix + "/sso/callback".

func WithDefaultProviders

func WithDefaultProviders(ps ...Provider) Option

WithDefaultProviders adds providers defined in code (better-auth's defaultSSO): matched by provider id or domain before any row, never stored, never editable through the API, and treated as domain-verified, since only the app's own code can add one.

func WithDomainVerification

func WithDomainVerification(d DomainVerification) Option

WithDomainVerification turns domain verification on.

func WithErrorURL

func WithErrorURL(path string) Option

WithErrorURL sends a failed sign-in's browser to path (with the error code) instead of answering JSON, as oauth.ErrorURL does.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient replaces the client every IdP call uses. The default refuses redirects and private, loopback and link-local addresses (the SSRF guard), resolved at connect time so a DNS answer that changes after a check cannot slip through. An app that replaces it owns that guard; WithTrustedOrigins is the narrower escape hatch.

func WithMutationGuard

func WithMutationGuard(fn MutationGuard) Option

WithMutationGuard runs fn before every provider update or delete; an error refuses it (an *auth.Error as it is, anything else as ErrMutationRejected).

func WithOrganizations

func WithOrganizations(o Organizations, prov OrganizationProvisioning) Option

WithOrganizations connects an organization system: registration under an organization then needs the caller to be its owner or admin, sign-in can name an organization by slug, and a user who signs in through an organization's provider joins it (OrganizationProvisioning).

func WithPrivateKeyResolver

func WithPrivateKeyResolver(fn PrivateKeyResolver) Option

WithPrivateKeyResolver supplies the key for private_key_jwt token authentication, so the key can live in an HSM or KMS rather than a row.

func WithProviderFields

func WithProviderFields(fields ...ProviderField) Option

WithProviderFields declares the provider's additional fields.

func WithProvidersLimit

func WithProvidersLimit(fn func(ctx context.Context, u auth.User) int) Option

WithProvidersLimit computes the cap per user, for plans that differ.

func WithProvisionUser

func WithProvisionUser(fn ProvisionFunc) Option

WithProvisionUser runs fn after a user signs in through a provider for the first time (or every time, with ProvisionUserOnEveryLogin), inside the sign-in transaction: an error refuses the sign-in and rolls back the user it created.

func WithResolveUser

func WithResolveUser(fn ResolveUserFunc) Option

WithResolveUser decides, before the built-in rules, whose account a first sign-in through a provider belongs to: continue with the rules, link to a user the app names, or refuse by returning an error (an *auth.Error is answered as it is). It runs in the sign-in transaction.

func WithSAMLEngine

func WithSAMLEngine(e SAMLEngine) Option

WithSAMLEngine replaces the XML-signature engine (GoSAML2 by default).

func WithSAMLOptions

func WithSAMLOptions(o SAMLOptions) Option

WithSAMLOptions sets the plugin-wide SAML settings; zero fields keep their defaults.

func WithStateTTL

func WithStateTTL(d time.Duration) Option

WithStateTTL bounds an OIDC flow; the default is DefaultStateTTL.

func WithTrustedOrigins

func WithTrustedOrigins(origins ...string) Option

WithTrustedOrigins lists IdP origins ("https://idp.internal:8443") the SSRF guard lets through even on a private address, and that may use plain http: an on-premises ADFS, a local test IdP.

type OrganizationProvisioning

type OrganizationProvisioning struct {
	// Disabled turns joining off.
	Disabled bool
	// DefaultRole is the new member's role; empty is RoleMember.
	DefaultRole string
	// Role computes the role per sign-in, overriding DefaultRole.
	Role func(ctx context.Context, ev ProvisionEvent) (string, error)
}

OrganizationProvisioning controls joining a provider's organization at sign-in.

type Organizations

type Organizations interface {
	// Role returns userID's role in orgID; ok is false when the user is not
	// a member.
	Role(ctx context.Context, orgID, userID string) (role string, ok bool, err error)
	// BySlug returns the id of the organization with slug; ok is false
	// when there is none.
	BySlug(ctx context.Context, slug string) (orgID string, ok bool, err error)
	// AddMember adds userID to orgID with role.
	AddMember(ctx context.Context, orgID, userID, role string) error
}

Organizations is the organization system the plugin consults. The organization plugin is a separate module this one does not import; an app adapts it (or its own) in a few lines, shown in the README.

type Plugin

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

Plugin is the sso plugin. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) ACS

func (p *Plugin) ACS(ctx context.Context, in ACSRequest) (CallbackResult, error)

ACS is POST /saml2/sp/acs (acsEndpoint).

func (*Plugin) Accounts

func (p *Plugin) Accounts(ctx context.Context, userID string) ([]Account, error)

Accounts lists the IdP accounts linked to userID, oldest first.

func (*Plugin) AssignByVerifiedDomain

func (p *Plugin) AssignByVerifiedDomain(ctx context.Context, userID, email string, emailVerified bool) (Assignment, error)

AssignByVerifiedDomain adds a user who signed in some other way (social OAuth, a password) to the organization whose domain-verified provider covers their verified email: exactly one such organization, or nothing. An unverified address never assigns, since anyone can type one.

func (*Plugin) AssignOAuth

func (p *Plugin) AssignOAuth(ctx context.Context, ev oauth.ResolvedEvent) (Assignment, error)

AssignOAuth is the assignment of one plugin/oauth sign-in: a link flow or a provider-initiated sign-in (nothing ties it to a person of this app) is AssignIneligibleSource; anything else AssignByVerifiedDomain.

func (*Plugin) Callback

func (p *Plugin) Callback(ctx context.Context, in CallbackRequest) (CallbackResult, error)

Callback is GET /callback (callbackSSO, callbackSSOShared).

func (*Plugin) DeleteProvider

func (p *Plugin) DeleteProvider(ctx context.Context, providerID string) error

DeleteProvider is POST /delete-provider (deleteSSOProvider).

func (*Plugin) Discover

func (p *Plugin) Discover(ctx context.Context, issuer string, cfg *OIDCConfig) error

Discover fills cfg's empty endpoints and token authentication from issuer's discovery document; values already set win. The document must name issuer exactly (trailing slash aside) and list the authorization, token and JWKS endpoints; relative endpoint paths are resolved against the issuer. With SkipDiscovery nothing is fetched and a missing endpoint is DiscoveryIncomplete.

func (*Plugin) DomainToken

func (p *Plugin) DomainToken(ctx context.Context, pr *Provider) (string, error)

DomainToken returns the provider's pending verification token, issuing a new one when none is pending or it expired (better-auth reuses an active token and gives each one week, DefaultDomainTokenTTL). The token lives on the provider row: it is public once published, and the row is the one place a verify must find it.

func (*Plugin) FetchDiscoveryDocument

func (p *Plugin) FetchDiscoveryDocument(ctx context.Context, u string) (DiscoveryDocument, error)

fetchDiscovery GETs and decodes the document.

func (*Plugin) GetProvider

func (p *Plugin) GetProvider(ctx context.Context, providerID string) (SanitizedProvider, error)

GetProvider is GET /get-provider (getSSOProvider).

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core, builds the guarded HTTP client and fills the default URLs.

func (*Plugin) InitiateSLO

func (p *Plugin) InitiateSLO(ctx context.Context, in LogoutRequest) (LogoutResult, error)

InitiateSLO is POST /saml2/logout (initiateSLO).

func (*Plugin) ListProviders

func (p *Plugin) ListProviders(ctx context.Context) (ProvidersResult, error)

ListProviders is GET /providers (listSSOProviders).

func (*Plugin) Name

func (p *Plugin) Name() string

Name is PluginName.

func (*Plugin) OAuthAssignment

func (p *Plugin) OAuthAssignment() oauth.ResolvedHook

OAuthAssignment is AssignByVerifiedDomain as a plugin/oauth hook: pass it to oauth.OnResolved and a social sign-in with a verified address in a verified domain joins that domain's organization. A link flow is not a sign-in and is left alone.

func (*Plugin) OperationHooks

func (p *Plugin) OperationHooks() []auth.OperationHook

OperationHooks forgets the SAML session record when the app session is signed out, under EnableSingleLogout.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists every operation; the two domain-verification ones only under WithDomainVerification.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) ProviderByID

func (p *Plugin) ProviderByID(ctx context.Context, providerID string) (Provider, error)

ProviderByID returns a provider by its provider id: a default provider first, then a row. Not found is ErrProviderNotFound.

func (*Plugin) ProviderForDomain

func (p *Plugin) ProviderForDomain(ctx context.Context, d string) (Provider, error)

ProviderForDomain returns the provider for an email domain: a default provider first, then a stored one whose domain is exactly d, then one whose domain list holds d or a parent of it (a provider for company.com serves eng.company.com). Under WithDomainVerification an unverified provider is passed over in the list scan, so a domain one tenant claimed without proof cannot capture another tenant's routing.

func (*Plugin) ProviderPaths

func (p *Plugin) ProviderPaths(mount string, next http.Handler) http.Handler

ProviderPaths wraps the app's auth handler (authhttp.Handler) for PerProviderPaths and better-auth's metadata route. mount is where the plugin's routes live ("/auth/sso" for authhttp.Prefix("/auth")):

  • GET <mount>/callback/<id> → GET <mount>/callback?providerId=<id>
  • POST <mount>/saml2/sp/acs/<id> → POST <mount>/saml2/sp/acs with providerId=<id>
  • GET|POST <mount>/saml2/sp/slo/<id> → the shared SLO route with providerId=<id>
  • GET <mount>/saml2/sp/metadata?providerId=<id> → the SP metadata as application/xml, as better-auth serves it

Every other request passes through untouched.

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds the public flow starts, the ACS, registration and DNS verification, each of which costs a row, a network call or a signature check.

func (*Plugin) RegisterProvider

func (p *Plugin) RegisterProvider(ctx context.Context, in RegisterRequest) (RegisterResult, error)

RegisterProvider is POST /register (registerSSOProvider).

func (*Plugin) RequestDomainVerification

func (p *Plugin) RequestDomainVerification(ctx context.Context, providerID string) (DomainTokenResult, error)

RequestDomainVerification is POST /request-domain-verification.

func (*Plugin) SLO

func (p *Plugin) SLO(ctx context.Context, in SLORequest) (LogoutResult, error)

SLO is the SLO endpoint (sloEndpoint), for the redirect binding.

func (*Plugin) SPMetadata

func (p *Plugin) SPMetadata(ctx context.Context, providerID string) (MetadataResult, error)

SPMetadata is GET /saml2/sp/metadata (spMetadata).

func (*Plugin) SetProviderFields

func (p *Plugin) SetProviderFields(ctx context.Context, providerID string, values map[string]any) error

SetProviderFields sets additional fields from Go, NoInput ones included; a field not declared is refused.

func (*Plugin) SignIn

func (p *Plugin) SignIn(ctx context.Context, in SignInRequest) (SignInResult, error)

SignIn is POST /sign-in (signInSSO).

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports that SSO is a way in.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares both tables, so auth.New refuses a second owner.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates both tables for engine. provider_id is UNIQUE; (provider_id, subject) is UNIQUE: one IdP account belongs to one user.

func (*Plugin) UpdateProvider

func (p *Plugin) UpdateProvider(ctx context.Context, in UpdateRequest) (SanitizedProvider, error)

UpdateProvider is POST /update-provider (updateSSOProvider).

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares that auth_sso_account rows belong to their user, so DeleteUser removes them. Providers are not listed: an organization's provider must survive the admin who registered it.

func (*Plugin) Validate

func (p *Plugin) Validate(c *auth.Config) error

Validate checks the default providers, the callback URLs, the SAML options and the domain-verification prefix, and derives the key that seals provider secrets.

func (*Plugin) VerifyDomain

func (p *Plugin) VerifyDomain(ctx context.Context, providerID string) error

VerifyDomain is POST /verify-domain.

type PostForm

type PostForm struct {
	Action      string `json:"action"`
	SAMLRequest string `json:"SAMLRequest"`
	RelayState  string `json:"RelayState"`
}

PostForm is an HTTP-POST binding message: the browser posts SAMLRequest and RelayState to Action (an auto-submitting form).

type PrivateKeyRequest

type PrivateKeyRequest struct {
	ProviderID string
	KeyID      string
	Issuer     string
	Algorithm  string
}

PrivateKeyRequest names the key private_key_jwt needs.

type PrivateKeyResolver

type PrivateKeyResolver func(ctx context.Context, r PrivateKeyRequest) (key crypto.Signer, kid string, err error)

PrivateKeyResolver returns the signer and key id for a provider's private_key_jwt client assertion (ECDSA P-256 or RSA, as oauth.ClientAssertion accepts).

type Protocol

type Protocol string

Protocol is how a provider signs people in.

const (
	// ProtocolOIDC is OpenID Connect, authorization code with PKCE.
	ProtocolOIDC Protocol = "oidc"
	// ProtocolSAML is SAML 2.0 Web Browser SSO, this app as the SP.
	ProtocolSAML Protocol = "saml"
)

type Provider

type Provider struct {
	// ID is the row id; "" for a default provider.
	ID string `json:"-"`
	// ProviderID is the app-chosen permanent name, unique across the app.
	ProviderID     string      `json:"providerId"`
	Issuer         string      `json:"issuer"`
	Domain         string      `json:"domain"`
	OrganizationID string      `json:"organizationId,omitempty"`
	UserID         string      `json:"userId,omitempty"`
	OIDC           *OIDCConfig `json:"oidcConfig,omitempty"`
	SAML           *SAMLConfig `json:"samlConfig,omitempty"`
	// DomainVerified is set once the domain's DNS record was found
	// (WithDomainVerification); a default provider counts as verified.
	DomainVerified bool      `json:"domainVerified"`
	CreatedAt      time.Time `json:"createdAt"`
	UpdatedAt      time.Time `json:"updatedAt"`
	// DomainToken is the pending domain-verification token and
	// DomainTokenExpiresAt its expiry; "" and nil when none is pending.
	DomainToken string `json:"-"`
	// Fields are the WithProviderFields values.
	Fields               map[string]any `json:"additionalFields,omitempty"`
	DomainTokenExpiresAt *time.Time     `json:"-"`
	// Default marks a WithDefaultProviders entry: code, not a row.
	Default bool `json:"-"`
}

Provider is one registered identity provider: a row of auth_sso_provider, or a WithDefaultProviders entry. A provider may offer OIDC, SAML or both.

Multi-shape fields: OIDC and SAML are each nil when the provider does not offer that protocol; at least one is set. Domain is one domain or a comma-separated list. UserID is who registered it ("" for a default provider); OrganizationID is "" for a provider not tied to one.

func (Provider) Domains

func (p Provider) Domains() []string

Domains returns the provider's domains, lower-cased and trimmed, empty entries dropped.

func (Provider) Protocols

func (p Provider) Protocols() []Protocol

Protocols lists what the provider offers, OIDC first.

func (Provider) Reference

func (p Provider) Reference() string

Reference is the provider's fingerprint: the SHA-256 of its authentication boundary (issuer, endpoints, client id, SAML entity ids, certificates), secrets left out. A flow carries it from start to callback and is refused when it changed in between, so an admin who repoints a provider mid-flow cannot have a response from the old IdP accepted under the new configuration.

type ProviderField

type ProviderField struct {
	Name string
	// Required refuses a registration without it.
	Required bool
	// NoInput refuses it in a register or update body (better-auth's
	// input: false): only the app's Go code sets it, with SetProviderFields.
	NoInput bool
}

ProviderField is an app-defined field on every provider (better-auth's schema.ssoProvider.additionalFields), stored as JSON beside the provider.

type ProviderRequest

type ProviderRequest struct {
	ProviderID string `json:"providerId"`
}

ProviderRequest names one provider.

type ProvidersResult

type ProvidersResult struct {
	Providers []SanitizedProvider `json:"providers"`
}

ProvidersResult lists the providers the caller may manage.

type ProvisionEvent

type ProvisionEvent struct {
	User     auth.User
	Info     UserInfo
	Provider Provider
	Protocol Protocol
	// Created is true when this sign-in created the user.
	Created bool
}

ProvisionEvent is a completed SSO sign-in, handed to WithProvisionUser.

type ProvisionFunc

type ProvisionFunc func(ctx context.Context, c *auth.Call, ev ProvisionEvent) error

ProvisionFunc is the WithProvisionUser hook.

type RawFacts

type RawFacts struct {
	Assertions          int
	SignatureAlgorithms []string
	DigestAlgorithms    []string
	// KeyEncryption and DataEncryption are the EncryptionMethod algorithms
	// of an EncryptedKey and of the EncryptedData around it.
	KeyEncryption  []string
	DataEncryption []string
}

RawFacts are what InspectResponse reads from the undecoded XML, before any signature check: how many assertions it holds anywhere in the tree, and every signature and digest algorithm it names.

func InspectResponse

func InspectResponse(encoded string, maxSize int) ([]byte, RawFacts, error)

InspectResponse decodes a base64 SAMLResponse and scans its XML. Counting assertions over the whole tree, not only where a parser expects them, is the defence in depth against signature wrapping: a wrapped response carries a second Assertion (or EncryptedAssertion) somewhere, and is refused here whatever the signature engine makes of it.

type RegisterRequest

type RegisterRequest struct {
	ProviderID     string      `json:"providerId"`
	Issuer         string      `json:"issuer"`
	Domain         string      `json:"domain"`
	OrganizationID string      `json:"organizationId,omitempty"`
	OIDCConfig     *OIDCConfig `json:"oidcConfig,omitempty"`
	SAMLConfig     *SAMLConfig `json:"samlConfig,omitempty"`
	// OverrideUserInfo sets OIDCConfig.OverrideUserInfo.
	OverrideUserInfo bool `json:"overrideUserInfo,omitempty"`
	// AdditionalFields are WithProviderFields values.
	AdditionalFields map[string]any `json:"additionalFields,omitempty"`
}

RegisterRequest registers a provider for the signed-in user, or for an organization the user owns or administers.

type RegisterResult

type RegisterResult struct {
	SanitizedProvider
	DomainVerificationToken string `json:"domainVerificationToken,omitempty"`
}

RegisterResult is the new provider, plus the domain-verification token when WithDomainVerification is on.

type ResolveAction

type ResolveAction string

ResolveAction is a WithResolveUser decision.

const (
	// ResolveContinue applies the built-in rules.
	ResolveContinue ResolveAction = "continue"
	// ResolveLink links the IdP account to Decision.UserID.
	ResolveLink ResolveAction = "link"
)

type ResolveInput

type ResolveInput struct {
	Protocol Protocol
	Provider Provider
	// Issuer and Subject are the account key: the ID token's iss and sub,
	// or the IdP entity id and NameID.
	Issuer  string
	Subject string
	Info    UserInfo
}

ResolveInput is a first sign-in through a provider: no account is linked to (provider, subject) yet.

type ResolveUserFunc

type ResolveUserFunc func(ctx context.Context, c *auth.Call, in ResolveInput) (Decision, error)

ResolveUserFunc is the WithResolveUser hook. To refuse the sign-in, return an error: an *auth.Error is answered as it is, anything else as ErrUserRejected with the reason logged.

type SAMLAssertion

type SAMLAssertion struct {
	ID           string
	Issuer       string
	Signed       bool
	NameID       string
	NameIDFormat string
	// HasConditions is false when the assertion has no Conditions element.
	HasConditions bool
	NotBefore     time.Time
	NotOnOrAfter  time.Time
	// Audiences holds each AudienceRestriction's audiences.
	Audiences     [][]string
	Confirmations []SubjectConfirmation
	SessionIndex  string
	Attributes    map[string][]string
}

SAMLAssertion is a verified assertion.

func CheckAssertion

func CheckAssertion(r *SAMLResponse, req CheckRequest) (SAMLAssertion, error)

CheckAssertion applies every rule that decides whether a verified response signs anyone in, and returns its one assertion:

  • exactly one assertion, signed itself when WantAssertionsSigned and otherwise covered by a signed Response;
  • the Response's and the Assertion's Issuer are the IdP entity id;
  • the Response's Destination, when present, is the ACS URL;
  • InResponseTo, on the Response and the bearer confirmation, is the request this browser started, or absent for an IdP-initiated one;
  • an AudienceRestriction exists and every one names the audience;
  • a bearer SubjectConfirmation whose Recipient is the ACS URL and whose NotOnOrAfter has not passed;
  • Conditions NotBefore has come and NotOnOrAfter has not passed, each with ClockSkew, and both present under RequireTimestamps.

type SAMLCode

type SAMLCode string

SAMLCode names why a response was refused; each is logged, the caller sees ErrSAMLInvalid (or ErrSAMLReplay, ErrSAMLUnsolicited, ErrSAMLTooLarge).

const (
	SAMLInvalidEncoding        SAMLCode = "SAML_INVALID_ENCODING"
	SAMLInvalidXML             SAMLCode = "SAML_INVALID_XML"
	SAMLNoAssertion            SAMLCode = "SAML_NO_ASSERTION"
	SAMLMultipleAssertions     SAMLCode = "SAML_MULTIPLE_ASSERTIONS"
	SAMLSignatureInvalid       SAMLCode = "SAML_SIGNATURE_INVALID"
	SAMLAssertionSigRequired   SAMLCode = "SAML_ASSERTION_SIGNATURE_REQUIRED"
	SAMLIssuerMismatch         SAMLCode = "SAML_ISSUER_MISMATCH"
	SAMLAudienceMissing        SAMLCode = "SAML_AUDIENCE_MISSING"
	SAMLAudienceMismatch       SAMLCode = "SAML_AUDIENCE_MISMATCH"
	SAMLBearerMissing          SAMLCode = "SAML_BEARER_CONFIRMATION_MISSING"
	SAMLRecipientMismatch      SAMLCode = "SAML_RECIPIENT_MISMATCH"
	SAMLDestinationMismatch    SAMLCode = "SAML_DESTINATION_MISMATCH"
	SAMLNotYetValid            SAMLCode = "SAML_NOT_YET_VALID"
	SAMLExpired                SAMLCode = "SAML_EXPIRED"
	SAMLMissingTimestamps      SAMLCode = "SAML_MISSING_TIMESTAMPS"
	SAMLInResponseToMismatch   SAMLCode = "SAML_IN_RESPONSE_TO_MISMATCH"
	SAMLUnknownRequest         SAMLCode = "SAML_UNKNOWN_REQUEST"
	SAMLReplay                 SAMLCode = "SAML_REPLAY"
	SAMLUnsolicited            SAMLCode = "SAML_UNSOLICITED"
	SAMLDeprecatedAlgorithm    SAMLCode = "SAML_DEPRECATED_ALGORITHM"
	SAMLAlgorithmNotAllowed    SAMLCode = "SAML_ALGORITHM_NOT_ALLOWED"
	SAMLUnknownAlgorithm       SAMLCode = "SAML_UNKNOWN_ALGORITHM"
	SAMLTooLarge               SAMLCode = "SAML_TOO_LARGE"
	SAMLMissingSubjectOrEmail  SAMLCode = "SAML_MISSING_IDENTITY"
	SAMLCertificateUnavailable SAMLCode = "CERT_SOURCE_MISSING"
)

type SAMLConfig

type SAMLConfig struct {
	// EntryPoint is the IdP's single sign-on URL (HTTP-Redirect binding),
	// used when IdPMetadata names no SingleSignOnService.
	EntryPoint string `json:"entryPoint,omitempty"`
	// Cert is the IdP's signing certificate, PEM; several PEM blocks are
	// several certificates, any of which may sign (rotation).
	// IdPMetadata.Cert wins when both are set.
	Cert string `json:"cert,omitempty"`
	// Audience is the AudienceRestriction every assertion must carry;
	// empty is the SP entity id.
	Audience string `json:"audience,omitempty"`
	// CallbackURL is where an IdP-initiated sign-in lands; empty falls
	// back to SAMLOptions.IdPInitiatedCallbackURL, then the site root.
	CallbackURL string `json:"callbackUrl,omitempty"`
	// IdPInitiatedCallbackURL overrides CallbackURL for IdP-initiated
	// sign-ins only.
	IdPInitiatedCallbackURL string      `json:"idpInitiatedCallbackUrl,omitempty"`
	IdPMetadata             IdPMetadata `json:"idpMetadata,omitempty"`
	SPMetadata              SPMetadata  `json:"spMetadata,omitempty"`
	// WantAssertionsSigned requires the Assertion's own signature; a
	// signed Response around an unsigned Assertion is then refused.
	WantAssertionsSigned bool `json:"wantAssertionsSigned,omitempty"`
	// AuthnRequestsSigned signs every AuthnRequest with PrivateKey.
	AuthnRequestsSigned bool `json:"authnRequestsSigned,omitempty"`
	// SignatureAlgorithm signs AuthnRequests: a URI or a short form
	// (sha256, sha384, sha512); empty is RSA-SHA256.
	SignatureAlgorithm string `json:"signatureAlgorithm,omitempty"`
	// DigestAlgorithm is advertised for AuthnRequest signatures.
	DigestAlgorithm string `json:"digestAlgorithm,omitempty"`
	// IdentifierFormat is the NameID format requested and advertised.
	IdentifierFormat string `json:"identifierFormat,omitempty"`
	// PrivateKey and SPCertificate are the SP's key pair, PEM: they sign
	// AuthnRequests and decrypt an EncryptedAssertion. The key is stored
	// sealed and never returned by a read.
	PrivateKey    string `json:"privateKey,omitempty"`
	SPCertificate string `json:"spCertificate,omitempty"`
	// DecryptionKey is a separate decryption key, PEM, when the IdP
	// encrypts to another key than the signing one; sealed like PrivateKey.
	DecryptionKey string `json:"decryptionKey,omitempty"`
	// DecryptionCertificate goes with DecryptionKey (PEM); it is advertised
	// as the encryption key in SP metadata.
	DecryptionCertificate string      `json:"decryptionCertificate,omitempty"`
	Mapping               SAMLMapping `json:"mapping,omitempty"`
	// AllowIdPInitiated accepts an unsolicited response for this provider
	// even when SAMLOptions.AllowIdPInitiated is off.
	AllowIdPInitiated bool `json:"allowIdpInitiated,omitempty"`
}

SAMLConfig is a SAML 2.0 provider's settings: this app is the service provider (SP), the customer's IdP signs the assertions.

func (SAMLConfig) IdP

func (s SAMLConfig) IdP(maxMetadata int) (IdPSettings, error)

IdP returns the IdP's entity id, SSO URL and signing certificates, read from the metadata XML when set and from the written fields otherwise; fields written out win over metadata.

type SAMLEngine

type SAMLEngine interface {
	// AuthnRequestURL returns the IdP's SSO URL carrying an AuthnRequest
	// with requestID, HTTP-Redirect binding, signed when
	// sp.AuthnRequestsSigned.
	AuthnRequestURL(sp SPSettings, idp IdPSettings, requestID, relayState string) (string, error)
	// AuthnRequestPOST returns the form of an HTTP-POST AuthnRequest with
	// requestID, its enveloped signature made when sp.AuthnRequestsSigned.
	AuthnRequestPOST(sp SPSettings, idp IdPSettings, requestID, relayState string) (PostForm, error)
	// VerifyResponse decodes a base64 SAMLResponse, verifies its signature
	// (on the Response, or on each Assertion) against idp.Certs, decrypts an
	// EncryptedAssertion with sp.DecryptionKey, and returns only verified
	// content. Any failure is an error.
	VerifyResponse(sp SPSettings, idp IdPSettings, samlResponse string, now time.Time) (*SAMLResponse, error)
}

SAMLEngine is the XML-signature work: building an AuthnRequest and verifying a response's signature. It returns what the signature covers; every rule about whether that content signs anyone in is checked by the plugin (CheckAssertion), so an engine swap cannot drop one.

type SAMLError

type SAMLError struct {
	Code SAMLCode
	Err  error
}

SAMLError is a refused response: the code, and the cause for logs.

func (*SAMLError) Error

func (e *SAMLError) Error() string

func (*SAMLError) Unwrap

func (e *SAMLError) Unwrap() []error

Unwrap returns the caller's answer, so the transport answers it.

type SAMLMapping

type SAMLMapping struct {
	// ID is the attribute taken as the subject instead of the NameID.
	ID string `json:"id,omitempty"`
	// Email defaults to "email", then the NameID when it is an address.
	Email string `json:"email,omitempty"`
	// EmailVerified is read only under TrustEmailVerified.
	EmailVerified string `json:"emailVerified,omitempty"`
	// Name defaults to "displayName", used when first and last are absent.
	Name string `json:"name,omitempty"`
	// FirstName defaults to "givenName", LastName to "surname".
	FirstName   string            `json:"firstName,omitempty"`
	LastName    string            `json:"lastName,omitempty"`
	ExtraFields map[string]string `json:"extraFields,omitempty"`
}

SAMLMapping names the attributes read for each profile field.

type SAMLOptions

type SAMLOptions struct {
	// AllowIdPInitiated accepts unsolicited responses (an IdP dashboard
	// tile). Off by default: an unsolicited response has no request ID to
	// bind it to this browser, so only replay protection and the time
	// window stand between a stolen response and a session.
	AllowIdPInitiated bool
	// RequestTTL bounds an AuthnRequest; DefaultSAMLRequestTTL when zero.
	RequestTTL time.Duration
	// ClockSkew is tolerated on every timestamp; DefaultClockSkew when zero.
	ClockSkew time.Duration
	// RequireTimestamps refuses an assertion without NotBefore and
	// NotOnOrAfter conditions.
	RequireTimestamps bool
	// OnDeprecated decides SHA-1 signatures and digests; empty is warn.
	OnDeprecated DeprecatedPolicy
	// AllowedSignatureAlgorithms and AllowedDigestAlgorithms, when set,
	// refuse every other algorithm.
	AllowedSignatureAlgorithms []string
	AllowedDigestAlgorithms    []string
	// AllowedKeyEncryptionAlgorithms and AllowedDataEncryptionAlgorithms,
	// when set, refuse an EncryptedAssertion using any other.
	AllowedKeyEncryptionAlgorithms  []string
	AllowedDataEncryptionAlgorithms []string
	// MaxResponseSize and MaxMetadataSize bound the decoded XML.
	MaxResponseSize int
	MaxMetadataSize int
	// IdPInitiatedCallbackURL is where an IdP-initiated sign-in lands when
	// its provider names none.
	IdPInitiatedCallbackURL string
	// EnableSingleLogout turns on SAML single logout: the logout route, the
	// SLO endpoint, SLO in SP metadata, and the session records.
	EnableSingleLogout bool
	// LogoutRequestTTL bounds an SP-initiated LogoutRequest;
	// DefaultLogoutRequestTTL when zero.
	LogoutRequestTTL time.Duration
	// AllowUnsignedLogoutRequests and AllowUnsignedLogoutResponses accept
	// an IdP logout message without a signature. Off by default (better-auth
	// defaults to accepting): an unsigned LogoutRequest lets anyone who
	// knows a NameID and SessionIndex end that person's session. A message
	// that carries a signature is always checked.
	AllowUnsignedLogoutRequests  bool
	AllowUnsignedLogoutResponses bool
	// SkipInResponseTo accepts an SP-initiated response without matching it
	// to its AuthnRequest (better-auth's enableInResponseToValidation:
	// false), for an IdP that drops InResponseTo. Replay protection and the
	// time window still apply; a warning is logged at startup.
	SkipInResponseTo bool
}

SAMLOptions are the plugin-wide SAML settings.

func (SAMLOptions) CheckAlgorithms

func (o SAMLOptions) CheckAlgorithms(f RawFacts, warn func(alg string)) error

CheckAlgorithms applies the algorithm policy to what a response names: an unknown algorithm is refused, an allow-list refuses everything else, and a SHA-1 one follows OnDeprecated (warn logs and continues).

type SAMLResponse

type SAMLResponse struct {
	ID           string
	InResponseTo string
	Destination  string
	Issuer       string
	Signed       bool
	Assertions   []SAMLAssertion
}

SAMLResponse is a verified response.

type SLORequest

type SLORequest struct {
	SAMLRequest  string `json:"SAMLRequest,omitempty"`
	SAMLResponse string `json:"SAMLResponse,omitempty"`
	RelayState   string `json:"RelayState,omitempty"`
	SigAlg       string `json:"SigAlg,omitempty"`
	Signature    string `json:"Signature,omitempty"`
	// ProviderID names the provider when the IdP's SLO URL carries it
	// (?providerId=); otherwise the message's issuer or InResponseTo does.
	ProviderID string `json:"providerId,omitempty"`
}

SLORequest is what the IdP sends to the SLO endpoint: a LogoutRequest (IdP-initiated) or a LogoutResponse (to an SP-initiated logout), by the redirect binding (query) or the POST binding (form).

type SPMetadata

type SPMetadata struct {
	// EntityID is the SP entity id; empty is the custom metadata's, then
	// the provider's Issuer.
	EntityID string `json:"entityID,omitempty"`
	// Metadata is custom SP metadata XML, served as it is instead of the
	// generated document. Its entity id, NameID format and
	// WantAssertionsSigned are read from it; it must list this app's ACS
	// URL with the POST binding, and may not advertise unsigned assertions
	// when WantAssertionsSigned is on (ErrSPMetadataMismatch).
	Metadata string `json:"metadata,omitempty"`
	// Binding is how AuthnRequests travel: "redirect" (the default, the
	// query signed) or "post" (an auto-submitting form, the XML signed,
	// which is where DigestAlgorithm applies).
	Binding string `json:"binding,omitempty"`
}

SPMetadata is this app's side.

type SPPolicy

type SPPolicy struct {
	EntityID             string
	WantAssertionsSigned bool
	NameIDFormat         string
	// ACSURLs are the HTTP-POST assertion consumer services it lists.
	ACSURLs []string
}

SPPolicy is what custom SP metadata says.

func DeriveSPPolicy

func DeriveSPPolicy(md string, maxSize int) (SPPolicy, error)

DeriveSPPolicy reads custom SP metadata (better-auth's deriveSAMLServiceProviderPolicy); empty metadata is the zero policy.

type SPSettings

type SPSettings struct {
	EntityID             string
	ACSURL               string
	Audience             string
	NameIDFormat         string
	AuthnRequestsSigned  bool
	SignatureAlgorithm   string
	SigningKey           crypto.Signer
	Certificate          *x509.Certificate
	DecryptionKey        crypto.Signer
	DecryptionCert       *x509.Certificate
	WantAssertionsSigned bool
	MaxResponseSize      int
	// SLOURL is the SP's single logout endpoint; "" when single logout is
	// off, and then left out of the metadata.
	SLOURL string
	// CustomMetadata is SPMetadata.Metadata, served instead of the
	// generated document.
	CustomMetadata string
	// POSTBinding sends AuthnRequests by HTTP-POST (SPMetadata.Binding).
	POSTBinding bool
	// Now is the core's clock (auth.WithClock) when an AuthnRequest is
	// built, so its IssueInstant follows the injected clock.
	Now time.Time
}

SPSettings are this app's SAML settings for one provider.

type SanitizedOIDC

type SanitizedOIDC struct {
	DiscoveryEndpoint           string          `json:"discoveryEndpoint,omitempty"`
	ClientIDLastFour            string          `json:"clientIdLastFour"`
	PKCE                        bool            `json:"pkce"`
	AuthorizationEndpoint       string          `json:"authorizationEndpoint,omitempty"`
	TokenEndpoint               string          `json:"tokenEndpoint,omitempty"`
	UserInfoEndpoint            string          `json:"userInfoEndpoint,omitempty"`
	JWKSEndpoint                string          `json:"jwksEndpoint,omitempty"`
	Scopes                      []string        `json:"scopes,omitempty"`
	TokenEndpointAuthentication oauth.TokenAuth `json:"tokenEndpointAuthentication,omitempty"`
}

SanitizedOIDC is an OIDCConfig without its secret.

type SanitizedProvider

type SanitizedProvider struct {
	ProviderID     string         `json:"providerId"`
	Type           Protocol       `json:"type"`
	Protocols      []Protocol     `json:"protocols"`
	Issuer         string         `json:"issuer"`
	Domain         string         `json:"domain"`
	OrganizationID string         `json:"organizationId,omitempty"`
	DomainVerified bool           `json:"domainVerified"`
	OIDC           *SanitizedOIDC `json:"oidcConfig,omitempty"`
	SAML           *SanitizedSAML `json:"samlConfig,omitempty"`
	// AdditionalFields are the WithProviderFields values.
	AdditionalFields map[string]any `json:"additionalFields,omitempty"`
	// RedirectURI is the OIDC redirect URI to register at the IdP.
	RedirectURI string `json:"redirectURI,omitempty"`
	// ACSURL and SPMetadataURL are what to enter at a SAML IdP.
	ACSURL        string `json:"acsURL,omitempty"`
	SPMetadataURL string `json:"spMetadataURL,omitempty"`
}

SanitizedProvider is what a read returns: no secret, the client id cut to its last four characters.

type SanitizedSAML

type SanitizedSAML struct {
	EntryPoint           string `json:"entryPoint,omitempty"`
	CallbackURL          string `json:"callbackUrl,omitempty"`
	Audience             string `json:"audience,omitempty"`
	IdPEntityID          string `json:"idpEntityId,omitempty"`
	SPEntityID           string `json:"spEntityId,omitempty"`
	WantAssertionsSigned bool   `json:"wantAssertionsSigned"`
	AuthnRequestsSigned  bool   `json:"authnRequestsSigned"`
	IdentifierFormat     string `json:"identifierFormat,omitempty"`
	// CertificatesCount is how many IdP signing certificates are on file.
	CertificatesCount int `json:"certificatesCount"`
}

SanitizedSAML is a SAMLConfig without its keys.

type SignInRequest

type SignInRequest struct {
	Email            string `json:"email,omitempty"`
	OrganizationSlug string `json:"organizationSlug,omitempty"`
	ProviderID       string `json:"providerId,omitempty"`
	Domain           string `json:"domain,omitempty"`
	// CallbackURL is where to land after sign-in: a path on this site or a
	// trusted origin; "" is the site root.
	CallbackURL string `json:"callbackURL,omitempty"`
	// ErrorCallbackURL is where a failed sign-in lands, with the error code.
	ErrorCallbackURL string `json:"errorCallbackURL,omitempty"`
	// NewUserCallbackURL replaces CallbackURL when the sign-in created the
	// user.
	NewUserCallbackURL string `json:"newUserCallbackURL,omitempty"`
	// Scopes replace the provider's scopes for this sign-in (OIDC).
	Scopes []string `json:"scopes,omitempty"`
	// LoginHint is sent as login_hint (OIDC); Email is sent when it is "".
	LoginHint string `json:"loginHint,omitempty"`
	// AdditionalParams are added to the authorization URL (OIDC); a
	// parameter the flow sets itself is ErrBadParams.
	AdditionalParams map[string]string `json:"additionalParams,omitempty"`
	// RequestSignUp allows creating a user under DisableImplicitSignUp.
	RequestSignUp bool `json:"requestSignUp,omitempty"`
	// ProviderType picks the protocol of a provider that offers both;
	// empty is OIDC when offered.
	ProviderType Protocol `json:"providerType,omitempty"`
}

SignInRequest starts an SSO sign-in. One of Email, Domain, OrganizationSlug or ProviderID names the provider; ProviderID wins, then OrganizationSlug, then Domain, then the domain of Email.

type SignInResult

type SignInResult struct {
	URL      string `json:"url"`
	Redirect bool   `json:"redirect"`
	// Form is set, and Redirect false, for a SAML provider on the POST
	// binding: post its fields to Form.Action from an auto-submitting form.
	Form *PostForm `json:"form,omitempty"`
}

SignInResult is where to send the browser.

type SubjectConfirmation

type SubjectConfirmation struct {
	Method       string
	Recipient    string
	InResponseTo string
	NotOnOrAfter time.Time
	NotBefore    time.Time
}

SubjectConfirmation is a SubjectConfirmation and its data.

type TXTResolver

type TXTResolver interface {
	LookupTXT(ctx context.Context, name string) ([]string, error)
}

TXTResolver looks up DNS TXT records; *net.Resolver implements it.

type UpdateRequest

type UpdateRequest struct {
	ProviderID string      `json:"providerId"`
	Issuer     *string     `json:"issuer,omitempty"`
	Domain     *string     `json:"domain,omitempty"`
	OIDCConfig *OIDCConfig `json:"oidcConfig,omitempty"`
	SAMLConfig *SAMLConfig `json:"samlConfig,omitempty"`
	// AdditionalFields are merged into the provider's; a NoInput field is
	// ErrFieldNotAllowed.
	AdditionalFields map[string]any `json:"additionalFields,omitempty"`
}

UpdateRequest changes a provider; a nil field is left as it is. A domain change resets DomainVerified. An empty OIDC client secret keeps the stored one.

type UserInfo

type UserInfo struct {
	Subject       string
	Email         string
	EmailVerified bool
	Name          string
	Image         string
	// Extra holds the mapping's ExtraFields, keyed by the app's names.
	// OIDC values are JSON-decoded claims; SAML values are []string.
	Extra map[string]any
	// Claims is the verified ID token's payload merged with userinfo, for
	// OIDC; nil for SAML.
	Claims map[string]any
	// Attributes are the signed assertion's attributes, every value kept,
	// for SAML; nil for OIDC.
	Attributes map[string][]string
	// SessionIndex is the SAML AuthnStatement's SessionIndex; NameID and
	// NameIDFormat are the assertion's subject, which single logout names.
	SessionIndex string
	NameID       string
	NameIDFormat string
}

UserInfo is what an IdP said about the person, after the provider's mapping. Subject is the account key: the OIDC sub (or the mapped id claim) or the SAML NameID, compared exactly.

func MapAttributes

func MapAttributes(m SAMLMapping, a SAMLAssertion) UserInfo

MapAttributes turns a verified assertion into UserInfo: the subject is the NameID (or the mapping's id attribute), the email the mapped attribute or an address-shaped NameID.

func MapClaims

func MapClaims(m OIDCMapping, claims map[string]any) UserInfo

MapClaims applies an OIDCMapping to claims. email_verified counts only as the boolean true or the string "true".

Directories

Path Synopsis
Package ssotest runs a fake OpenID Connect provider and a fake SAML identity provider for tests of an app's SSO wiring: the OIDC side serves discovery, JWKS, a token endpoint that checks PKCE and the client, and userinfo; the SAML side signs responses with a fresh certificate.
Package ssotest runs a fake OpenID Connect provider and a fake SAML identity provider for tests of an app's SSO wiring: the OIDC side serves discovery, JWKS, a token endpoint that checks PKCE and the client, and userinfo; the SAML side signs responses with a fresh certificate.

Jump to

Keyboard shortcuts

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