Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 119 additions & 130 deletions authn/README.md

Large diffs are not rendered by default.

61 changes: 22 additions & 39 deletions authn/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,29 @@ type Config struct {
Audience string `yaml:"audience"`
InsecureSkipTLSVerify bool `yaml:"insecureSkipTLSVerify"`

// 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 `yaml:"userClaims"`
ClientClaims ClaimSpec `yaml:"clientClaims"`
// Claims configures the standard identity claims. See WithClaims. Zero
// value = every claim optional.
Claims ClaimsConfig `yaml:"claims"`
}

// ClaimsConfig configures each standard identity claim — "email", "ouId" and
// "ouHandle" — which populate the Principal fields of the same name.
//
// ClaimsConfig carries yaml struct tags, so it can be populated generically
// (e.g. via yaml.Unmarshal) as part of Config.
type ClaimsConfig struct {
Email ClaimConfig `yaml:"email"`
OUID ClaimConfig `yaml:"ouId"`
OUHandle ClaimConfig `yaml:"ouHandle"`
}

// 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 `yaml:"rolesClaim"`
// ClaimConfig configures one standard identity claim.
type ClaimConfig struct {
// Required rejects a token whose claim is absent or blank. The
// requirement applies to every token, including client-credentials
// tokens issued to systems, which typically carry no email or
// organization unit. Default false: the Principal field is "" instead.
Required bool `yaml:"required"`
}

func (c Config) Validate() error {
Expand All @@ -48,33 +59,5 @@ func (c Config) Validate() error {
if c.Audience == "" {
return fmt.Errorf("audience is required")
}

if err := validateRolesClaimName(c.rolesClaim()); err != nil {
return err
}
for _, decl := range []struct {
what string
names []string
}{
{"UserClaims.Optional", c.UserClaims.Optional},
{"UserClaims.Required", c.UserClaims.Required},
{"ClientClaims.Optional", c.ClientClaims.Optional},
{"ClientClaims.Required", c.ClientClaims.Required},
} {
if err := validateClaimNames(decl.what, decl.names, c.rolesClaim()); err != nil {
return err
}
}

return nil
}

// rolesClaim resolves the configured roles claim name, treating an unset field
// as the default. A whitespace-only value is left as-is so Validate reports it
// as a typo rather than silently defaulting.
func (c Config) rolesClaim() string {
if c.RolesClaim == "" {
return defaultRolesClaim
}
return c.RolesClaim
}
70 changes: 0 additions & 70 deletions authn/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,73 +44,3 @@ func TestConfigValidate(t *testing.T) {
})
}
}

func TestConfigValidate_RejectsFixedSchemaExtraClaim(t *testing.T) {
valid := Config{
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
}

tests := []struct {
name string
mutate func(*Config)
}{
{"optional user claim", func(c *Config) { c.UserClaims.Optional = []string{"scope"} }},
{"required user claim", func(c *Config) { c.UserClaims.Required = []string{"client_id"} }},
{"optional client claim", func(c *Config) { c.ClientClaims.Optional = []string{"roles"} }},
{"required client claim", func(c *Config) { c.ClientClaims.Required = []string{"sub"} }},
// Case variants: encoding/json binds struct tags case-insensitively,
// so these still land in the fixed-schema fields and must be rejected.
{"case-variant user claim", func(c *Config) { c.UserClaims.Optional = []string{"Roles"} }},
{"case-variant client claim", func(c *Config) { c.ClientClaims.Optional = []string{"Client_Id"} }},
{"blank claim name", func(c *Config) { c.UserClaims.Required = []string{"email", " "} }},
{"roles claim collides with extra claim", func(c *Config) {
c.RolesClaim = "groups"
c.UserClaims.Optional = []string{"groups"}
}},
{"roles claim is a fixed-schema name", func(c *Config) { c.RolesClaim = "scope" }},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := valid
tt.mutate(&cfg)
if err := cfg.Validate(); err == nil {
t.Fatalf("expected error for fixed-schema claim declaration")
}
})
}
}

// TestConfigValidate_AllowsFormerlyFixedClaims locks in the schema shrink at
// the Config layer too: email/phone_number/ouId/ouHandle used to be
// hardcoded fixed-schema fields and are now legitimate extra-claim
// declarations.
func TestConfigValidate_AllowsFormerlyFixedClaims(t *testing.T) {
cfg := Config{
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
UserClaims: ClaimSpec{Optional: []string{"email", "phone_number", "ouId", "ouHandle", "given_name"}},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}

// TestConfigValidate_AllowsNamespacedClaimNames locks in that a claim name is
// matched exactly rather than parsed: Auth0-style namespaced claims contain
// dots and slashes and must not be mistaken for a nested path.
func TestConfigValidate_AllowsNamespacedClaimNames(t *testing.T) {
cfg := Config{
JWKSURL: "https://localhost/jwks",
Issuer: "https://localhost/token",
Audience: "TRADER_PORTAL_APP",
RolesClaim: "https://app.example.com/roles",
UserClaims: ClaimSpec{Optional: []string{"https://app.example.com/department"}},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
189 changes: 51 additions & 138 deletions authn/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,90 +7,11 @@ import (
"context"
)

// 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
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)
}

// 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 UserContext struct {
ID string `json:"id"`
IDPUserID string `json:"idpUserId"`
Roles []string `json:"roles"`
Scopes []string `json:"scopes"`
ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

// ClientContext represents a machine client's context.
type ClientContext struct {
ClientID string `json:"clientId"`
Roles []string `json:"roles"`
Scopes []string `json:"scopes"`
ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

// 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.
// by the auth middleware. It carries the Principal resolved from the request's
// token.
type AuthContext struct {
User *UserContext
Client *ClientContext
Principal Principal
}

// ContextKey is a custom type for context keys to avoid collisions.
Expand All @@ -108,7 +29,7 @@ const AuthContextKey ContextKey = "authContext"
// if authCtx == nil {
// // Handle unauthorized request
// }
// userID := authCtx.User.ID
// subject := authCtx.Subject()
func GetAuthContext(ctx context.Context) *AuthContext {
authCtx, ok := ctx.Value(AuthContextKey).(*AuthContext)
if !ok {
Expand All @@ -118,87 +39,79 @@ func GetAuthContext(ctx context.Context) *AuthContext {
}

// The methods below form a small, stable seam over the authenticated principal so
// that a future authz layer can depend on a narrow interface it defines itself
// that an authz layer can depend on a narrow interface it defines itself
// (e.g. interface{ Roles() []string; Scopes() []string; Subject() string }) which
// *AuthContext satisfies structurally — rather than reaching into the concrete
// User/Client fields or branching on principal type. They are nil-safe.
// *AuthContext satisfies structurally. They are nil-safe.

// Type reports the principal type of the context: UserPrincipalType,
// ClientPrincipalType, or "" when unauthenticated.
func (a *AuthContext) Type() PrincipalType {
switch {
case a == nil:
// Subject returns the principal's JWT "sub" claim, or "" when unauthenticated.
func (a *AuthContext) Subject() string {
if a == nil {
return ""
case a.User != nil:
return UserPrincipalType
case a.Client != nil:
return ClientPrincipalType
default:
}
return a.Principal.Subject
}

// ClientID returns the OAuth2 client the principal's token was issued to, or ""
// when unauthenticated.
func (a *AuthContext) ClientID() string {
if a == nil {
return ""
}
return a.Principal.ClientID
}

// 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 (a *AuthContext) Subject() string {
switch {
case a == nil:
// Email returns the principal's "email" claim, or "" when absent or
// unauthenticated.
func (a *AuthContext) Email() string {
if a == nil {
return ""
case a.User != nil:
if a.User.ID != "" {
return a.User.ID
}
return a.User.IDPUserID
case a.Client != nil:
return a.Client.ClientID
default:
}
return a.Principal.Email
}

// OUID returns the principal's "ouId" claim, or "" when absent or
// unauthenticated.
func (a *AuthContext) OUID() string {
if a == nil {
return ""
}
return a.Principal.OUID
}

// Roles returns the granted roles for the principal (user or client), or nil.
// OUHandle returns the principal's "ouHandle" claim, or "" when absent or
// unauthenticated.
func (a *AuthContext) OUHandle() string {
if a == nil {
return ""
}
return a.Principal.OUHandle
}

// Roles returns the granted roles for the principal, or nil.
func (a *AuthContext) Roles() []string {
switch {
case a == nil:
return nil
case a.User != nil:
return a.User.Roles
case a.Client != nil:
return a.Client.Roles
default:
if a == nil {
return nil
}
return a.Principal.Roles
}

// Scopes returns the granted OAuth2 scopes for the principal (user or client), or nil.
// Scopes returns the granted OAuth2 scopes for the principal, or nil.
func (a *AuthContext) Scopes() []string {
switch {
case a == nil:
return nil
case a.User != nil:
return a.User.Scopes
case a.Client != nil:
return a.Client.Scopes
default:
if a == nil {
return nil
}
return a.Principal.Scopes
}

// 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.
// ExtraClaims returns the principal's claims beyond the fixed schema, 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 (a *AuthContext) ExtraClaims() ExtraClaims {
switch {
case a == nil:
return nil
case a.User != nil:
return a.User.ExtraClaims
case a.Client != nil:
return a.Client.ExtraClaims
default:
if a == nil {
return nil
}
return a.Principal.ExtraClaims
}
Loading
Loading