Help us shape the future of Go! Take the 2026 Go Developer Survey

authn

package
v0.6.1 Latest Latest
Warning

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

Go to latest
Published: Oct 28, 2026 License: Apache-2.0 Imports: 15 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(authn.Config{
    JWKSURL:  "https://idp.example.com/.well-known/jwks.json",
    Issuer:   "https://idp.example.com",
    Audience: "my-api",

    // Optional: make standard identity claims required. See "Standard identity claims" below.
    Claims: authn.ClaimsConfig{
        OUID: authn.ClaimConfig{Required: true},
    },
})

Config carries yaml struct tags, so it can be embedded in a larger application config struct and populated generically (e.g. via yaml.Unmarshal):

authn:
  jwksURL: https://idp.example.com/.well-known/jwks.json
  issuer: https://idp.example.com
  audience: my-api
  claims:
    ouId:
      required: true

A token is accepted when its signature, iss, aud and exp check out, it carries non-empty sub and client_id claims, and it carries every standard identity claim configured as required. How the token was obtained (grant_type) plays no part: a person signing in through a client and a system using client credentials get the same Principal, distinguished only by the roles, scopes and claims they carry. Every other claim in the token is passed through as ExtraClaims — nothing has to be declared.

Note: audience is what binds a token to this service: any client the IdP issues a token for this audience is accepted. Which clients may call the service is authorization policy, not this package's concern — restrict it in authz or the application using AuthContext.ClientID(). If your IdP sets aud to the requesting client rather than the API, configure an audience / API resource for the service in the IdP.

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

authn only authenticates. Anything your application does with the validated principal — provisioning a user record, loading a profile, attaching tenant data — goes in your own middleware, chained after authn's:

// in the application, not in authn
func ProvisionUser(users *user.Service) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authCtx := authn.GetAuthContext(r.Context())
            u, err := users.GetOrCreate(r.Context(), authCtx.Subject(), authCtx.Email(), authCtx.OUID())
            if err != nil {
                http.Error(w, "user provisioning failed", http.StatusInternalServerError)
                return
            }
            next.ServeHTTP(w, r.WithContext(user.NewContext(r.Context(), u)))
        })
    }
}

// wrap all protected routes once, not per route
api := http.NewServeMux()
api.HandleFunc("POST /api/x", handlerX)

root := http.NewServeMux()
root.Handle("/api/", manager.RequireAuthMiddleware()(ProvisionUser(users)(api)))

The application owns that policy: who gets a record, what goes on the context, and whether a failure rejects the request.

Reading identity in handlers

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

fmt.Println(authCtx.Subject())                        // JWT "sub"
fmt.Println(authCtx.ClientID())                       // JWT "client_id"
fmt.Println(authCtx.Roles())                          // role claims
fmt.Println(authCtx.Scopes())                         // scope claims
fmt.Println(authCtx.Email())                          // "email", "" if absent
fmt.Println(authCtx.OUID(), authCtx.OUHandle())       // "ouId", "ouHandle", "" if absent
fmt.Println(authCtx.ExtraClaims().String("given_name")) // any other claim — see "Extra claims"

The accessors are nil-safe, so they can be called on a nil *AuthContext:

email := authn.GetAuthContext(r.Context()).Email()

The same values are available as fields on authCtx.Principal.

Standard identity claims

email, ouId and ouHandle have dedicated fields: Principal.Email, Principal.OUID and Principal.OUHandle, also available as Email(), OUID() and OUHandle() on AuthContext. Values are whitespace-trimmed.

Each is optional by default: a token without the claim (or with a JSON-null or blank value) is accepted and the field is "". Configure a claim as required to reject such tokens instead:

claims:
  email: {}              # optional (same as omitting it)
  ouId:
    required: true
  ouHandle:
    required: false
authn.Config{ /* ... */ Claims: authn.ClaimsConfig{OUID: authn.ClaimConfig{Required: true}} }
// or authn.WithClaims(authn.ClaimsConfig{OUID: authn.ClaimConfig{Required: true}})
  • Required applies to every token, including client-credentials tokens issued to systems, which typically carry no email or organization unit. Only require a claim every caller's token carries; check anything route-specific in your own middleware or handler.
  • A present claim that is not a JSON string rejects the token.

Extra claims (beyond the fixed schema)

authn's fixed schema is deliberately small: sub, client_id, scope, roles, the standard identity claims email, ouId and ouHandle, plus the JWT registered claims iss, aud, exp, nbf, iat, jti. Every other claim in the token — phone_number, grant_type, an IdP-specific claim like given_name, a namespaced claim like https://app.example.com/department — is passed through in ExtraClaims, keyed by its exact name.

authCtx := authn.GetAuthContext(r.Context())
name   := authCtx.ExtraClaims().String("given_name")  // "" if absent or not a string
groups := authCtx.ExtraClaims().Strings("groups")     // JSON array of strings, or a space-delimited string
dept   := authCtx.ExtraClaims()["department"]         // any other shape: index the map directly

authn does not decide which of these a request needs. If your application depends on one, check it in your own middleware or handler.

Semantics:

  • Claims authn binds itself never appear in ExtraClaims, so each value has one home: read Subject(), not ExtraClaims()["sub"]. Case variants of fixed-schema names (Roles, SUB) are excluded too, because encoding/json matches struct tags case-insensitively and binds them to the fixed field.
  • A claim whose value is JSON null or an empty/whitespace-only string is dropped, so it reads the same as a claim the token never carried. A token with no other claims has a nil ExtraClaims.
  • Claim names are matched exactly — JWT claim names are case-sensitive (RFC 7519 §4). 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.

Principal fields

Field Claim Required? Description
Subject sub Always The IdP's ID for the caller
ClientID client_id Always The OAuth2 client the token was issued to
Roles roles No Must be an array of strings if present; nil if absent
Scopes scope No Space-delimited; nil if absent
Email email Configurable (default: no) "" if absent — see "Standard identity claims" above
OUID ouId Configurable (default: no) "" if absent
OUHandle ouHandle Configurable (default: no) "" if absent
ExtraClaims — — Every other claim in the token — see "Extra claims" above

Validation also requires exp, and checks iss and aud against issuer and audience.

Roles populate AuthContext.Roles(), which is what the authz seam consumes. They are read from a top-level roles claim only; a role claim under another name, or nested (such as realm_access.roles), has to be mapped to roles on the IdP side. A roles claim of any shape other than an array of strings rejects the token, rather than silently reading as "no roles".

Migrating from user and client principals

Earlier versions split every caller into a user or a client principal by the token's grant_type claim. There is now one Principal.

Before After
NewManager(userProfileSvc, cfg) NewManager(cfg)
Middleware(userProfileSvc, extractor) / RequireAuth(userProfileSvc, extractor) Middleware(extractor) / RequireAuth(extractor)
UserProfileService.GetOrCreateUser Your own middleware chained after authn — see "Middleware"
authCtx.User.ID (persisted ID) Whatever your provisioning middleware puts on the context
authCtx.User.IDPUserID authCtx.Subject()
authCtx.Client.ClientID authCtx.ClientID()
authCtx.User.Roles / authCtx.Client.Roles (and Scopes, ExtraClaims) authCtx.Roles() (and Scopes(), ExtraClaims())
authCtx.Type(), UserPrincipalType, ClientPrincipalType Removed — branch on roles, scopes or claims instead
authCtx.User.ExtraClaims.String("email") (and "ouId", "ouHandle") authCtx.Email() (and OUID(), OUHandle())
UserClaims / ClientClaims (ClaimSpec{Optional, Required}), WithUserClaims / WithClientClaims email / ouId / ouHandle: Claims (ClaimsConfig) / WithClaims. Any other claim: ExtraClaims, no declaration; check required ones in your own middleware
RolesClaim / WithRolesClaim Removed — roles are always read from roles; map a differently-named roles claim to roles on the IdP side

Behaviour changes the compiler will not catch:

  • Subject() now returns the JWT sub claim. For user tokens it used to return the persisted user ID from UserProfileService (falling back to sub), and for client tokens it returned the client_id. Anything keyed by Subject() — stored records, audit entries, cache keys — must be checked.
  • sub is required on every token. Client-credentials tokens without sub used to be accepted; they are now rejected.
  • grant_type is ignored. Tokens without it, or with any value, are accepted. It now passes through in ExtraClaims like any other claim.
  • Only email, ouId and ouHandle can be required. A token missing any other claim you previously listed as Required is now accepted; move that check into your own middleware. Requirements now apply to every token, not just user or client tokens.
  • email, ouId and ouHandle are no longer in ExtraClaims: ExtraClaims().String("email") now returns ""; use Email().
  • ExtraClaims now holds every other claim, not just declared ones. Code that treated "present in ExtraClaims" as "declared in config" must be checked.
  • A leftover rolesClaim key in a yaml config is silently ignored, so roles are read from roles instead. If your IdP emits roles under another name, every caller now has no roles until the IdP maps them to roles.
  • The JSON shape of AuthContext changed, for anything that serializes it.

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

Overview

Package authn validates JWT access tokens and exposes the caller as a single principal. See README.md.

This is a temporary, unmodified copy of github.com/OpenNSW/core/authn from https://github.com/mushrafmim/nsw-core, branch refactor/291-single-principal, commit 1e6f75d. OpenNSW still depends on the older authn, so this version is not released upstream yet. Do not add OpenNDX-specific changes here; chain application middleware after authn instead. Once OpenNSW/core releases this version, delete this package and import the upstream module.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Middleware

func Middleware(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 Principal 3. 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.

This design allows: - Public endpoints (no auth required) - Protected endpoints (check for context) - Optional auth endpoints (use context if available)

Anything an application wants to do with the validated principal (e.g. provisioning a user record) belongs in its own middleware chained after this one; see the package README.

func RequireAuth

func RequireAuth(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(tokenExtractor)(handler))

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

Types

type AuthContext

type AuthContext struct {
	Principal Principal
}

AuthContext is the transient authentication context injected into each request by the auth middleware. It carries the Principal resolved from the request's token.

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
}
subject := authCtx.Subject()

func (*AuthContext) ClientID

func (a *AuthContext) ClientID() string

ClientID returns the OAuth2 client the principal's token was issued to, or "" when unauthenticated.

func (*AuthContext) Email

func (a *AuthContext) Email() string

Email returns the principal's "email" claim, or "" when absent or unauthenticated.

func (*AuthContext) ExtraClaims

func (a *AuthContext) ExtraClaims() ExtraClaims

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 (*AuthContext) OUHandle

func (a *AuthContext) OUHandle() string

OUHandle returns the principal's "ouHandle" claim, or "" when absent or unauthenticated.

func (*AuthContext) OUID

func (a *AuthContext) OUID() string

OUID returns the principal's "ouId" claim, or "" when absent or unauthenticated.

func (*AuthContext) Roles

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

Roles returns the granted roles for the principal, or nil.

func (*AuthContext) Scopes

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

Scopes returns the granted OAuth2 scopes for the principal, or nil.

func (*AuthContext) Subject

func (a *AuthContext) Subject() string

Subject returns the principal's JWT "sub" claim, or "" when unauthenticated.

type ClaimConfig

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"`
}

ClaimConfig configures one standard identity claim.

type ClaimsConfig

type ClaimsConfig struct {
	Email    ClaimConfig `yaml:"email"`
	OUID     ClaimConfig `yaml:"ouId"`
	OUHandle ClaimConfig `yaml:"ouHandle"`
}

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 Config

type Config struct {
	JWKSURL               string `yaml:"jwksURL"`
	Issuer                string `yaml:"issuer"`
	Audience              string `yaml:"audience"`
	InsecureSkipTLSVerify bool   `yaml:"insecureSkipTLSVerify"`

	// Claims configures the standard identity claims. See WithClaims. Zero
	// value = every claim optional.
	Claims ClaimsConfig `yaml:"claims"`
}

Config carries yaml struct tags, so it can be embedded in a larger application config struct and populated generically (e.g. via yaml.Unmarshal).

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

type ExtraClaims map[string]any

ExtraClaims holds every claim in a validated token outside authn's fixed schema (sub, client_id, scope, the roles claim, and the JWT registered claims), which already have dedicated Principal fields or are consumed by validation. Nothing has to be declared: whatever the IdP puts in the token is here. Deciding which of these a request needs is the application's job.

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

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

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.

The manager only authenticates: it validates the token and injects the resulting Principal. Anything an application does with that principal (e.g. provisioning a user record) belongs in its own middleware chained after it.

func NewManager

func NewManager(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.

Usage:

authManager, err := auth.NewManager(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(cfg.Auth)
defer authManager.Close()

func (*Manager) Health

func (m *Manager) Health() error

Health checks if the auth system is functioning properly. It verifies that the auth system components are initialized correctly.

Usage in server startup:

authManager := auth.NewManager(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 a Principal 3. 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

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, which NewTokenExtractor reports as a construction error.

func WithClaims

func WithClaims(claims ClaimsConfig) Option

WithClaims configures the standard identity claims (see ClaimsConfig). A later call replaces an earlier one.

type Principal

type Principal struct {
	// Subject is the JWT "sub" claim: the identity provider's ID for the
	// caller. Always non-empty: a token without "sub" is rejected.
	Subject string `json:"subject"`
	// ClientID is the JWT "client_id" claim: the OAuth2 client the token was
	// issued to. Always non-empty: a token without "client_id" is rejected.
	ClientID string `json:"clientId"`
	// Roles is the JWT "roles" claim, nil when the token does not carry it. A
	// "roles" claim that is not an array of strings rejects the token.
	Roles []string `json:"roles"`
	// Scopes is the space-delimited JWT "scope" claim, nil when the token does
	// not carry it.
	Scopes []string `json:"scopes"`

	// Email, OUID and OUHandle are the standard identity claims "email",
	// "ouId" and "ouHandle", whitespace-trimmed. Each is "" when the token
	// does not carry it, unless configured as required (see ClaimsConfig),
	// in which case such a token is rejected.
	Email    string `json:"email,omitempty"`
	OUID     string `json:"ouId,omitempty"`
	OUHandle string `json:"ouHandle,omitempty"`

	ExtraClaims ExtraClaims `json:"extraClaims,omitempty"`
}

Principal is the authenticated caller a validated token describes. There is one shape for every token, however it was obtained: a person signing in through a client and a system using client credentials are distinguished only by what they carry (roles, scopes, claims), never by kind.

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 the token's Principal.

func NewTokenExtractor

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

func NewTokenExtractorWithClient

func NewTokenExtractorWithClient(jwksURL, issuer, audience 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 a Principal.

Jump to

Keyboard shortcuts

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