oidcauth

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jun 29, 2026 License: MIT Imports: 14 Imported by: 0

README

oidcauth

OpenID Connect token verification for Go, with optional in-memory caching and authorization helpers. Designed for Keycloak but compatible with any OIDC-compliant provider.

Features

  • JWT verification (signature, issuer, audience, expiry) via go-oidc
  • RFC 7662 token introspection to detect server-side revocation (opt-out via DisableIntrospection)
  • Pluggable cache via the Cache interface ships with MemoryCache or bring your own (Redis, Memcached, etc.)
  • Authorization helpers: HasRole, HasScope, HasAllScopes, IsAuthorizedParty
  • Machine-to-machine (client credentials) support via SkipClientIDCheck
  • Safe for concurrent use

Installation

go get github.com/raykavin/gobox/oidcauth

Usage

Basic

Every call to Verify performs a full JWT check plus a remote introspection request. ClientSecret is required when introspection is enabled (the default).

verifier, err := oidcauth.New(ctx, oidcauth.Config{
    RealmURL:     "https://keycloak.example.com/realms/main",
    ClientID:     "my-app",
    ClientSecret: "secret",
})
if err != nil {
    log.Fatal(err)
}

claims, err := verifier.Verify(ctx, bearerToken)
if err != nil {
    log.Fatal(err)
}

fmt.Println(claims.PreferredUsername)
Without introspection

Set DisableIntrospection: true to skip the remote RFC 7662 call and rely solely on local JWT verification. In this mode ClientSecret is not required, but revoked tokens will not be detected until they expire.

verifier, err := oidcauth.New(ctx, oidcauth.Config{
    RealmURL:             "https://keycloak.example.com/realms/main",
    ClientID:             "my-app",
    DisableIntrospection: true,
})
With MemoryCache

Cache verified claims to avoid a network round-trip on every request.

cache := oidcauth.NewMemoryCache(ctx, oidcauth.DefaultCacheDuration) // 5m TTL
defer cache.Close()

verifier, err := oidcauth.New(ctx, config, oidcauth.WithCache(cache))

The entry TTL is min(token.exp, now + duration) the cache never serves a token past its own expiry.

With a custom cache backend

Implement the Cache interface to use any external store.

type Cache interface {
    Get(key string, now time.Time) (Claims, bool)
    Set(key string, claims Claims, now time.Time)
}

verifier, err := oidcauth.New(ctx, config, oidcauth.WithCache(myRedisCache))
Role-based authorization

HasRole checks for a Keycloak client role inside resource_access[clientID].roles.

if !verifier.HasRole(claims, "admin") {
    http.Error(w, "forbidden", http.StatusForbidden)
    return
}
Scope-based authorization

HasScope checks for a single OAuth 2.0 scope in claims.Scope (space-separated, per RFC 6749).

if !verifier.HasScope(claims, "read:data") {
    http.Error(w, "insufficient scope", http.StatusForbidden)
    return
}

HasAllScopes requires every listed scope to be present.

if !verifier.HasAllScopes(claims, "read:data", "write:data") {
    http.Error(w, "insufficient scope", http.StatusForbidden)
    return
}
Authorized party

IsAuthorizedParty compares claims.Azp against an expected client ID. Useful when a gateway or another service forwards tokens downstream.

if !verifier.IsAuthorizedParty(claims, "api-gateway") {
    http.Error(w, "unauthorized party", http.StatusForbidden)
    return
}
Machine-to-machine (client credentials)

Access tokens obtained via the OAuth 2.0 client credentials grant often carry an aud value that does not match the resource server's ClientID, causing the default audience check to fail. Set SkipClientIDCheck: true to bypass it and validate the caller identity manually with the authorization helpers.

verifier, err := oidcauth.New(ctx, oidcauth.Config{
    RealmURL:          "https://keycloak.example.com/realms/main",
    ClientID:          "resource-server",
    ClientSecret:      "secret",
    SkipClientIDCheck: true, // M2M tokens may not carry this client's ID in aud
})

claims, err := verifier.Verify(ctx, token)
if err != nil {
    // handle error
}

if !verifier.IsAuthorizedParty(claims, "allowed-service") {
    http.Error(w, "unauthorized party", http.StatusForbidden)
    return
}
if !verifier.HasAllScopes(claims, "read:data") {
    http.Error(w, "insufficient scope", http.StatusForbidden)
    return
}
Error handling

All errors wrap a package-level sentinel and can be inspected with errors.Is.

claims, err := verifier.Verify(ctx, token)
switch {
case errors.Is(err, oidcauth.ErrTokenRevoked):
    // token was revoked server-side
case errors.Is(err, oidcauth.ErrTokenValidationFailed):
    // signature / expiry / audience check failed
case errors.Is(err, oidcauth.ErrIntrospectionFailed):
    // could not reach the introspection endpoint
case err != nil:
    // unexpected error
}
Sentinel Cause
ErrInvalidRealmURL RealmURL is empty or not a valid HTTP(S) URL
ErrEmptyClientID ClientID is empty
ErrMissingClientSecret ClientSecret not set and introspection is enabled
ErrProviderInitFailed OIDC discovery request failed
ErrTokenValidationFailed JWT signature, issuer, audience, or expiry check failed
ErrIntrospectionFailed Introspection endpoint unreachable or returned unexpected status
ErrTokenRevoked Token is valid but marked inactive by the provider

Configuration

oidcauth.Config{
    RealmURL:     "https://keycloak.example.com/realms/main", // required
    ClientID:     "my-app",                                   // required
    ClientSecret: "secret",                                   // required unless DisableIntrospection is set
    RequestTimeout: 10 * time.Second,                         // default: 30s

    // DisableIntrospection skips the remote RFC 7662 call in Verify.
    // Revoked tokens will not be detected until their exp claim elapses.
    // ClientSecret is not required when this is true.
    DisableIntrospection: false,

    // SkipClientIDCheck disables audience validation against ClientID.
    // Use for M2M / client-credentials flows where aud does not match.
    SkipClientIDCheck: false,

    // Test-only do not enable in production.
    SkipIssuerCheck: false,
    SkipExpiryCheck: false,
}

Authorization helpers

Method Checks
HasRole(claims, role) resource_access[clientID].roles contains role
HasScope(claims, scope) claims.Scope contains scope (exact word match)
HasAllScopes(claims, scopes...) every scope in the list is present
IsAuthorizedParty(claims, azp) claims.Azp == azp

Security notes

  • Cache keys are SHA-256 hashes of the raw bearer token raw tokens are never stored in memory as map keys.
  • SkipIssuerCheck and SkipExpiryCheck are intended for testing only. Enabling them in production disables core JWT security checks.
  • SkipClientIDCheck is legitimate for M2M flows; compensate by validating azp and scopes explicitly.
  • DisableIntrospection removes server-side revocation detection. Use only when the provider does not expose an introspection endpoint or when latency constraints prevent the extra round-trip, and accept the trade-off.
  • Revoked tokens remain valid for up to CacheDuration when caching is enabled. Choose a TTL that matches your revocation latency requirements.

Documentation

Overview

Package oidcauth provides OpenID Connect token verification with optional in-memory caching and authorization helpers for roles, scopes, and authorized parties.

Basic usage

verifier, err := oidcauth.New(ctx, oidcauth.Config{
    RealmURL:     "https://keycloak.example.com/realms/main",
    ClientID:     "my-app",
    ClientSecret: "secret",
})
if err != nil {
    log.Fatal(err)
}

claims, err := verifier.Verify(ctx, bearerToken)
if err != nil {
    // inspect with errors.Is(err, oidcauth.ErrTokenRevoked), etc.
}

Caching

By default no cache is used and every call to Verify hits the provider. Attach a MemoryCache to avoid redundant network round-trips:

cache := oidcauth.NewMemoryCache(ctx, oidcauth.DefaultCacheDuration)
defer cache.Close()

verifier, err := oidcauth.New(ctx, config, oidcauth.WithCache(cache))

Custom backends (Redis, Memcached, etc.) can be used by implementing the Cache interface:

type Cache interface {
    Get(key string, now time.Time) (Claims, bool)
    Set(key string, claims Claims, now time.Time)
}

Role-based authorization

HasRole checks whether a token carries a specific Keycloak client role (resource_access[clientID].roles):

if !verifier.HasRole(claims, "admin") {
    http.Error(w, "forbidden", http.StatusForbidden)
    return
}

Scope-based authorization

HasScope checks whether a token carries a specific OAuth 2.0 scope:

if !verifier.HasScope(claims, "read:data") {
    http.Error(w, "insufficient scope", http.StatusForbidden)
    return
}

HasAllScopes requires every listed scope to be present:

if !verifier.HasAllScopes(claims, "read:data", "write:data") {
    http.Error(w, "insufficient scope", http.StatusForbidden)
    return
}

IsAuthorizedParty compares the azp claim against an expected client ID, useful in multi-service architectures where a gateway forwards tokens:

if !verifier.IsAuthorizedParty(claims, "api-gateway") {
    http.Error(w, "unauthorized party", http.StatusForbidden)
    return
}

Machine-to-machine (client credentials)

Access tokens obtained via the OAuth 2.0 client credentials grant often carry an aud value that does not match the resource server's ClientID, causing the default audience check to fail. Set SkipClientIDCheck: true to bypass that check and validate the caller identity manually instead:

verifier, err := oidcauth.New(ctx, oidcauth.Config{
    RealmURL:          "https://keycloak.example.com/realms/main",
    ClientID:          "resource-server",
    ClientSecret:      "secret",
    SkipClientIDCheck: true, // M2M tokens may not carry this client's ID in aud
})

claims, err := verifier.Verify(ctx, token)
if err != nil { /* ... */ }

if !verifier.IsAuthorizedParty(claims, "allowed-service") {
    http.Error(w, "unauthorized party", http.StatusForbidden)
    return
}
if !verifier.HasAllScopes(claims, "read:data") {
    http.Error(w, "insufficient scope", http.StatusForbidden)
    return
}

Disabling introspection

By default Verify performs a remote RFC 7662 introspection call to catch revoked-but-not-yet-expired tokens. When introspection is enabled, ClientSecret is required. To rely solely on local JWT verification and skip the introspection round-trip, set DisableIntrospection: true:

verifier, err := oidcauth.New(ctx, oidcauth.Config{
    RealmURL:             "https://keycloak.example.com/realms/main",
    ClientID:             "my-app",
    DisableIntrospection: true, // no ClientSecret needed; no revocation detection
})

Error handling

All errors wrap one of the package-level sentinels and can be inspected with errors.Is:

switch {
case errors.Is(err, oidcauth.ErrTokenRevoked):
    // token was revoked server-side
case errors.Is(err, oidcauth.ErrTokenValidationFailed):
    // signature / expiry / audience check failed
case errors.Is(err, oidcauth.ErrIntrospectionFailed):
    // could not reach the introspection endpoint
case errors.Is(err, oidcauth.ErrMissingClientSecret):
    // ClientSecret not set and introspection is enabled
}

Index

Constants

View Source
const DefaultCacheDuration = 5 * time.Minute

DefaultCacheDuration is the TTL used when no custom duration is provided.

Variables

View Source
var (
	ErrInvalidRealmURL       = errors.New("invalid OIDC configuration URL")
	ErrEmptyClientID         = errors.New("client ID cannot be empty")
	ErrMissingClientSecret   = errors.New("client secret is required when introspection is enabled")
	ErrTokenValidationFailed = errors.New("token validation failed")
	ErrProviderInitFailed    = errors.New("failed to initialize OIDC provider")
	ErrIntrospectionFailed   = errors.New("token introspection failed")
	ErrTokenRevoked          = errors.New("access token has been revoked")
)

Sentinel errors returned by the OIDC verifier.

Functions

This section is empty.

Types

type Cache

type Cache interface {
	Get(key string, now time.Time) (Claims, bool)
	Set(key string, claims Claims, now time.Time)
}

Cache is the interface for token caching backends. Implement it to plug in Redis, Memcached, or any other store.

type Claims

type Claims struct {
	Aud               []string                       `json:"aud"`
	AllowedOrigins    []string                       `json:"allowed-origins"`
	Jti               string                         `json:"jti"`
	Iss               string                         `json:"iss"`
	Sub               string                         `json:"sub"`
	Typ               string                         `json:"typ"`
	Azp               string                         `json:"azp"`
	Sid               string                         `json:"sid"`
	Acr               string                         `json:"acr"`
	Scope             string                         `json:"scope"`
	Name              string                         `json:"name"`
	PreferredUsername string                         `json:"preferred_username"`
	GivenName         string                         `json:"given_name"`
	FamilyName        string                         `json:"family_name"`
	Email             string                         `json:"email"`
	Exp               float64                        `json:"exp"`
	Iat               float64                        `json:"iat"`
	AuthTime          int                            `json:"auth_time"`
	RealmAccess       map[string][]string            `json:"realm_access"`
	ResourceAccess    map[string]map[string][]string `json:"resource_access"`
	EmailVerified     bool                           `json:"email_verified"`
}

Claims represents the structure of the claims extracted from an authentication token.

type Config

type Config struct {
	RealmURL             string        // Issuer URL (e.g. https://kc.example.com/realms/main).
	ClientID             string        // OAuth client ID used for audience checks and introspection auth.
	ClientSecret         string        // Confidential client secret used for introspection. Required unless DisableIntrospection is set.
	RequestTimeout       time.Duration // HTTP timeout for provider calls. Defaults to 30s.
	SkipIssuerCheck      bool          // Disable iss claim validation (test-only).
	SkipClientIDCheck    bool          // Disable aud claim validation against ClientID (test-only).
	SkipExpiryCheck      bool          // Disable exp claim validation (test-only).
	DisableIntrospection bool          // Skip remote RFC 7662 introspection in Verify; rely only on local JWT verification.
}

Config controls how the OIDC verifier connects to the identity provider and validates tokens. RealmURL and ClientID are required; everything else has a sensible default.

Introspection is enabled by default. When enabled, ClientSecret is required and Verify performs a remote RFC 7662 introspection call to detect revoked-but-not-yet-expired tokens. Set DisableIntrospection to rely solely on local JWT verification (no revocation detection, no provider round-trip).

type Introspection

type Introspection struct {
	Claims
	Active bool `json:"active"`
}

Introspection represents the result of token introspection, including the claims and the active status of the token.

type MemoryCache

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

MemoryCache is a thread-safe in-memory Cache with TTL-based eviction. Create one with NewMemoryCache and attach it to a verifier via WithCache.

func NewMemoryCache

func NewMemoryCache(ctx context.Context, duration time.Duration) *MemoryCache

NewMemoryCache returns a MemoryCache that caps each entry's TTL at duration and runs a background eviction goroutine until ctx is cancelled or Close is called.

func (*MemoryCache) Close

func (c *MemoryCache) Close()

Close stops the background eviction goroutine. Safe to call multiple times.

func (*MemoryCache) Get

func (c *MemoryCache) Get(key string, now time.Time) (Claims, bool)

Get returns the cached claims for key if present and not yet expired.

func (*MemoryCache) Set

func (c *MemoryCache) Set(key string, claims Claims, now time.Time)

Set stores claims under key, expiring at min(token.exp, now+duration).

type OIDC

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

OIDC verifies tokens issued by an OpenID Connect provider and exposes helpers for role- and scope-based authorization. A single OIDC value is safe for concurrent use.

func New

func New(ctx context.Context, config Config, opts ...Option) (*OIDC, error)

New builds an OIDC verifier and discovers the provider's metadata. The supplied context bounds only the discovery call; use WithCache to control caching behaviour.

func (*OIDC) HasAllScopes added in v0.2.0

func (o *OIDC) HasAllScopes(claims Claims, scopes ...string) bool

HasAllScopes reports whether the given claims include all of the named scopes. Returns true if scopes is empty (vacuous truth).

func (*OIDC) HasRole

func (o *OIDC) HasRole(claims Claims, role string) bool

HasRole reports whether the given claims include the named role for this verifier's client (i.e. claims.ResourceAccess[ClientID].roles).

func (*OIDC) HasScope added in v0.2.0

func (o *OIDC) HasScope(claims Claims, scope string) bool

HasScope reports whether the given claims include the named scope. Scopes in claims.Scope are space-separated per RFC 6749.

func (*OIDC) IsAuthorizedParty added in v0.2.0

func (o *OIDC) IsAuthorizedParty(claims Claims, azp string) bool

IsAuthorizedParty reports whether the claims' azp (authorized party) field matches the provided value.

func (*OIDC) Verify

func (o *OIDC) Verify(ctx context.Context, token string) (Claims, error)

Verify validates a bearer token and returns its claims.

The flow is:

  1. cache lookup, return immediately on hit (if a Cache was attached);
  2. local JWT verification (signature, iss, aud, exp);
  3. remote introspection to catch revoked-but-not-yet-expired tokens, unless disabled via Config.DisableIntrospection;
  4. cache the claims (if a Cache was attached).

Any failure in steps 2 or 3 returns ErrTokenValidationFailed; an inactive token returns ErrTokenRevoked. The original error is wrapped so callers using errors.Unwrap can still inspect it.

type Option

type Option func(*OIDC)

Option configures an OIDC verifier.

func WithCache

func WithCache(c Cache) Option

WithCache attaches a Cache to the verifier. Verify returns cached claims on hit and stores validated claims on miss. Cache lifecycle (e.g. Close) is managed by the caller.

Jump to

Keyboard shortcuts

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