authn

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 11, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

authn

JWT-based authentication. Validates tokens against a JWKS endpoint, extracts identity claims, and provides HTTP middleware that injects an AuthContext into each request's context.

Configuration

import "github.com/OpenNSW/core/authn"

manager, err := authn.NewManager(userProfileSvc, authn.Config{
    JWKSURL:   "https://idp.example.com/.well-known/jwks.json",
    Issuer:    "https://idp.example.com",
    Audience:  "my-api",
    ClientIDs: []string{"my-m2m-client"},

    // Optional: declare any claims beyond the fixed schema you need. See
    // "Extra claims" below.
    UserClaims: authn.ClaimSpec{Optional: []string{"email", "ouId", "ouHandle"}},
})

UserProfileService is optional. When provided, it is called on the first appearance of a user token to create or retrieve a persisted user record (e.g. to assign an internal user ID). Pass nil to skip user persistence.

Note: every token must carry a grant_type claim — it decides user vs. client principal, and a token without it is rejected with unsupported grant type. This is not a standard access-token claim (RFC 9068 does not define it), so an IdP that omits it will not work with this package as-is.

Middleware

// 401 if no valid token
mux.Handle("/api/v1/tasks", manager.RequireAuthMiddleware()(handler))

// Proceeds with or without a token; handler checks presence itself
mux.Handle("/api/v1/public", manager.OptionalAuthMiddleware()(handler))

Reading identity in handlers

authCtx := authn.GetAuthContext(r.Context())
if authCtx == nil {
    // no token present (only possible with OptionalAuthMiddleware)
    return
}

// Human user token
if authCtx.Type() == authn.UserPrincipalType {
    fmt.Println(authCtx.User.ID)                             // internal persisted ID (if UserProfileService set)
    fmt.Println(authCtx.User.Roles)                          // role claims
    fmt.Println(authCtx.User.ExtraClaims.String("email"))    // "" unless declared — see "Extra claims"
    fmt.Println(authCtx.User.ExtraClaims.String("ouHandle"))
}

// Machine-to-machine token
if authCtx.Type() == authn.ClientPrincipalType {
    fmt.Println(authCtx.Client.ClientID)
}

AuthContext also exposes a nil-safe accessor seam that works for either principal type without branching — Type(), Subject(), Roles(), Scopes(), and ExtraClaims():

// Safe even when authCtx is nil or the request was a client token.
email := authn.GetAuthContext(r.Context()).ExtraClaims().String("email")

Extra claims (beyond the fixed schema)

authn's fixed schema is deliberately small: client_id, grant_type, scope, and roles (plus the JWT registered claims). Anything else — email, phone_number, ouId, ouHandle, an IdP-specific claim like given_name, or anything your identity provider emits — is declared explicitly via Config fields (Manager path) or functional options (direct TokenExtractor construction). Separate declarations exist for user-principal (authorization_code grant) and client-principal (client_credentials grant) tokens, since some claims are only meaningful for one or the other.

manager, err := authn.NewManager(userProfileSvc, authn.Config{
    JWKSURL:   "...",
    Issuer:    "...",
    Audience:  "...",
    ClientIDs: []string{"my-m2m-client"},

    UserClaims: authn.ClaimSpec{
        Optional: []string{"email", "ouId", "ouHandle", "given_name"},
        Required: []string{"phone_number"},
    },
    ClientClaims: authn.ClaimSpec{
        Optional: []string{"department"},
    },
})

Or, constructing a TokenExtractor directly:

extractor, err := authn.NewTokenExtractor(jwksURL, issuer, audience, clientIDs,
    authn.WithUserClaims(authn.ClaimSpec{
        Optional: []string{"email", "ouId", "ouHandle"},
        Required: []string{"phone_number"},
    }),
)

Reading extra claims in a handler:

authCtx := authn.GetAuthContext(r.Context())
if authCtx == nil || authCtx.Type() != authn.UserPrincipalType {
    return
}
email  := authCtx.User.ExtraClaims.String("email")     // "" if not declared/present
groups := authCtx.User.ExtraClaims.Strings("groups")   // JSON array of strings, or a space-delimited string
roles  := authCtx.User.Roles                           // roles are fixed schema, not an extra claim

If a UserProfileService implementation needs one of these values (e.g. to scope users by organization), declare it and read it from the principal passed into GetOrCreateUser — see "Principal fields" below.

Semantics:

  • User-scoped vs. client-scoped. UserClaims applies only to user-principal tokens, ClientClaims only to client-principal tokens. A name declared on one side is never extracted from the other.
  • Optional: a claim that is absent, JSON-null, or an empty/whitespace-only string is silently skipped — the token is never rejected.
  • Required: the token is rejected unless the claim carries a usable value — a non-blank string, or a non-empty array of non-blank strings. A number, boolean, object, empty array or mixed array is not accepted, because it would read back as ""/nil and silently defeat the requirement. A name listed in both Optional and Required (in any order, across any number of calls) is required.
  • Claim names are matched exactly — JWT claim names are case-sensitive (RFC 7519 §4) and only whitespace-trimmed. Nested lookups are not supported: a name is a top-level key, which is what lets namespaced names like https://app.example.com/roles work verbatim.
  • Values are the claim's JSON-decoded Go representation, with one normalization: every JSON string a value directly carries is whitespace-trimmed — the value itself, and the elements of a top-level array of strings. Nested objects are never rewritten. (Fixed-schema claims such as sub are decoded separately and are not trimmed.)
  • Use ExtraClaims.String(name) for a plain string value and ExtraClaims.Strings(name) for a list-shaped claim. Strings accepts a JSON array of strings or a single space-delimited string (the convention the scope claim uses), so it whitespace-splits free text — use String for anything that is not list-shaped. Both are nil-safe and return the zero value for absent/wrong-shape claims; they never panic or error.
  • A claim already bound by authn — the fixed schema, any case variant of it, or your configured RolesClaim — cannot be declared as an extra claim. Construction (or Config.Validate()) fails fast. Case variants are rejected because encoding/json matches struct tags case-insensitively, so a payload key of Roles still lands in the fixed-schema roles field.
  • A blank claim name is a construction error, not a silent no-op — a trailing comma in MY_CLAIMS=email, would otherwise drop a rule you asked for.

Remapping the roles claim

Roles populate Principal.Roles and AuthContext.Roles(), which is what the authz seam consumes. If your IdP does not emit a top-level roles claim, point authn at the one it does emit:

authn.Config{ /* ... */ RolesClaim: "groups" }   // or authn.WithRolesClaim("groups")

The claim must be a top-level JSON array of strings — exactly the shape the default roles claim must have. An absent claim yields no roles; a present claim of any other shape rejects the token, so a mistyped name fails loudly on the first request rather than silently disabling every role check.

Dotted paths into nested objects (Keycloak's realm_access.roles) are not supported — flatten them with an IdP-side protocol mapper. Exact matching is what keeps namespaced names such as https://app.example.com/roles working.

Principal fields

UserContext

Field Description
ID Internal persisted user ID (set by UserProfileService)
IDPUserID Subject claim from the token
Roles Role claims
Scopes Scope claims
ExtraClaims Claims beyond the fixed schema, populated only for names you declared — see "Extra claims" above

ClientContext

Field Description
ClientID Client ID claim
Roles Role claims
Scopes Scope claims
ExtraClaims Claims beyond the fixed schema, populated only for names you declared — see "Extra claims" above

UserProfileService

type UserProfileService interface {
    GetOrCreateUser(ctx context.Context, principal *UserPrincipal) (string, error)
}

Called on first login for user-principal tokens. principal.Subject is the JWT sub claim — the IdP's user ID, not the persisted ID you return. principal.ExtraClaims holds whatever claims you declared; index it with principal.ExtraClaims.String("email"), etc.

principal is never nil and must not be mutated: the middleware has already built the request's AuthContext from it and shares the same ExtraClaims map.

Migrating from the fixed email/phone/ouId/ouHandle fields

Those four claims used to be fixed-schema fields, populated on every user token. They are IdP-specific rather than standard OIDC, so they are now ordinary extra claims that you declare.

Before After
authCtx.User.Email authCtx.User.ExtraClaims.String("email") + declare "email"
authCtx.User.PhoneNumber (*string) ...String("phone_number") + declare it
authCtx.User.OUID ...String("ouId") + declare it
authCtx.User.OUHandle ...String("ouHandle") + declare it
UserPrincipal.UserID UserPrincipal.Subject
GetOrCreateUser(ctx, idpUserID, email, phone, ouID, ouHandle) GetOrCreateUser(ctx, principal)

Declaring is not optional. An undeclared claim reads back as "" even when the signed token carries it, and nothing warns you — the compiler catches the removed struct fields and the changed GetOrCreateUser signature, but it cannot catch a missing declaration. If a value is load-bearing (an organization/tenant identifier used for scoping, say), put it in Required so a token without it is rejected rather than silently scoped to "":

UserClaims: authn.ClaimSpec{
    Required: []string{"ouId"},
    Optional: []string{"email", "phone_number", "ouHandle"},
},

Earlier versions rejected a user token missing email, ouId, or ouHandle. That check is now yours to declare.

Health check

if err := manager.Health(); err != nil {
    // auth components are not initialized
    // (this does NOT check JWKS reachability)
}

Authorization

authn handles identity only. For scope enforcement use the authz package — *AuthContext satisfies authz.Principal directly.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Middleware

func Middleware(userProfileService UserProfileService, tokenExtractor *TokenExtractor) func(http.Handler) http.Handler

Middleware creates an HTTP middleware that extracts and injects authentication context. This middleware: 1. Extracts the Authorization header 2. Parses the token into a user principal or client principal 3. For user principals, resolves user profile idempotently if UserProfileService is provided 4. Injects the auth context into the request

Behavior summary: - Missing Authorization header: request proceeds without auth context. - Invalid token: request is rejected with 401. - Auth dependencies unavailable: request is rejected with 500. - User principal on first login: resolves (get-or-create) user profile if service is provided.

This design allows: - Public endpoints (no auth required) - Protected endpoints (check for context) - Optional auth endpoints (use context if available) - Generic auth that works with or without a user profile service

func RequireAuth

func RequireAuth(userProfileService UserProfileService, tokenExtractor *TokenExtractor) func(http.Handler) http.Handler

RequireAuth returns a middleware that requires authentication. If no auth context is found, returns 401 Unauthorized. This middleware should be applied to protected endpoints.

Usage:

mux.Handle("POST /api/protected", auth.RequireAuth(userProfileService, tokenExtractor)(handler))

TODO_JWT_FUTURE: Consider adding: - Different auth levels (basic, standard, admin) - Claim validation beyond token signature - Rate limiting per user

Types

type AllowedGrantType

type AllowedGrantType string
const (
	AuthorizationCodeGrant AllowedGrantType = "authorization_code"
	ClientCredentialsGrant AllowedGrantType = "client_credentials"
)

type AuthContext

type AuthContext struct {
	User   *UserContext
	Client *ClientContext
}

AuthContext is the transient authentication context injected into each request by the auth middleware. For user principals, User contains identity fields and roles. For client principals (M2M), Client is set.

func GetAuthContext

func GetAuthContext(ctx context.Context) *AuthContext

GetAuthContext extracts the AuthContext from a request context. Returns nil if no auth context is available (for example: public route, missing auth header, or middleware not applied).

Usage in handlers:

authCtx := auth.GetAuthContext(r.Context())
if authCtx == nil {
    // Handle unauthorized request
}
userID := authCtx.User.ID

func (*AuthContext) ExtraClaims added in v0.3.0

func (a *AuthContext) ExtraClaims() ExtraClaims

ExtraClaims returns the consumer-declared extra claims for the principal (user or client), or nil. Nil-safe, and ExtraClaims' own methods are nil-safe too, so authCtx.ExtraClaims().String("email") never panics.

Like Roles and Scopes, this returns the live value rather than a copy — do not mutate it.

func (*AuthContext) Roles

func (a *AuthContext) Roles() []string

Roles returns the granted roles for the principal (user or client), or nil.

func (*AuthContext) Scopes

func (a *AuthContext) Scopes() []string

Scopes returns the granted OAuth2 scopes for the principal (user or client), or nil.

func (*AuthContext) Subject

func (a *AuthContext) Subject() string

Subject returns a stable identifier for the principal: the resolved user ID (falling back to the IdP user ID) for users, the client ID for clients, or "".

func (*AuthContext) Type

func (a *AuthContext) Type() PrincipalType

Type reports the principal type of the context: UserPrincipalType, ClientPrincipalType, or "" when unauthenticated.

type ClaimSpec added in v0.3.0

type ClaimSpec struct {
	Optional []string
	Required []string
}

ClaimSpec declares the extra JWT claims — those beyond authn's fixed schema (client_id, grant_type, scope, roles, and the JWT registered claims) — to extract for one principal type.

Optional claims are best-effort: a name that is absent, JSON-null, or an empty/whitespace-only string is silently skipped and never fails a token. Required claims must carry a usable value — a non-blank string, or a non-empty array of non-blank strings — or ExtractPrincipalFromHeader rejects the token. A name listed in both is required.

Names are matched exactly against the token payload: JWT claim names are case-sensitive (RFC 7519 §4). Nested lookups are not supported; a name is a top-level key, which is what makes namespaced names like "https://app.example.com/roles" work unchanged.

type ClientContext

type ClientContext struct {
	ClientID    string      `json:"clientId"`
	Roles       []string    `json:"roles"`
	Scopes      []string    `json:"scopes"`
	ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

ClientContext represents a machine client's context.

type ClientPrincipal

type ClientPrincipal struct {
	ClientID    string      `json:"clientId"`
	Roles       []string    `json:"roles"`
	Scopes      []string    `json:"scopes"`
	ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

type Config

type Config struct {
	JWKSURL               string
	Issuer                string
	Audience              string
	ClientIDs             []string
	InsecureSkipTLSVerify bool

	// UserClaims declares extra JWT claims (beyond authn's fixed schema, e.g.
	// "email", "phone_number", "ouId", "ouHandle", "given_name") to extract
	// for user-principal (authorization_code grant) tokens. ClientClaims is
	// the client-credential (M2M) analogue. See WithUserClaims /
	// WithClientClaims. Zero value = no extra claims extracted.
	UserClaims   ClaimSpec
	ClientClaims ClaimSpec

	// RolesClaim overrides which claim carries the principal's roles, for
	// IdPs that do not emit a top-level "roles" claim. See WithRolesClaim.
	// Empty = "roles".
	RolesClaim string
}

func (Config) Validate

func (c Config) Validate() error

type ContextKey

type ContextKey string

ContextKey is a custom type for context keys to avoid collisions.

const AuthContextKey ContextKey = "authContext"

type ExtraClaims added in v0.3.0

type ExtraClaims map[string]any

ExtraClaims holds JWT claims outside authn's fixed schema, declared explicitly by a consumer via WithUserClaims (user principals) or WithClientClaims (client principals).

Values are the claim's JSON-decoded Go representation (string, []any, float64, bool, map[string]any), with one normalization: every JSON string a value directly carries is whitespace-trimmed — the value itself when it is a string, and the elements of a top-level array of strings. Nested objects are never rewritten, so what you index out of a map-valued claim is byte-for-byte what the IdP sent. Note the fixed-schema claims (sub, client_id, and the registered claims) are decoded by jwt.ParseWithClaims and are NOT trimmed.

Use String/Strings for the common shapes; index the map directly to read anything else. A nil or empty ExtraClaims is safe to read from and safe to call methods on, but is not writable — copy it before mutating.

func (ExtraClaims) String added in v0.3.0

func (c ExtraClaims) String(name string) string

String returns the claim's value as a string, or "" if the claim is absent or not a JSON string (e.g. "email", "ouId", "ouHandle", "given_name").

func (ExtraClaims) Strings added in v0.3.0

func (c ExtraClaims) Strings(name string) []string

Strings returns the claim's value as a string slice. It accepts a JSON array of strings, or a single space-delimited string — mirroring how the OAuth2 "scope" claim is parsed by spaceDelimitedScope in token_parser.go. An empty array, or an empty/whitespace-only string, returns an empty (non-nil) slice; an absent claim or any other shape (including an array with a non-string element) returns nil.

The space-splitting makes this the wrong accessor for a free-text claim: Strings("department") on "Customs Division" returns two elements. Use String for anything that is not list-shaped.

type Manager

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

Manager handles all authentication-related operations and middleware setup. It encapsulates the token extraction and middleware creation, providing a clean interface for the HTTP server to use.

This manager pattern keeps auth logic self-contained and optionally delegates user persistence to a UserProfileService (if provided). The manager can work without a user profile service, making it suitable for any authentication use case.

func NewManager

func NewManager(userProfileService UserProfileService, authConfig Config) (*Manager, error)

NewManager creates and initializes a new auth manager. This is the single entry point for all auth initialization in the application.

userProfileService is OPTIONAL. If not provided (nil), user creation on first login is disabled. tokenExtractor is REQUIRED and is always initialized by this constructor. This allows the auth package to be used in systems that don't track user profiles while still guaranteeing auth token parsing is available.

Usage examples:

// With user profile service (NSW example)
userProfileService := user.NewService(db)
authManager := auth.NewManager(userProfileService, cfg.Auth)

// Without user profile service (generic auth only)
authManager := auth.NewManager(nil, cfg.Auth)

// With custom user profile service
customService := &MyCustomUserService{}
authManager := auth.NewManager(customService, cfg.Auth)

func (*Manager) Close

func (m *Manager) Close() error

Close performs any cleanup needed for the auth manager. Currently a no-op, but provided for future extensibility (e.g., closing JWT validators, flushing caches, logging stats).

Usage:

authManager := auth.NewManager(userService, cfg.Auth)
defer authManager.Close()

func (*Manager) Health

func (m *Manager) Health() error

Health checks if the auth system is functioning properly. Since the UserProfileService is optional, this only verifies that the auth system components are initialized correctly.

Usage in server startup:

authManager := auth.NewManager(userProfileService, cfg.Auth)
if err := authManager.Health(); err != nil {
    log.Fatalf("auth system health check failed: %v", err)
}

Returns an error if auth system components are misconfigured.

func (*Manager) Middleware

func (m *Manager) Middleware() func(http.Handler) http.Handler

Middleware returns the auth middleware function. This middleware extracts and injects authentication context into requests.

Usage:

handler := authManager.Middleware()(mux)

The middleware: 1. Extracts Authorization header 2. Parses token into user or client principal 3. For user principals, creates user record if it's their first login 4. Injects context into request

func (*Manager) OptionalAuthMiddleware

func (m *Manager) OptionalAuthMiddleware() func(http.Handler) http.Handler

OptionalAuthMiddleware returns a middleware for endpoints that work with or without auth. This is the same as the basic Middleware() - included for semantic clarity.

Usage:

mux.Handle("GET /api/products",
    authManager.OptionalAuthMiddleware()(handler),
)

The handler can check if auth context is available and personalize response.

func (*Manager) RequireAuthMiddleware

func (m *Manager) RequireAuthMiddleware() func(http.Handler) http.Handler

RequireAuthMiddleware returns a middleware that requires authentication. If no auth context is found, returns 401 Unauthorized. Use this for protected endpoints.

Usage:

mux.Handle("POST /api/protected",
    authManager.RequireAuthMiddleware()(handler),
)

type Option added in v0.3.0

type Option func(*TokenExtractor)

Option configures optional behavior on TokenExtractor construction.

Options never fail on their own: they record intent verbatim and every check runs afterwards in validateConfig, so NewTokenExtractor reports a bad declaration as a construction error regardless of the order options were passed in.

func WithClientClaims added in v0.3.0

func WithClientClaims(spec ClaimSpec) Option

WithClientClaims is the WithUserClaims analogue for client-credential (M2M) tokens, populating ClientPrincipal.ExtraClaims / ClientContext.ExtraClaims.

The declaration is separate because the two token types carry different claim sets: a client token may carry e.g. "department" or "cost_center", while "email"/"ouHandle"/"given_name" only appear on user tokens. A name declared for one principal type is never extracted from the other.

func WithRolesClaim added in v0.3.0

func WithRolesClaim(name string) Option

WithRolesClaim points authn at a differently-named claim for the roles that populate Principal.Roles and AuthContext.Roles(), for identity providers that do not emit a top-level "roles" claim (Okta commonly uses "groups"). Defaults to "roles". The name applies to both user and client principals.

The claim must be a top-level JSON array of strings — exactly the shape the default "roles" claim must have. An absent claim yields no roles; a present claim of any other shape rejects the token, so a mistyped name fails loudly on the first request instead of silently disabling every role check.

Dotted paths into nested objects (Keycloak's realm_access.roles) are NOT supported: names are matched exactly, which is what keeps namespaced claim names such as "https://app.example.com/roles" working. Flatten a nested roles claim with an IdP-side protocol mapper.

The configured name is reserved: it cannot also be declared as an extra claim via WithUserClaims/WithClientClaims, since it already has a dedicated field.

func WithUserClaims added in v0.3.0

func WithUserClaims(spec ClaimSpec) Option

WithUserClaims declares extra claims to extract from user-principal (authorization_code grant) tokens. Extracted values surface on UserPrincipal.ExtraClaims / UserContext.ExtraClaims.

Repeated calls accumulate rather than replace, so a name declared required by any call stays required.

type Principal

type Principal struct {
	Type            PrincipalType    `json:"type"`
	UserPrincipal   *UserPrincipal   `json:"userPrincipal,omitempty"`
	ClientPrincipal *ClientPrincipal `json:"clientPrincipal,omitempty"`
}

type PrincipalType

type PrincipalType string
const (
	UserPrincipalType   PrincipalType = "user"
	ClientPrincipalType PrincipalType = "client"
)

type TokenExtractor

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

TokenExtractor handles token extraction and parsing from HTTP headers. It validates JWT signatures using JWKS and resolves a user principal or client principal based on grant type.

func NewTokenExtractor

func NewTokenExtractor(jwksURL, issuer, audience string, expectedClientIDs []string, opts ...Option) (*TokenExtractor, error)

func NewTokenExtractorWithClient

func NewTokenExtractorWithClient(jwksURL, issuer, audience string, expectedClientIDs []string, httpClient *http.Client, opts ...Option) (*TokenExtractor, error)

func (*TokenExtractor) ExtractPrincipalFromHeader

func (te *TokenExtractor) ExtractPrincipalFromHeader(authHeader string) (*Principal, error)

ExtractPrincipalFromHeader extracts the principal from Authorization header. Expected header format: "Bearer <jwt_token>". JWT signature is validated against configured JWKS endpoint, then claims are mapped into either UserPrincipal or ClientPrincipal.

type UserContext

type UserContext struct {
	ID          string      `json:"id"`
	IDPUserID   string      `json:"idpUserId"`
	Roles       []string    `json:"roles"`
	Scopes      []string    `json:"scopes"`
	ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

UserContext represents a user principal's runtime context injected into each request. It includes identity fields and principal-derived roles. Note: Per-request NSWData is not persisted here; services requiring user metadata should call the user profile service on-demand.

type UserPrincipal

type UserPrincipal struct {
	// Subject is the JWT "sub" claim: the identity provider's ID for the user.
	// It is NOT the internally persisted user ID a UserProfileService returns
	// — see UserContext, which carries both as ID and IDPUserID.
	Subject     string      `json:"subject"`
	Roles       []string    `json:"roles"`
	Scopes      []string    `json:"scopes"`
	ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

type UserProfileService

type UserProfileService interface {
	// GetOrCreateUser creates or retrieves a user profile.
	//
	// principal is never nil, and MUST NOT be mutated: the middleware has
	// already built the request's AuthContext from it and shares the same
	// ExtraClaims map, so writing to it would alter the live request context.
	// (ExtraClaims is also nil unless claims were declared — nil maps read
	// fine but panic on assignment.)
	//
	//   - principal.Subject is the identity provider's user ID (the JWT "sub"
	//     claim), NOT the persisted ID this method returns.
	//   - principal.ExtraClaims holds the claims declared via WithUserClaims /
	//     Config.UserClaims; nil if none were declared.
	//
	// Implementation notes:
	//   - Should be idempotent: calling multiple times with the same subject should be safe
	//   - Called during first login after token validation
	//   - Errors are logged but don't block authentication
	//   - Should not return error if user already exists
	// Returns user ID of the created or existing user, or an error if the operation fails.
	GetOrCreateUser(ctx context.Context, principal *UserPrincipal) (string, error)
}

UserProfileService defines the contract for managing user profiles. Implementations are responsible for persisting and managing user records in their system.

This interface is OPTIONAL when using the auth package. If not provided (nil), user creation on first login will be skipped. This allows:

1. Systems that don't track user profiles - just use auth for token validation 2. Systems that manage user profiles separately - implement this interface 3. Systems that handle user creation elsewhere - pass nil

The whole authenticated principal is passed rather than a list of named identity values, because everything except the JWT "sub" claim (email, phone number, organization/tenant identifiers, ...) is IdP/consumer-specific. Naming those as parameters is what forced this interface to change once already; passing the principal means adding a claim never changes the signature again.

Example implementation:

type MyUserService struct {
    db *sql.DB
}

func (s *MyUserService) GetOrCreateUser(ctx context.Context, principal *authn.UserPrincipal) (string, error) {
    // Your implementation to create or fetch the user idempotently
    email := principal.ExtraClaims.String("email")
    persistedID := "generated-id"
    if err := s.db.Exec("INSERT INTO users ...", principal.Subject, email).Error; err != nil {
        return "", err
    }
    return persistedID, nil
}

authManager := auth.NewManager(myUserService, cfg.Auth)  // myUserService can be nil

Jump to

Keyboard shortcuts

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