Documentation
¶
Overview ¶
Package relyingparty lets an application federate its login to SystemAuth as an OpenID Connect relying party, so the application never implements GitHub/Google login itself.
It provides:
- Client: discovery, authorization URL with state + nonce + PKCE, code exchange, ID-token verification against the SystemAuth JWKS (issuer, audience, nonce, expiry), UserInfo, refresh and revocation.
- ResolvePrincipal: find-or-create of the app-local principal keyed by the OIDC sub (stored as sf_principal_id), with a verified-email linking fallback, behind the storage-agnostic PrincipalStore interface.
- BFF: an http.Handler serving the /bff/* cookie-session surface the shared frontend expects (session, current user, login, callback, logout), with a server-side SessionStore.
- BearerMiddleware: authentication for programmatic clients using SystemAuth-issued JWT access tokens (verified against the JWKS) or API keys, placing an AuthenticatedPrincipal in the request context.
Index ¶
- Constants
- Variables
- func BackchannelHandler(sink AccessChangeSink, verifier LogoutTokenVerifier, opts ...BackchannelOption) http.Handler
- func BearerMiddleware(cfg BearerConfig) func(http.Handler) http.Handler
- func IDPHint(provider string) oauth2.AuthCodeOption
- func OrgsTruncated(claims map[string]any) bool
- func Replay(ctx context.Context, src AccessChangeSource, cache *RevocationCache) error
- func ReplayWithRetry(ctx context.Context, src AccessChangeSource, cache *RevocationCache, ...) error
- func ToRequestPrincipal(p *AuthenticatedPrincipal) *requestprincipal.RequestPrincipal
- func WithPrincipal(ctx context.Context, p *AuthenticatedPrincipal) context.Context
- type AccessChange
- type AccessChangePoller
- type AccessChangeSink
- type AccessChangeSource
- type AccessTokenClaims
- type AccessTokenVerifier
- type AuthError
- type AuthMethod
- type AuthenticatedPrincipal
- type BFF
- func (b *BFF) AccessToken(r *http.Request) (string, error)
- func (b *BFF) BackchannelLogoutHandler() http.Handler
- func (b *BFF) CurrentSession(r *http.Request) (*Session, *Principal, []Membership, error)
- func (b *BFF) RequireSession(next http.Handler) http.Handler
- func (b *BFF) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (b *BFF) SignOut(w http.ResponseWriter, r *http.Request) error
- type BFFConfig
- type BackchannelOption
- type BearerConfig
- type CacheOption
- type Client
- func (c *Client) AccessChangesSource(token func(ctx context.Context) (string, error)) AccessChangeSource
- func (c *Client) AuthCodeURL(state, nonce, verifier string, opts ...oauth2.AuthCodeOption) string
- func (c *Client) ClientCredentialsToken(clientID, clientSecret string) func(ctx context.Context) (string, error)
- func (c *Client) Config() Config
- func (c *Client) DPoPThumbprint() string
- func (c *Client) Exchange(ctx context.Context, code, verifier string) (*oauth2.Token, error)
- func (c *Client) Metadata() Metadata
- func (c *Client) Refresh(ctx context.Context, refreshToken string) (*oauth2.Token, error)
- func (c *Client) ResolveOrganization(ctx context.Context, accessToken, slug string) (m *Membership, alias bool, err error)
- func (c *Client) Revoke(ctx context.Context, token, hint string) (err error)
- func (c *Client) Transport(base http.RoundTripper) http.RoundTripper
- func (c *Client) UserInfo(ctx context.Context, accessToken string) (info *UserInfo, err error)
- func (c *Client) VerifyAccessToken(ctx context.Context, raw string) (*AccessTokenClaims, error)
- func (c *Client) VerifyIDToken(ctx context.Context, raw, nonce string) (*IDTokenClaims, error)
- func (c *Client) VerifyLogoutToken(ctx context.Context, raw string) (*LogoutTokenClaims, error)
- type Config
- type DPoPConfig
- type DPoPReplayCache
- type IDTokenClaims
- type KeySet
- type LoginState
- type LoginStateStore
- type LogoutTokenClaims
- type LogoutTokenVerifier
- type Membership
- type MembershipLister
- type MembershipQuery
- type MembershipSource
- type MembershipSourceFunc
- type MemoryLoginStateStore
- type MemoryPrincipalStore
- func (s *MemoryPrincipalStore) AddMembership(principalID string, m Membership) error
- func (s *MemoryPrincipalStore) AddPrincipal(p Principal) (*Principal, error)
- func (s *MemoryPrincipalStore) CreatePrincipal(_ context.Context, id VerifiedIdentity) (*Principal, error)
- func (s *MemoryPrincipalStore) FindByEmail(_ context.Context, email string) (*Principal, error)
- func (s *MemoryPrincipalStore) FindBySubject(_ context.Context, subject string) (*Principal, error)
- func (s *MemoryPrincipalStore) GetPrincipal(_ context.Context, id string) (*Principal, error)
- func (s *MemoryPrincipalStore) LinkSubject(_ context.Context, principalID, subject string) error
- func (s *MemoryPrincipalStore) Memberships(_ context.Context, principalID string) ([]Membership, error)
- func (s *MemoryPrincipalStore) RecordLogin(_ context.Context, principalID string, id VerifiedIdentity, at time.Time) error
- func (s *MemoryPrincipalStore) SetActive(principalID string, active bool) error
- type MemorySessionStore
- func (s *MemorySessionStore) Create(_ context.Context, token string, session Session) error
- func (s *MemorySessionStore) Delete(_ context.Context, token string) error
- func (s *MemorySessionStore) DeleteBySID(_ context.Context, sid string) (int, error)
- func (s *MemorySessionStore) DeleteBySubject(_ context.Context, subject string) (int, error)
- func (s *MemorySessionStore) Get(_ context.Context, token string) (Session, error)
- func (s *MemorySessionStore) Update(_ context.Context, token string, session Session) error
- type Metadata
- type PollerOption
- type Principal
- type PrincipalStore
- type RequestAuthenticator
- type RevocationCache
- func (c *RevocationCache) Accept(principalID, orgID string, iat time.Time) bool
- func (c *RevocationCache) ChangedAt(principalID, orgID string) time.Time
- func (c *RevocationCache) ClearSuspect()
- func (c *RevocationCache) Len() int
- func (c *RevocationCache) Margin() time.Duration
- func (c *RevocationCache) MarkSuspect()
- func (c *RevocationCache) Record(ch AccessChange)
- func (c *RevocationCache) RecordChange(principalID, organizationID string, changedAt time.Time, reason string)
- func (c *RevocationCache) Suspect() bool
- func (c *RevocationCache) Sweep()
- func (c *RevocationCache) ValidFrom(principalID, orgID string) time.Time
- type Session
- type SessionStatus
- type SessionStore
- type SignOutResponse
- type SystemAuthSignOut
- type TokenConfirmation
- type User
- type UserInfo
- type UserMembership
- type VerifiedIdentity
Constants ¶
const ( // MinPollInterval and MaxPollInterval bound the polling interval. MinPollInterval = time.Second MaxPollInterval = 2 * time.Second )
Polling bounds (ADR-010 decision 4: every second or two).
const ( // EventBackchannelLogout is the OpenID Connect Back-Channel Logout 1.0 // event: end the sessions of the token's sub (or sid). EventBackchannelLogout = "http://schemas.openid.net/event/backchannel-logout" // EventAccessChange announces a reduction of the sub's access // (ADR-010). EventAccessChange = "urn:systemforge:event:access_change" )
Event identifiers in a logout token's "events" claim.
const ( // DefaultSkewMargin is the default clock-skew margin of a // RevocationCache. DefaultSkewMargin = 30 * time.Second // MinSkewMargin is the smallest margin a RevocationCache accepts; a // smaller configured value is raised to it. It is enough where clocks // are NTP-disciplined. MinSkewMargin = 15 * time.Second // DefaultMaxTokenLifetime is the longest access-token lifetime // SystemAuth issues. Entries are kept that long, plus the margin, // after the change: past that moment no token issued before the change // can still be valid at any verifier. DefaultMaxTokenLifetime = 15 * time.Minute )
Revocation timing (ADR-010).
const AccessChangesPath = "/access-changes"
AccessChangesPath is the path of SystemAuth's replay endpoint.
const AccessChangesScope = "access_changes:read"
AccessChangesScope is the scope a client-credentials token needs to read the replay endpoint.
const LogoutTokenType = "logout+jwt" //nolint:gosec // G101: a JOSE typ value, not a credential
LogoutTokenType is the JOSE typ header of a logout token.
const PrincipalTypeClaim = "principal_type"
PrincipalTypeClaim is the access token claim naming the principal's type; SystemAuth sets it to "service" for a service account.
const ServiceAccountClientID = "systemauth-jwt-bearer" //nolint:gosec // G101: a public client identifier, not a credential
ServiceAccountClientID is the client_id of the tokens SystemAuth issues to service accounts through the JWT-bearer grant. An API that accepts service accounts lists it in Config.AcceptedClientIDs.
const ( // SessionCookieName is the BFF session cookie. The __Host- prefix makes // browsers enforce Secure, Path=/ and no Domain attribute. SessionCookieName = "__Host-sf_rp_session" )
Variables ¶
var ( // ErrInvalidToken is returned when an ID token or access token fails // verification (signature, issuer, audience, nonce or expiry). ErrInvalidToken = errors.New("relyingparty: invalid token") // ErrPrincipalNotFound is returned by PrincipalStore lookups that find // nothing. ErrPrincipalNotFound = errors.New("relyingparty: principal not found") // ErrPrincipalInactive is returned when the resolved principal is // disabled. ErrPrincipalInactive = errors.New("relyingparty: principal is inactive") // ErrEmailNotVerified is returned when an unlinked identity has no // verified email: such an identity can neither link to nor create a // principal. ErrEmailNotVerified = errors.New("relyingparty: identity email is not verified") // ErrEmailConflict is returned when the identity's email belongs to a // local principal that cannot be linked automatically (its own email // is unverified, or it is already linked to another SystemAuth // principal). ErrEmailConflict = errors.New("relyingparty: email belongs to a principal that cannot be linked") // ErrSubjectLinked is returned by PrincipalStore writes when the // SystemAuth subject is already linked to a principal (a lost race). ErrSubjectLinked = errors.New("relyingparty: subject already linked") // ErrSessionNotFound is returned by SessionStore lookups for unknown or // expired sessions. ErrSessionNotFound = errors.New("relyingparty: session not found") // ErrInvalidState is returned for unknown, used or expired login state. ErrInvalidState = errors.New("relyingparty: invalid or expired login state") )
var DefaultScopes = []string{"openid", "profile", "email", "offline_access"}
DefaultScopes are requested when Config.Scopes is empty.
var ErrForeignDPoPToken = errors.New("relyingparty: the access token is bound to another DPoP key")
ErrForeignDPoPToken is returned when the Client is asked to present a DPoP-bound token bound to a key it does not hold, such as the token of a caller authenticated by BearerMiddleware: only its holder can use it.
var ErrOrganizationNotResolved = errors.New("relyingparty: organization not resolved")
ErrOrganizationNotResolved is returned by ResolveOrganization when the slug names no organization the token's principal is an active member of.
Functions ¶
func BackchannelHandler ¶ added in v0.14.0
func BackchannelHandler(sink AccessChangeSink, verifier LogoutTokenVerifier, opts ...BackchannelOption) http.Handler
BackchannelHandler returns the endpoint SystemAuth POSTs logout tokens to (ADR-010 decision 4, separate deployments; register its URL as the client's backchannel_logout_uri). It verifies the token with verifier and then
- records an access_change event into sink (a RevocationCache), so tokens issued before the change are refused;
- on a back-channel logout event, deletes the sessions of the token's sid, or of its sub, when WithBackchannelSessions gave it a store.
It answers 200 on success, 400 for a token that fails verification or a replayed jti, and 500 when sessions cannot be deleted, which makes SystemAuth retry. sink may be nil to handle logout events only.
func BearerMiddleware ¶
func BearerMiddleware(cfg BearerConfig) func(http.Handler) http.Handler
BearerMiddleware authenticates programmatic clients and puts an AuthenticatedPrincipal in the request context. It accepts, in order:
- Authorization: DPoP <JWT> with a DPoP header — a SystemAuth JWT access token bound to a DPoP key, verified against the SystemAuth JWKS, and a proof signed by that key for this request; its sub must be linked to a local principal (sf_principal_id), who must have signed in to the app once.
- Authorization: Bearer <JWT>, only with AllowBearerTokens — an unbound SystemAuth JWT access token, checked the same way.
- a BFF session cookie, when Sessions is set.
Anything else gets 401 with WWW-Authenticate challenges for the DPoP scheme and, for legacy tokens, the Bearer scheme. Static API keys are not accepted (ADR-016): command-line tools sign people in with identity/publicclient, and automation uses service accounts.
It panics without Principals, and when Tokens is set without DPoP or AllowBearerTokens, since it could then accept no access token.
func IDPHint ¶
func IDPHint(provider string) oauth2.AuthCodeOption
IDPHint asks SystemAuth to sign in with the named upstream provider (github, google) instead of showing its chooser.
func OrgsTruncated ¶ added in v0.14.0
OrgsTruncated reports whether the claims say SystemAuth omitted organizations from "orgs".
func Replay ¶ added in v0.14.0
func Replay(ctx context.Context, src AccessChangeSource, cache *RevocationCache) error
Replay rebuilds a cache after a restart: it reads the changes of the last maximum token lifetime plus the margin from src into cache and, once that has succeeded, ends the cache's suspect mode. Call it before serving (ADR-010 decision 6); until it returns, a cache marked with MarkSuspect refuses every token issued before it was marked.
func ReplayWithRetry ¶ added in v0.14.0
func ReplayWithRetry(ctx context.Context, src AccessChangeSource, cache *RevocationCache, logger *slog.Logger) error
ReplayWithRetry calls Replay until it succeeds, waiting between attempts with exponential backoff from one second to thirty, logging each failure. The cache stays in suspect mode meanwhile, so the server may start serving at once and loses nothing but convenience. It returns nil after a success, or ctx's error.
func ToRequestPrincipal ¶ added in v0.14.0
func ToRequestPrincipal(p *AuthenticatedPrincipal) *requestprincipal.RequestPrincipal
ToRequestPrincipal converts an AuthenticatedPrincipal to the protocol-neutral view. PrincipalID is the app-local principal ID.
func WithPrincipal ¶
func WithPrincipal(ctx context.Context, p *AuthenticatedPrincipal) context.Context
WithPrincipal returns ctx carrying p.
Types ¶
type AccessChange ¶ added in v0.14.0
type AccessChange struct {
// PrincipalID is the SystemAuth principal, the token's sub.
PrincipalID string
// OrganizationID scopes the change to one organization; empty means
// every organization.
OrganizationID string
// ChangedAt is when SystemAuth recorded the change, on its clock.
ChangedAt time.Time
// Reason says why access changed, such as membership_removed or
// deactivated. It is opaque here: SystemAuth's registry of reasons grows
// without a migration, so a value this package does not know is not an
// error.
Reason string
}
AccessChange is a reduction of a principal's access, as SystemAuth announces it (ADR-010).
type AccessChangePoller ¶ added in v0.14.0
type AccessChangePoller struct {
// contains filtered or unexported fields
}
AccessChangePoller feeds a sink with the changes a source reports newer than its last read, so replicas that share a database but not memory see each other's access changes within one polling interval. One indexed query per instance per interval, independent of request volume.
Each poll re-reads from the margin before the newest change it has seen, so a row that committed late with an earlier timestamp is still picked up; recording a change twice is harmless. The first successful poll reads back one maximum token lifetime plus the margin, as Replay does, and clears suspect mode on a sink that has one.
func NewAccessChangePoller ¶ added in v0.14.0
func NewAccessChangePoller(src AccessChangeSource, sink AccessChangeSink, opts ...PollerOption) *AccessChangePoller
NewAccessChangePoller returns a poller reading src into sink.
type AccessChangeSink ¶ added in v0.14.0
type AccessChangeSink interface {
Record(AccessChange)
}
AccessChangeSink receives access changes. RevocationCache implements it; the replica poller and the back-channel receiver feed one.
type AccessChangeSource ¶ added in v0.14.0
type AccessChangeSource interface {
AccessChangesSince(ctx context.Context, since time.Time) ([]AccessChange, error)
}
AccessChangeSource reads the access changes SystemAuth recorded after a time. The poller and Replay read through it: from the shared database (pgstore.NewAccessChangeSource, accesschange.NewSource) or from SystemAuth's GET /access-changes endpoint (Client.AccessChangesSource). It returns every change after since, oldest first, however many pages that takes.
type AccessTokenClaims ¶
type AccessTokenClaims struct {
gojwt.RegisteredClaims
ClientID string `json:"client_id,omitempty"`
Scopes []string `json:"scp,omitempty"`
// Confirmation is the cnf claim (RFC 7800); its JKT binds the token to
// a DPoP key (RFC 9449).
Confirmation *TokenConfirmation `json:"cnf,omitempty"`
// Raw holds every claim of the verified token.
Raw map[string]any `json:"-"`
}
AccessTokenClaims are the verified claims of a SystemAuth JWT access token (features.enable_jwt_access_tokens).
func (*AccessTokenClaims) DPoPThumbprint ¶ added in v0.21.0
func (c *AccessTokenClaims) DPoPThumbprint() string
DPoPThumbprint returns the thumbprint of the DPoP key the token is bound to, or "" for an unbound bearer token.
func (*AccessTokenClaims) HasScope ¶
func (c *AccessTokenClaims) HasScope(scope string) bool
HasScope reports whether the token was granted scope.
type AccessTokenVerifier ¶
type AccessTokenVerifier interface {
VerifyAccessToken(ctx context.Context, raw string) (*AccessTokenClaims, error)
}
AccessTokenVerifier verifies JWT access tokens. *Client implements it.
type AuthMethod ¶
type AuthMethod string
AuthMethod is how a request was authenticated.
const ( // AuthMethodSession is a BFF cookie session. AuthMethodSession AuthMethod = "session" // AuthMethodAccessToken is a SystemAuth JWT access token. AuthMethodAccessToken AuthMethod = "access_token" )
type AuthenticatedPrincipal ¶
type AuthenticatedPrincipal struct {
// ID is the app-local principal ID (for a client-credentials token
// without a local principal, the OAuth client ID).
ID string
// Type is the principal type: human, service, ... or "client" for a
// client-credentials token.
Type string
// Subject is the SystemAuth subject (sf_principal_id), when known.
Subject string
// Method is how the request authenticated.
Method AuthMethod
// Scopes are the access token's scopes (empty for sessions, which
// carry the principal's full access).
Scopes []string
// Memberships are the principal's organization memberships.
Memberships []Membership
}
AuthenticatedPrincipal is the principal of an authenticated request.
func PrincipalFromContext ¶
func PrincipalFromContext(ctx context.Context) (*AuthenticatedPrincipal, bool)
PrincipalFromContext returns the authenticated principal, if any.
func (*AuthenticatedPrincipal) HasScope ¶
func (p *AuthenticatedPrincipal) HasScope(scope string) bool
HasScope reports whether the principal's credential carries scope. Session principals have no scopes and always return false.
type BFF ¶
type BFF struct {
// contains filtered or unexported fields
}
BFF serves the /bff/* cookie-session surface of the shared frontend auth package:
GET {base}/session session status (+ user with memberships)
GET {base}/api/v1/users/me current user with memberships
GET {base}/auth/login start SystemAuth login (?return_to=)
GET {base}/auth/{provider} start login via github|google (?return_to=)
GET {base}/auth/callback OIDC callback; sets the session cookie
POST {base}/auth/logout end the session, revoke SystemAuth tokens;
asked for JSON, also say where to end the
SystemAuth login session (SignOutResponse)
Mount it on the application's router at BasePath.
func (*BFF) AccessToken ¶
AccessToken returns a valid SystemAuth access token for r's session, refreshing (and rotating the stored refresh token) when it is about to expire. It returns ErrSessionNotFound when there is no session or the refresh was rejected (the session is then ended).
func (*BFF) BackchannelLogoutHandler ¶ added in v0.14.0
BackchannelLogoutHandler is the endpoint SystemAuth POSTs back-channel logout and access-change tokens to. The BFF serves it at <BasePath>/auth/backchannel-logout; use this to mount it elsewhere. It ends the sessions of a logout event's sid or sub and records access changes into BFFConfig.Revocation, when set.
func (*BFF) CurrentSession ¶
CurrentSession returns the session, principal and memberships carried by r's session cookie, or ErrSessionNotFound.
func (*BFF) RequireSession ¶
RequireSession is middleware for the application's own browser APIs: it requires a BFF session (401 AuthError otherwise) and puts the principal in the request context.
func (*BFF) ServeHTTP ¶
func (b *BFF) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP implements http.Handler.
func (*BFF) SignOut ¶
SignOut ends r's browser session: it deletes the session, revokes its SystemAuth refresh token, and clears the session cookie on w. A request without a session only has its cookie cleared. It writes no response, so a handler that serves its own sign-out, such as an embedded organization console, can call it after its own checks; /bff/auth/logout uses it after the cross-origin check.
type BFFConfig ¶
type BFFConfig struct {
// Client is the SystemAuth relying-party client (required).
Client *Client
// Principals resolves SystemAuth identities to app-local principals
// (required).
Principals PrincipalStore
// Memberships supplies organization memberships (with the session's
// SystemAuth claims). Default: ClaimsMemberships.
Memberships MembershipSource
// Apps, when set, lists the deployment's application catalog in the
// session response ("apps"), for the product switcher. Use
// appcatalog.Static when the catalog is in the process, or
// appcatalog.Remote to read what SystemAuth publishes. A shell shows the
// entries the person holds a role in for the current organization.
Apps appcatalog.Source
// Revocation, when set, makes the BFF refresh a session's tokens and
// claims from SystemAuth when a change to the principal's access was
// recorded after the claims were obtained (ADR-010). The session is not
// ended: the person stays signed in with reduced claims. If SystemAuth
// refuses the refresh (deactivation, revocation) the session ends.
Revocation *RevocationCache
// Sessions stores browser sessions. Default: in-memory (single
// instance); use pgstore.NewSessionStore in production.
Sessions SessionStore
// LoginStates stores in-flight logins. Default: in-memory (single
// instance); use pgstore.NewLoginStateStore in production.
LoginStates LoginStateStore
// BasePath is where the handler is mounted. Default: "/bff".
BasePath string
// SessionLifetime is the absolute session lifetime. Default: 12h.
SessionLifetime time.Duration
// LoginErrorPath, when set, is the app's sign-in page. A browser whose
// sign-in fails at GET {BasePath}/auth/login or the callback is then
// redirected there with ?error=<CODE> (LOGIN_FAILED, INVALID_STATE,
// EMAIL_NOT_VERIFIED, ACCOUNT_CONFLICT, ACCOUNT_DISABLED,
// INVALID_RETURN_TO, UNKNOWN_PROVIDER or INTERNAL_ERROR) instead of
// shown a JSON error. A request that asks for JSON (Accept:
// application/json without text/html) still gets JSON. It must pass the
// same checks as a return_to, typically a path such as "/login". Empty
// keeps the JSON errors.
LoginErrorPath string
// DefaultReturnTo is where users land after login when no return_to is
// given. Default: "/".
DefaultReturnTo string
// AllowedOrigins are absolute origins (scheme://host[:port]) that may be
// post-login return_to targets and may call the BFF's state-changing
// endpoints. The origin of the Client's RedirectURL is always allowed.
AllowedOrigins []string
// SignOutURL is SystemAuth's sign-out page, where a browser that signed
// out of the app goes next to end its SystemAuth login session, so the
// next sign-in asks who it is instead of resuming that session.
// Signing out there also ends the person's sessions in the other apps,
// through the back-channel logout. Default: the issuer's /logout. "-"
// leaves it out, for an issuer without one.
SignOutURL string
// InsecureCookies drops the __Host- prefix and the Secure flag for
// local development over plain HTTP. Never enable in production;
// NewBFF logs a warning when it is set.
InsecureCookies bool
// Logger receives operational errors. Default: slog.Default().
Logger *slog.Logger
}
BFFConfig configures the /bff/* cookie-session handler.
type BackchannelOption ¶ added in v0.14.0
type BackchannelOption func(*backchannelHandler)
BackchannelOption configures BackchannelHandler.
func WithBackchannelLogger ¶ added in v0.14.0
func WithBackchannelLogger(l *slog.Logger) BackchannelOption
WithBackchannelLogger sets the logger for refused tokens and failures.
func WithBackchannelSessions ¶ added in v0.14.0
func WithBackchannelSessions(store SessionStore) BackchannelOption
WithBackchannelSessions deletes the sessions of a logout event's sub or sid from store, as the OIDC logout requires of a relying party with server-side sessions.
type BearerConfig ¶
type BearerConfig struct {
// Tokens verifies SystemAuth JWT access tokens. Nil disables them.
Tokens AccessTokenVerifier
// Principals resolves token subjects to app-local principals
// (required).
Principals PrincipalStore
// Memberships supplies organization memberships. Default:
// ClaimsMemberships, which reads a JWT access token's claims.
Memberships MembershipSource
// Sessions, when set, also accepts a BFF cookie session for requests
// without credentials, so one middleware protects an API used by both
// the browser and programmatic clients.
Sessions *BFF
// AllowClientTokens accepts client-credentials access tokens (subject
// equal to the client ID, no local principal) as a "client" principal.
AllowClientTokens bool
// ServicePrincipals accepts SystemAuth service accounts: a verified
// access token whose subject has no local principal and whose
// principal_type claim is "service" creates one with
// PrincipalStore.CreatePrincipal (VerifiedIdentity.Service), since a
// service account never signs in. The principal store must support
// service identities. Without it such a token is refused as an unknown
// principal.
ServicePrincipals bool
// Revocation, when set, refuses a JWT access token issued before a
// recorded change to its principal's access (ADR-010). The refusal is
// a 401 invalid_token "token expired", which makes the client refresh
// and obtain claims that reflect the change.
Revocation *RevocationCache
// Organization names the organization a request addresses, so that a
// change to the principal's access in another organization does not
// refuse it. Without it every change to the principal applies.
Organization func(r *http.Request) string
// DPoP verifies the proofs that come with DPoP-bound access tokens (a
// cnf.jkt claim), presented with the "DPoP" authorization scheme (RFC
// 9449). SystemAuth binds every token it issues unless the client is a
// legacy bearer exception, so DPoP is required whenever Tokens is set,
// unless AllowBearerTokens is. A DPoP-bound token presented with the
// Bearer scheme is always refused, so a stolen bound token does not
// work as a bearer token.
DPoP *DPoPConfig
// AllowBearerTokens also accepts unbound JWT access tokens with the
// Bearer scheme, as SystemAuth issues to clients registered with its
// allow_bearer_tokens exception. It is an explicit opt-in: by default
// an access token without cnf.jkt is refused, since whoever holds a
// bearer token can use it.
AllowBearerTokens bool
// Logger receives storage errors. Default: slog.Default().
Logger *slog.Logger
}
BearerConfig configures BearerMiddleware.
type CacheOption ¶ added in v0.14.0
type CacheOption func(*RevocationCache)
CacheOption configures a RevocationCache.
func WithClock ¶ added in v0.14.0
func WithClock(now func() time.Time) CacheOption
WithClock sets the clock that decides when entries expire. For tests.
func WithMaxTokenLifetime ¶ added in v0.14.0
func WithMaxTokenLifetime(d time.Duration) CacheOption
WithMaxTokenLifetime sets the longest access-token lifetime the cache must cover, DefaultMaxTokenLifetime by default.
func WithSkewMargin ¶ added in v0.14.0
func WithSkewMargin(d time.Duration) CacheOption
WithSkewMargin sets the clock-skew margin. Values below MinSkewMargin are raised to it.
func WithSuspectStart ¶ added in v0.14.0
func WithSuspectStart() CacheOption
WithSuspectStart starts the cache in suspect mode, for a relying party that restarts or runs in a separate process: until Replay (or a first poll) succeeds, only tokens issued at least one margin after the cache was created are accepted, so a restart never widens the window in which a revoked token is honored.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is an OpenID Connect relying-party client for SystemAuth.
func NewClientWithMetadata ¶
NewClientWithMetadata returns a Client for already-known provider metadata (no discovery request).
func (*Client) AccessChangesSource ¶ added in v0.14.0
func (c *Client) AccessChangesSource(token func(ctx context.Context) (string, error)) AccessChangeSource
AccessChangesSource reads changes from SystemAuth's GET /access-changes endpoint (replay), authenticating each request with the token token returns, which must carry AccessChangesScope. Use ClientCredentialsToken for a token source.
func (*Client) AuthCodeURL ¶
func (c *Client) AuthCodeURL(state, nonce, verifier string, opts ...oauth2.AuthCodeOption) string
AuthCodeURL returns the SystemAuth authorization URL for a login with the given state, nonce and PKCE verifier. Pass an IDPHint option to skip the provider chooser.
func (*Client) ClientCredentialsToken ¶ added in v0.14.0
func (c *Client) ClientCredentialsToken(clientID, clientSecret string) func(ctx context.Context) (string, error)
ClientCredentialsToken returns a token source for the client-credentials grant with scope AccessChangesScope, for AccessChangesSource. The OAuth client must be registered with that scope and grant.
func (*Client) DPoPThumbprint ¶ added in v0.21.0
DPoPThumbprint returns the thumbprint of the DPoP key the Client binds its tokens to.
func (*Client) Refresh ¶
Refresh exchanges a refresh token for new tokens. SystemAuth rotates refresh tokens: the returned token carries the replacement, and the old one must not be used again.
func (*Client) ResolveOrganization ¶
func (c *Client) ResolveOrganization(ctx context.Context, accessToken, slug string) (m *Membership, alias bool, err error)
ResolveOrganization resolves an organization slug from a URL to the membership it names, with the SystemAuth organization ID to key data by, through SystemAuth's resolve endpoint (GET <userinfo endpoint>/memberships/resolve). A slug the organization gave up by renaming keeps resolving during its cooling-off period, with alias true and OrganizationSlug the current slug, so the application can redirect to it. accessToken must be granted openid. It returns ErrOrganizationNotResolved when the slug names no organization the principal belongs to.
func (*Client) Revoke ¶
Revoke revokes token at SystemAuth (RFC 7009). hint is "refresh_token" or "access_token" (optional). Revoking a refresh token revokes its whole token family.
func (*Client) Transport ¶
func (c *Client) Transport(base http.RoundTripper) http.RoundTripper
Transport returns an http.RoundTripper for calls to SystemAuth's APIs that carry an access token the Client obtained, such as a session's token sent to the managed-principal API by mpclient or a reverse proxy. The caller sets the token in the Authorization header, as "Bearer <token>" or "DPoP <token>"; the transport presents it the way the Client presents its own:
- a token bound to Config.DPoPKey, or an opaque one, with the DPoP scheme and a new proof by the key for the request's method and URL;
- an unbound JWT, from a client registered with allow_bearer_tokens, with the Bearer scheme and no proof;
- a JWT bound to another key is refused with ErrForeignDPoPToken, since only its holder can make proofs for it. An opaque token's binding cannot be read, so it is sent with this client's proof and SystemAuth refuses it if it is bound to another key;
- a JWT whose signature SystemAuth's keys do not verify, or from another issuer, is refused with ErrInvalidToken, so no proof is made for a token SystemAuth did not issue. Its expiry is not checked: SystemAuth answers an expired token itself.
A request refused with 401 use_dpop_nonce is retried once with SystemAuth's nonce when its body can be sent again (none, or GetBody); otherwise the refusal is returned and the next request carries the nonce. Requests without an Authorization header pass through unchanged.
The proof's htu is the request's URL without query and fragment, so send the requests to the URL SystemAuth checks proofs against: its issuer origin, or dpop.resource_base_url when SystemAuth sets one. base nil is http.DefaultTransport.
func (*Client) VerifyAccessToken ¶
VerifyAccessToken verifies a SystemAuth JWT access token: RS256 signature against the JWKS, issuer, expiry, a client_id claim (ID tokens and other SystemAuth JWTs carry none), a JOSE typ of at+jwt or JWT when present, Config.AccessTokenAudience when set, and the client the token was issued to (see Config.AcceptedClientIDs). Revocation is not checked (JWTs are self-contained; keep their lifetime short).
func (*Client) VerifyIDToken ¶
VerifyIDToken verifies an ID token per OIDC Core §3.1.3.7: RS256 signature against the SystemAuth JWKS, iss equal to the issuer, aud containing the client ID (and azp equal to it when there are several audiences), unexpired, and nonce equal to the one sent at login.
func (*Client) VerifyLogoutToken ¶ added in v0.14.0
VerifyLogoutToken verifies a logout token per OpenID Connect Back-Channel Logout 1.0 section 2.6: RS256 signature against the SystemAuth JWKS, iss equal to the issuer, aud containing the client ID, a jti, an expiry, typ logout+jwt, no nonce, an events claim naming the back-channel logout or access-change event, and a sub or sid.
type Config ¶
type Config struct {
// Issuer is the SystemAuth issuer URL (required).
Issuer string
// ClientID is this application's OAuth client ID at SystemAuth
// (required).
ClientID string
// ClientSecret is the client secret for confidential clients. Leave it
// empty for public clients (PKCE is always used).
ClientSecret string
// RedirectURL is the absolute callback URL registered with SystemAuth,
// e.g. https://app.example.com/bff/auth/callback (required).
RedirectURL string
// Scopes requested at login. Default: DefaultScopes.
Scopes []string
// AccessTokenAudience, when set, is required in the "aud" of JWT access
// tokens accepted by VerifyAccessToken. Set it when this application is
// an API that clients request tokens for with an audience parameter;
// tokens from any client carrying the audience are then accepted.
AccessTokenAudience string
// AcceptedClientIDs lists the OAuth clients whose JWT access tokens
// VerifyAccessToken accepts, by the token's client_id claim. Default:
// ClientID alone when AccessTokenAudience is empty, and any client when
// it is set. List other clients here, for example service clients that
// call this API with client-credentials tokens; include ClientID to keep
// accepting this application's own tokens.
AcceptedClientIDs []string
// HTTPClient is used for all calls to SystemAuth. Default:
// http.DefaultClient.
HTTPClient *http.Client
// DPoPKey is the key the Client proves possession of to SystemAuth
// (RFC 9449): every token request carries a proof by it, so the tokens
// SystemAuth issues are bound to it, and refreshing them needs it.
// Default: a key generated when the Client is created, which suits one
// instance. When several instances share sessions, give them all the
// same key (dpop.DeserializeKeyPairJSON, from a secret), or a refresh
// on another instance than the one that signed in is refused.
DPoPKey *dpop.KeyPair
}
Config configures a relying-party Client.
type DPoPConfig ¶ added in v0.21.0
type DPoPConfig struct {
// Verification sets the proof age window and clock skew. Access token
// binding (the ath claim) is always required. Zero values use
// dpop.DefaultVerificationConfig.
Verification dpop.VerificationConfig
// ReplayCache rejects a proof whose jti has been used before
// (required).
ReplayCache DPoPReplayCache
// BaseURL is the scheme and host clients use to reach this API, such as
// "https://api.example.com"; a proof's htu must equal BaseURL plus the
// request path. If empty, the scheme comes from the connection and the
// host from the Host header. Forwarded headers are never trusted, so
// set BaseURL behind a proxy that terminates TLS.
BaseURL string
}
DPoPConfig enables DPoP sender-constrained access tokens (RFC 9449) in BearerMiddleware.
type DPoPReplayCache ¶ added in v0.21.0
type DPoPReplayCache interface {
// Use records key until expiresAt and reports whether it was already
// recorded (a replay).
Use(ctx context.Context, key string, expiresAt time.Time) (replayed bool, err error)
}
DPoPReplayCache records DPoP proof identifiers so that each proof is accepted once. Implementations must be safe for concurrent use and, when several instances serve the API, shared between them. The replay caches in session/middleware (NewMemoryReplayCache, NewRedisReplayCache) implement it.
type IDTokenClaims ¶
type IDTokenClaims struct {
gojwt.RegisteredClaims
Nonce string `json:"nonce,omitempty"`
AuthorizedParty string `json:"azp,omitempty"`
AuthTime int64 `json:"auth_time,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
// EmailVerifiedAt is SystemForge's email_verified_at extension: when
// the email was verified, as a NumericDate (seconds since the epoch,
// which may arrive in floating-point form); nil when absent.
EmailVerifiedAt *gojwt.NumericDate `json:"email_verified_at,omitempty"`
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
SID string `json:"sid,omitempty"`
// Raw holds every claim of the verified token.
Raw map[string]any `json:"-"`
}
IDTokenClaims are the verified claims of a SystemAuth ID token.
type KeySet ¶
type KeySet struct {
// contains filtered or unexported fields
}
KeySet caches the SystemAuth JSON Web Key Set and verifies RS256 JWT signatures against it. An unknown key ID triggers a (rate-limited) refetch, so signing-key rotation is picked up without a restart.
type LoginState ¶
type LoginState struct {
// Nonce is the OIDC nonce sent in the authorization request.
Nonce string
// Verifier is the PKCE code verifier.
Verifier string
// ReturnTo is the validated post-login target.
ReturnTo string
// ExpiresAt bounds how long the login may take.
ExpiresAt time.Time
}
LoginState is the server-side record of an in-flight login.
type LoginStateStore ¶
type LoginStateStore interface {
// Put stores state.
Put(ctx context.Context, state string, data LoginState) error
// Take returns and removes the state, or ErrInvalidState when it is
// unknown, already used or expired.
Take(ctx context.Context, state string) (LoginState, error)
}
LoginStateStore keeps in-flight login state keyed by the OAuth state value. Take must be single-use.
type LogoutTokenClaims ¶ added in v0.14.0
type LogoutTokenClaims struct {
gojwt.RegisteredClaims
SID string `json:"sid,omitempty"`
Nonce string `json:"nonce,omitempty"`
Events map[string]json.RawMessage `json:"events"`
}
LogoutTokenClaims are the verified claims of a SystemAuth logout token.
type LogoutTokenVerifier ¶ added in v0.14.0
type LogoutTokenVerifier interface {
VerifyLogoutToken(ctx context.Context, raw string) (*LogoutTokenClaims, error)
}
LogoutTokenVerifier verifies logout tokens. *Client implements it.
type Membership ¶
type Membership struct {
ID string
OrganizationID string
OrganizationName string
OrganizationSlug string
// OrganizationType is personal, team or enterprise, when known.
OrganizationType string
// Role is the organization role: owner, admin, member or collaborator.
Role string
JoinedAt time.Time
// Applications maps an application identifier to the principal's role
// there. An application absent from the map is one the principal may
// not open. Sources that do not know application roles leave it nil.
Applications map[string]string
}
Membership is an organization membership of a principal.
func MembershipsFromClaims ¶ added in v0.14.0
func MembershipsFromClaims(claims map[string]any) []Membership
MembershipsFromClaims decodes the "orgs" claim. Entries that are not objects with an "id" are skipped.
type MembershipLister ¶
type MembershipLister interface {
// Memberships lists the principal's organization memberships.
Memberships(ctx context.Context, principalID string) ([]Membership, error)
}
MembershipLister is an app-local membership table. MemoryPrincipalStore implements it.
type MembershipQuery ¶
type MembershipQuery struct {
// Principal is the resolved app-local principal.
Principal *Principal
// Subject is the SystemAuth subject (sf_principal_id), when known.
Subject string
// Claims are the verified SystemAuth claims seen for this request: the
// ID token and UserInfo claims stored with a BFF session, or the claims
// of a JWT access token. Nil for API keys.
Claims map[string]any
// AccessToken returns a valid SystemAuth access token for the request
// (the presented JWT, or a session's token, refreshed when it is about
// to expire). It is nil when the request has none, as with an API key.
// It is lazy so that sources reading claims never trigger a refresh.
AccessToken func(ctx context.Context) (string, error)
}
MembershipQuery identifies whose organization memberships to load.
type MembershipSource ¶
type MembershipSource interface {
Memberships(ctx context.Context, q MembershipQuery) ([]Membership, error)
}
MembershipSource supplies a principal's organization memberships.
SystemAuth is the source of truth for memberships, roles and entitlements (ADR-007). The default source, ClaimsMemberships, reads them from the verified claims; RemoteMemberships reads them from SystemAuth's memberships endpoint; LocalMemberships reads an app-local table and is for development and tests only.
var ClaimsMemberships MembershipSource = MembershipSourceFunc(func(_ context.Context, q MembershipQuery) ([]Membership, error) { return MembershipsFromClaims(q.Claims), nil })
ClaimsMemberships reads memberships from the SystemAuth "orgs" claim of the verified claims (ADR-007): the ID token and userinfo claims of a BFF session, or the claims of a JWT access token. It is the default source.
The claim lists the principal's organizations with their role and, in "apps", the principal's role in each application. A JWT access token carries the compact form without slug, name or type. When the server omitted organizations ("orgs_truncated") or the application needs more than the token carries, wrap this source with ClaimsOrRemoteMemberships.
A request without claims, such as one authenticated by an API key, has no memberships from this source.
var NoMemberships MembershipSource = MembershipSourceFunc(func(context.Context, MembershipQuery) ([]Membership, error) { return nil, nil })
NoMemberships reports no memberships.
func ClaimsOrRemoteMemberships ¶ added in v0.14.0
func ClaimsOrRemoteMemberships(remote MembershipSource, logger *slog.Logger) MembershipSource
ClaimsOrRemoteMemberships reads the claims and falls back to remote when they cannot answer: when SystemAuth truncated "orgs" or when the claims carry none (a compact token without the claim, or a server without an OrgsSource). If remote fails, the claims' possibly partial answer is returned and the failure logged, so SystemAuth being unavailable does not stop requests (ADR-007: serve from the last token).
func LocalMemberships ¶
func LocalMemberships(l MembershipLister) MembershipSource
LocalMemberships reads memberships from an app-local MembershipLister.
It is for development and tests only. A table the application keeps itself diverges from SystemAuth (members removed or roles changed there are not seen here), which is what ADR-007 removes. Production applications use ClaimsMemberships or RemoteMemberships.
func ProvisioningMemberships ¶ added in v0.14.0
func ProvisioningMemberships(src MembershipSource, application string, provision func(ctx context.Context, m Membership) error) MembershipSource
ProvisioningMemberships wraps src so that the first time a principal holding a role in application is seen in an organization, provision runs for that organization (ADR-007 decision 10): the application creates its own records for an organization ID on first use instead of keeping membership tables. provision receives the membership, whose OrganizationID is the SystemAuth organization ID to key data by.
provision must be idempotent: it runs once per organization per process, so other instances and restarts run it again. A failure is returned from Memberships (the request fails) and the organization is retried on the next request. Organizations in which the principal holds no role in application are not provisioned.
func RemoteMemberships ¶ added in v0.14.0
func RemoteMemberships(c *Client) MembershipSource
RemoteMemberships reads the full membership list from SystemAuth's memberships endpoint (GET <userinfo endpoint>/memberships), following the pagination cursor. It needs an access token granted openid, which a JWT access token or a BFF session supplies; a request without one (an API key) has no memberships from this source.
Use it when the claims cannot answer: the token was truncated, or the application holds only the compact access-token form and needs slugs and names. ClaimsOrRemoteMemberships does that choice for you.
type MembershipSourceFunc ¶
type MembershipSourceFunc func(ctx context.Context, q MembershipQuery) ([]Membership, error)
MembershipSourceFunc adapts a function to MembershipSource.
func (MembershipSourceFunc) Memberships ¶
func (f MembershipSourceFunc) Memberships(ctx context.Context, q MembershipQuery) ([]Membership, error)
Memberships implements MembershipSource.
type MemoryLoginStateStore ¶
type MemoryLoginStateStore struct {
// contains filtered or unexported fields
}
MemoryLoginStateStore is an in-memory LoginStateStore for a single instance.
func NewMemoryLoginStateStore ¶
func NewMemoryLoginStateStore() *MemoryLoginStateStore
NewMemoryLoginStateStore creates an empty in-memory login state store.
func (*MemoryLoginStateStore) Put ¶
func (s *MemoryLoginStateStore) Put(_ context.Context, state string, data LoginState) error
Put implements LoginStateStore. Expired entries are pruned.
func (*MemoryLoginStateStore) Take ¶
func (s *MemoryLoginStateStore) Take(_ context.Context, state string) (LoginState, error)
Take implements LoginStateStore.
type MemoryPrincipalStore ¶
type MemoryPrincipalStore struct {
// contains filtered or unexported fields
}
MemoryPrincipalStore is an in-memory PrincipalStore for development and tests.
func NewMemoryPrincipalStore ¶
func NewMemoryPrincipalStore() *MemoryPrincipalStore
NewMemoryPrincipalStore creates an empty in-memory store.
func (*MemoryPrincipalStore) AddMembership ¶
func (s *MemoryPrincipalStore) AddMembership(principalID string, m Membership) error
AddMembership adds an organization membership for a principal.
func (*MemoryPrincipalStore) AddPrincipal ¶
func (s *MemoryPrincipalStore) AddPrincipal(p Principal) (*Principal, error)
AddPrincipal seeds a principal (e.g. one created by the app's own signup). An empty ID is assigned.
func (*MemoryPrincipalStore) CreatePrincipal ¶
func (s *MemoryPrincipalStore) CreatePrincipal(_ context.Context, id VerifiedIdentity) (*Principal, error)
CreatePrincipal implements PrincipalStore.
func (*MemoryPrincipalStore) FindByEmail ¶
FindByEmail implements PrincipalStore.
func (*MemoryPrincipalStore) FindBySubject ¶
FindBySubject implements PrincipalStore.
func (*MemoryPrincipalStore) GetPrincipal ¶
GetPrincipal implements PrincipalStore.
func (*MemoryPrincipalStore) LinkSubject ¶
func (s *MemoryPrincipalStore) LinkSubject(_ context.Context, principalID, subject string) error
LinkSubject implements PrincipalStore.
func (*MemoryPrincipalStore) Memberships ¶
func (s *MemoryPrincipalStore) Memberships(_ context.Context, principalID string) ([]Membership, error)
Memberships implements MembershipLister.
func (*MemoryPrincipalStore) RecordLogin ¶
func (s *MemoryPrincipalStore) RecordLogin(_ context.Context, principalID string, id VerifiedIdentity, at time.Time) error
RecordLogin implements PrincipalStore: it refreshes name and avatar.
type MemorySessionStore ¶
type MemorySessionStore struct {
// contains filtered or unexported fields
}
MemorySessionStore is an in-memory SessionStore for a single instance.
func NewMemorySessionStore ¶
func NewMemorySessionStore() *MemorySessionStore
NewMemorySessionStore creates an empty in-memory session store.
func (*MemorySessionStore) Delete ¶
func (s *MemorySessionStore) Delete(_ context.Context, token string) error
Delete implements SessionStore.
func (*MemorySessionStore) DeleteBySID ¶
DeleteBySID implements SessionStore.
func (*MemorySessionStore) DeleteBySubject ¶
DeleteBySubject implements SessionStore.
type Metadata ¶
type Metadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
JWKSURI string `json:"jwks_uri"`
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
}
Metadata is the subset of the SystemAuth OpenID Provider configuration a relying party uses.
type PollerOption ¶ added in v0.14.0
type PollerOption func(*AccessChangePoller)
PollerOption configures an AccessChangePoller.
func WithPollInterval ¶ added in v0.14.0
func WithPollInterval(d time.Duration) PollerOption
WithPollInterval sets the polling interval, limited to [MinPollInterval, MaxPollInterval]. The default is MinPollInterval.
func WithPollerClock ¶ added in v0.14.0
func WithPollerClock(now func() time.Time) PollerOption
WithPollerClock sets the poller's clock. For tests.
func WithPollerLogger ¶ added in v0.14.0
func WithPollerLogger(l *slog.Logger) PollerOption
WithPollerLogger sets the logger that records failed polls.
func WithPollerMargin ¶ added in v0.14.0
func WithPollerMargin(d time.Duration) PollerOption
WithPollerMargin sets the margin the poller re-reads behind the newest change. Use the sink cache's margin (the default is DefaultSkewMargin).
type Principal ¶
type Principal struct {
// ID is the app-local principal ID.
ID string
// Type is the principal type (human, service, ...). Default "human".
Type string
// SFPrincipalID is the linked SystemAuth principal ID (the OIDC sub);
// empty when not linked yet.
SFPrincipalID string
// Email is the principal's email.
Email string
// EmailVerified reports whether the app considers Email verified.
EmailVerified bool
// Name is the display name.
Name string
// AvatarURL is the avatar URL.
AvatarURL string
// Active reports whether the principal may sign in.
Active bool
// Managed reports a managed principal, which has no email.
Managed bool
// CreatedAt and UpdatedAt are record timestamps.
CreatedAt time.Time
UpdatedAt time.Time
}
Principal is the application's local principal, as the relying-party layer sees it.
func ResolvePrincipal ¶
func ResolvePrincipal(ctx context.Context, store PrincipalStore, id VerifiedIdentity, now time.Time) (*Principal, error)
ResolvePrincipal finds or creates the app-local principal for a verified SystemAuth identity, using the same account-linking rules as SystemAuth:
- The subject (sf_principal_id) is the primary key: a linked principal signs in regardless of its current email.
- Otherwise a verified identity email links to an existing principal whose own email is verified and that is not linked to another SystemAuth principal.
- Otherwise a new principal is created, which requires a verified email.
An unverified email never links to or creates a principal. A managed identity never links by email, even when it has one (ADR-009): after the subject lookup it is created directly, marked managed.
type PrincipalStore ¶
type PrincipalStore interface {
// FindBySubject returns the principal linked to the SystemAuth subject
// (sf_principal_id), or ErrPrincipalNotFound.
FindBySubject(ctx context.Context, subject string) (*Principal, error)
// FindByEmail returns the principal with this email (case-insensitive),
// or ErrPrincipalNotFound.
FindByEmail(ctx context.Context, email string) (*Principal, error)
// LinkSubject sets sf_principal_id on an existing principal. It returns
// ErrSubjectLinked if the subject is already linked to a principal.
LinkSubject(ctx context.Context, principalID, subject string) error
// CreatePrincipal creates an active human principal linked to
// identity.Subject, with a verified email or, when identity.Managed,
// with or without an email. When identity.Service is set it creates a
// principal of type service without an email instead; a store that
// cannot returns an error, and an application must not enable
// BearerConfig.ServicePrincipals with it. It returns ErrSubjectLinked if
// the subject was linked concurrently and ErrEmailConflict if the email
// is taken.
CreatePrincipal(ctx context.Context, identity VerifiedIdentity) (*Principal, error)
// RecordLogin refreshes profile fields from identity after a sign-in.
RecordLogin(ctx context.Context, principalID string, identity VerifiedIdentity, at time.Time) error
// GetPrincipal returns a principal by app-local ID, or
// ErrPrincipalNotFound.
GetPrincipal(ctx context.Context, id string) (*Principal, error)
}
PrincipalStore is the persistence port the relying-party layer needs. An application implements it over its own schema; with Ent, a principal entity using identity/ent/mixin.PrincipalMixin already has the sf_principal_id field that Principal.SFPrincipalID maps to.
type RequestAuthenticator ¶ added in v0.14.0
type RequestAuthenticator struct {
// contains filtered or unexported fields
}
RequestAuthenticator adapts BearerConfig to a requestprincipal.Authenticator, so SystemAuth access tokens, API keys and BFF sessions can share one requestprincipal.Middleware with other protocols (such as AAuth for AI agents).
func NewRequestAuthenticator ¶ added in v0.14.0
func NewRequestAuthenticator(cfg BearerConfig) *RequestAuthenticator
NewRequestAuthenticator returns an Authenticator accepting what BearerMiddleware accepts with cfg. Requests with no Authorization or X-API-Key header and no BFF session are left to the next authenticator.
func (*RequestAuthenticator) Authenticate ¶ added in v0.14.0
func (a *RequestAuthenticator) Authenticate(r *http.Request) (*requestprincipal.RequestPrincipal, error)
Authenticate implements requestprincipal.Authenticator. The result's Native is the *AuthenticatedPrincipal, which carries the memberships.
func (*RequestAuthenticator) Challenge ¶ added in v0.14.0
func (a *RequestAuthenticator) Challenge(h http.Header)
Challenge implements requestprincipal.Challenger.
type RevocationCache ¶ added in v0.14.0
type RevocationCache struct {
// contains filtered or unexported fields
}
RevocationCache remembers recent access changes so a relying party can refuse access tokens issued before them (ADR-010). It is keyed by principal, and by organization for organization-scoped changes.
A token is accepted only if its iat is at or after the change plus the clock-skew margin; a token stamped inside the margin is treated as issued before the change. Refusing is cheap and recoverable: the client refreshes and gets claims that reflect the change.
An entry is dropped once the change plus the maximum token lifetime plus the margin has passed, so the cache holds only the changes of the last token lifetime. In a modular monolith one RevocationCache is shared by SystemAuth, which records into it, and every relying party, which reads it; both use the same clock, so the margin is moot there but applied uniformly. It is safe for concurrent use.
func NewRevocationCache ¶ added in v0.14.0
func NewRevocationCache(opts ...CacheOption) *RevocationCache
NewRevocationCache returns an empty cache.
func (*RevocationCache) Accept ¶ added in v0.14.0
func (c *RevocationCache) Accept(principalID, orgID string, iat time.Time) bool
Accept reports whether a token issued at iat for principalID may be used. orgID is the organization the request addresses; when it is empty the token is checked against every change to the principal, in any organization, which is conservative: it refuses a token for a change that may not concern the request, and the client's refresh sets it right. A token with a zero iat is accepted only if the principal has no recorded change.
func (*RevocationCache) ChangedAt ¶ added in v0.14.0
func (c *RevocationCache) ChangedAt(principalID, orgID string) time.Time
ChangedAt returns the latest unexpired change recorded for the principal (in the organization, or in any when orgID is empty), or the zero time. In suspect mode it is no earlier than the moment suspect mode began.
func (*RevocationCache) ClearSuspect ¶ added in v0.14.0
func (c *RevocationCache) ClearSuspect()
ClearSuspect ends suspect mode.
func (*RevocationCache) Len ¶ added in v0.14.0
func (c *RevocationCache) Len() int
Len returns the number of principals with a remembered change.
func (*RevocationCache) Margin ¶ added in v0.14.0
func (c *RevocationCache) Margin() time.Duration
Margin returns the clock-skew margin in effect.
func (*RevocationCache) MarkSuspect ¶ added in v0.14.0
func (c *RevocationCache) MarkSuspect()
MarkSuspect puts the cache in suspect mode from now: tokens issued before now plus the margin are refused until ClearSuspect, which Replay and a successful first poll call.
func (*RevocationCache) Record ¶ added in v0.14.0
func (c *RevocationCache) Record(ch AccessChange)
Record remembers a change. It keeps the latest change per principal and organization, so recording the same change twice, or out of order, is harmless. A change with no principal or no time is ignored, as is one already past its retention.
func (*RevocationCache) RecordChange ¶ added in v0.14.0
func (c *RevocationCache) RecordChange(principalID, organizationID string, changedAt time.Time, reason string)
RecordChange records a change from its parts. It lets a package that cannot import relyingparty, such as accesschange, feed the cache through an interface.
func (*RevocationCache) Suspect ¶ added in v0.14.0
func (c *RevocationCache) Suspect() bool
Suspect reports whether the cache is in suspect mode.
func (*RevocationCache) Sweep ¶ added in v0.14.0
func (c *RevocationCache) Sweep()
Sweep drops the entries that are past their retention. Record does it at most once a second; call Sweep from a ticker if changes are rare and memory matters.
type Session ¶
type Session struct {
// PrincipalID is the app-local principal.
PrincipalID string
// Subject is the SystemAuth principal ID (sf_principal_id).
Subject string
// SID is the SystemAuth session ID ("sid" claim of the ID token), when
// SystemAuth issues one. Back-channel logout deletes sessions by SID or
// Subject.
SID string
// Claims are the verified ID token claims, overlaid with the UserInfo
// claims, captured at sign-in.
// A MembershipSource can read SystemAuth-issued memberships from them.
Claims map[string]any
// ClaimsIssuedAt is when Claims were obtained from SystemAuth, or, after
// a refresh forced by an access change, the earliest time a
// RevocationCache accepts (ADR-010). Zero for a session created before
// the field existed: CreatedAt stands in.
ClaimsIssuedAt time.Time
// AccessToken, RefreshToken and IDToken are the SystemAuth tokens.
AccessToken string
RefreshToken string
IDToken string
// AccessTokenExpiresAt is when AccessToken expires.
AccessTokenExpiresAt time.Time
// CreatedAt is when the session was established.
CreatedAt time.Time
// ExpiresAt is the absolute session expiry.
ExpiresAt time.Time
}
Session is a server-side browser session of the application. The cookie carries only an opaque token; SystemAuth tokens never reach the browser.
type SessionStatus ¶
type SessionStatus struct {
Authenticated bool `json:"authenticated"`
UserID string `json:"user_id,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
User *User `json:"user,omitempty"`
// Apps is the deployment's application catalog (BFFConfig.Apps), in
// order, for a signed-in session.
Apps []appcatalog.App `json:"apps,omitempty"`
}
SessionStatus is the GET /bff/session response.
type SessionStore ¶
type SessionStore interface {
// Create stores session under token.
Create(ctx context.Context, token string, session Session) error
// Get returns the session for token, or ErrSessionNotFound when it is
// unknown or expired.
Get(ctx context.Context, token string) (Session, error)
// Update replaces the session for token (e.g. after a token refresh).
// It returns ErrSessionNotFound for an unknown token.
Update(ctx context.Context, token string, session Session) error
// Delete removes the session. Deleting an unknown token is not an error.
Delete(ctx context.Context, token string) error
// DeleteBySubject removes every session of a SystemAuth subject and
// returns how many were removed (for back-channel logout).
DeleteBySubject(ctx context.Context, subject string) (int, error)
// DeleteBySID removes every session established under a SystemAuth
// session ID and returns how many were removed.
DeleteBySID(ctx context.Context, sid string) (int, error)
}
SessionStore persists sessions keyed by an opaque session token. Implementations should store only a hash of the token and must be safe for concurrent use.
type SignOutResponse ¶
type SignOutResponse struct {
// SignOut, when set, is where the browser goes next to end its
// SystemAuth login session.
SignOut *SystemAuthSignOut `json:"signOut,omitempty"`
}
SignOutResponse answers POST {base}/auth/logout for a client that asks for JSON.
type SystemAuthSignOut ¶
SystemAuthSignOut is SystemAuth's sign-out, which the browser reaches with a form POST: URL, with a return_to field of ReturnTo, where SystemAuth sends it afterwards. SystemAuth accepts the POST from the app's origin.
type TokenConfirmation ¶ added in v0.21.0
type TokenConfirmation struct {
// JKT is the JWK SHA-256 thumbprint of the DPoP key the token is bound
// to.
JKT string `json:"jkt,omitempty"`
}
TokenConfirmation is the cnf claim of an access token.
type User ¶
type User struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Managed bool `json:"managed,omitempty"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
Memberships []UserMembership `json:"memberships"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
User is the frontend's user representation.
type UserInfo ¶
type UserInfo struct {
Subject string `json:"sub"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
// EmailVerifiedAt is SystemForge's email_verified_at extension: when
// the email was verified, as a NumericDate (seconds since the epoch,
// which may arrive in floating-point form); nil when absent.
EmailVerifiedAt *gojwt.NumericDate `json:"email_verified_at,omitempty"`
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
// Raw holds every returned claim.
Raw map[string]any `json:"-"`
}
UserInfo is the SystemAuth UserInfo response.
type UserMembership ¶
type UserMembership struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
OrganizationName string `json:"organization_name"`
OrganizationSlug string `json:"organization_slug"`
OrganizationType string `json:"organization_type,omitempty"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
// Applications maps application identifiers to the user's role there.
Applications map[string]string `json:"applications,omitempty"`
}
UserMembership is the frontend's organization membership.
type VerifiedIdentity ¶
type VerifiedIdentity struct {
// Subject is the OIDC sub: the SystemAuth principal ID. Applications
// store it as sf_principal_id.
Subject string
// Email is the SystemAuth-reported email.
Email string
// EmailVerified reports whether SystemAuth asserts Email is verified.
EmailVerified bool
// EmailVerifiedAt is when SystemAuth verified Email (the
// email_verified_at claim); zero when it is not verified or the time
// was not sent.
EmailVerifiedAt time.Time
// Name is the display name.
Name string
// Picture is the avatar URL.
Picture string
// Managed reports a managed principal (the managed claim): a human
// without an email whose managers control the account (ADR-005).
// Managed identities have no email.
Managed bool
// Service reports a service account (a principal_type of "service" in
// an access token): created with type service and no email, never by
// signing in. See BearerConfig.ServicePrincipals.
Service bool
}
VerifiedIdentity is a SystemAuth identity whose ID token (and UserInfo) passed verification.
func IdentityFromClaims ¶
func IdentityFromClaims(idt *IDTokenClaims, info *UserInfo) (VerifiedIdentity, error)
IdentityFromClaims builds the verified identity from a verified ID token and, when available, the UserInfo response (which must be for the same subject, OIDC Core §5.3.2). UserInfo values take precedence as the fresher ones.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package pgstore provides PostgreSQL-backed relying-party stores: a relyingparty.SessionStore and a relyingparty.LoginStateStore that survive restarts and are shared by every instance of the application.
|
Package pgstore provides PostgreSQL-backed relying-party stores: a relyingparty.SessionStore and a relyingparty.LoginStateStore that survive restarts and are shared by every instance of the application. |
|
Package storetest is a conformance suite for relying-party stores.
|
Package storetest is a conformance suite for relying-party stores. |