Skip to content

phlipse/go-oidc

Repository files navigation

go-oidc

Go package to perform OAuth2 Authorization Code Flow with PKCE against an OIDC provider.

Install

go get github.com/phlipse/go-oidc

Quick start

package main

import (
	"context"
	"net/url"
	"time"

	"github.com/phlipse/go-oidc"
	"golang.org/x/oauth2"
)

func main() {
	config := &oauth2.Config{
		ClientID: "your-client-id",
		Endpoint: oauth2.Endpoint{
			AuthURL:  "https://example.com/auth",  // optional if supported by discovery doc
			TokenURL: "https://example.com/token", // optional if supported by discovery doc
		},
		RedirectURL: "http://localhost:8080/callback",
		Scopes:      []string{"openid", "offline_access"},
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	discoveryURL, _ := url.Parse("https://example.com/.well-known/openid-configuration")

	opts := oidc.ClientOptions{
		Config:       config,
		DiscoveryURL: discoveryURL,
		// Recommended for ID tokens:
		NonceMode: oidc.NonceRequired,
	}

	tokenClient, err := oidc.GetTokenClient(ctx, opts)
	if err != nil {
		panic(err)
	}

	_ = tokenClient // use tokenClient.Client and tokenClient.Claims
}

API overview

GetTokenClient

func GetTokenClient(ctx context.Context, opts oidc.ClientOptions) (*oidc.TokenClient, error)

Returns a TokenClient with:

  • Client: authenticated *http.Client
  • Claims: parsed ID token claims

GetClaims

func GetClaims(ctx context.Context, t *oauth2.Token, opts oidc.ClaimsOptions) (*oidc.Claims, error)

This is useful if you already have a token and want to validate/extract its ID token claims.

Example:

claims, err := oidc.GetClaims(ctx, token, oidc.ClaimsOptions{
	DiscoveryURL:     discoveryURL,
	ExpectedAudience: config.ClientID, // optional but recommended for aud check
})

ClaimsOptions.DiscoveryURL is required. ExpectedAudience is optional but recommended when you want to enforce aud (for GetTokenClient it defaults to Config.ClientID).

ClientOptions (required fields)

  • Config (*oauth2.Config) required
  • DiscoveryURL (*url.URL) required

If either is missing, GetTokenClient returns an error.

HTTP client / timeouts

All discovery/JWKS requests respect context cancellation and use an HTTP client in this order:

  1. ClientOptions.HTTPClient / ClaimsOptions.HTTPClient
  2. oauth2.HTTPClient from ctx
  3. http.DefaultClient

Set a timeout on your client for production use.

Logging

You can inject a logger via ClientOptions.Logger or ClaimsOptions.Logger. It uses a minimal interface and accepts key/value pairs (slog-style). For central redaction/filters, use LogHook.

Example with slog:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

opts := oidc.ClientOptions{
	Config:       config,
	DiscoveryURL: discoveryURL,
	Logger: oidc.LoggerFunc(func(ctx context.Context, level oidc.LogLevel, msg string, args ...any) {
		var slogLevel slog.Level
		switch level {
		case oidc.LogDebug:
			slogLevel = slog.LevelDebug
		case oidc.LogInfo:
			slogLevel = slog.LevelInfo
		case oidc.LogWarn:
			slogLevel = slog.LevelWarn
		case oidc.LogError:
			slogLevel = slog.LevelError
		}
		logger.Log(ctx, slogLevel, msg, args...)
	}),
	LogHook: oidc.RedactKeysHook("id_token", "access_token", "refresh_token", "nonce", "aud", "sub", "subject"),
}

Example with fmt.Println:

opts := oidc.ClientOptions{
	Config:       config,
	DiscoveryURL: discoveryURL,
	Logger: oidc.LoggerFunc(func(_ context.Context, level oidc.LogLevel, msg string, args ...any) {
		fmt.Println(level, msg, args)
	}),
	LogHook: oidc.RedactKeysHook("id_token", "access_token", "refresh_token", "nonce", "aud", "sub", "subject"),
}

Nonce handling

Nonce is an OIDC concern (not part of PKCE). Control it via:

  • NonceMode:
    • NonceDisabled (default): nonce is not added and not validated
    • NonceRequired: nonce is added and validated
  • NonceProvider:
    • if nil and NonceRequired, RandomNonceProvider is used

Example: disable nonce (not recommended for ID tokens)

opts := oidc.ClientOptions{
	Config:       config,
	DiscoveryURL: discoveryURL,
	NonceMode:    oidc.NonceDisabled,
}

Token persistence (reuse after restart)

To reuse an authenticated client after a restart, persist the oauth2.Token and provide a TokenStore. The client will attempt to use the stored token first; if it is expired, it will refresh it. If refresh fails (e.g. token invalidated), it falls back to the interactive auth flow.

store := oidc.FuncTokenStore{
	LoadFunc: func() (*oauth2.Token, error) {
		// load token from disk/db
		return nil, nil
	},
	SaveFunc: func(token *oauth2.Token) error {
		// persist token (including Extra/id_token)
		return nil
	},
}

opts := oidc.ClientOptions{
	Config:       config,
	DiscoveryURL: discoveryURL,
	TokenStore:   store,
	NonceMode:    oidc.NonceRequired, // recommended for ID tokens
}

Persisting the token (including id_token) as JSON:

type storedToken struct {
	Token *oauth2.Token          `json:"token"`
	Extra map[string]interface{} `json:"extra"`
}

store := oidc.FuncTokenStore{
	LoadFunc: func() (*oauth2.Token, error) {
		data, err := os.ReadFile("token.json")
		if err != nil {
			return nil, nil // treat missing file as no token
		}
		var st storedToken
		if err := json.Unmarshal(data, &st); err != nil {
			return nil, err
		}
		if st.Token == nil {
			return nil, nil
		}
		if len(st.Extra) > 0 {
			return st.Token.WithExtra(st.Extra), nil
		}
		return st.Token, nil
	},
	SaveFunc: func(token *oauth2.Token) error {
		st := storedToken{
			Token: token,
			Extra: map[string]interface{}{
				"id_token": token.Extra("id_token"),
			},
		}
		data, err := json.Marshal(st)
		if err != nil {
			return err
		}
		return os.WriteFile("token.json", data, 0600)
	},
}

For production, store tokens securely.

Caching

You can cache discovery and JWKS responses via a TTL cache.

cache := oidc.NewMemoryCache()
opts := oidc.ClientOptions{
	Config:       config,
	DiscoveryURL: discoveryURL,
	Cache:        cache,
	DiscoveryTTL: 15 * time.Minute, // optional; defaults to 15m when Cache is set
	JWKSTTL:      30 * time.Minute, // optional; defaults to 30m when Cache is set
}

Redirect URL / callback requirements

oauth2.Config.RedirectURL must be a full URL with host, port, and path. The local callback server will:

  • listen on Hostname:Port
  • expect the exact path from RedirectURL

Example:

http://localhost:8080/callback

Token validation options

Both ClientOptions and ClaimsOptions expose:

  • AllowedAlgs: restrict acceptable alg values (defaults to discovery id_token_signing_alg_values_supported when available)
  • ClockSkew: leeway window for time-based claims
  • RequireExp: require exp to be present
  • DisableTimeValidation: skip exp/nbf/iat validation entirely
  • ExpectedIssuer: override issuer check (defaults to discovery issuer)
  • ExpectedAudience: override audience check (defaults to Config.ClientID when using GetTokenClient)

Validation behavior

The ID token is validated for:

  • signature (JWKS)
  • iss matches discovery issuer (or ExpectedIssuer)
  • aud contains Config.ClientID (or ExpectedAudience)
  • nonce matches when NonceRequired is enabled
  • exp/nbf/iat based on ClockSkew and DisableTimeValidation

If an ID token is missing (e.g., refresh-token-only responses), GetClaims returns ErrIDTokenMissing.

Discovery document

DiscoveryURL is required. If Config.Endpoint.AuthURL or TokenURL is missing/invalid, the discovery document is used to fill them.

Production recommendations

If you plan to use this package in production, consider the following:

  • Token storage: Persist tokens securely. Always store the id_token (in Extra) if you rely on claims.
  • HTTP timeouts: Provide a custom HTTPClient with sensible timeouts; avoid http.DefaultClient.
  • Refresh behavior: Decide what should happen when refresh fails (e.g., non-interactive services should return an error instead of opening a browser).
  • Nonce usage: Enable NonceRequired for ID tokens, generate a fresh nonce per auth request, and never reuse it across sessions.
  • Validation policy: Set AllowedAlgs, ExpectedIssuer, and ExpectedAudience explicitly to match your security requirements.
  • Redirect safety: Use a loopback redirect URI (http://localhost:<port>/...) and avoid registering overly broad redirect URIs (e.g., wildcards, open subdomains, ...).
  • Deployment model: The local callback server is convenient for desktop/CLI apps but may be unsuitable for headless/server deployments.

Production-ready example:

httpClient := &http.Client{
	Timeout: 10 * time.Second,
}

store := oidc.FuncTokenStore{
	LoadFunc: func() (*oauth2.Token, error) {
		// load token from secure place
		return nil, nil
	},
	SaveFunc: func(token *oauth2.Token) error {
		// persist token (including Extra/id_token) securely
		return nil
	},
}

opts := oidc.ClientOptions{
	Config:       config,
	DiscoveryURL: discoveryURL,
	HTTPClient:   httpClient,
	TokenStore:   store,

	// Security policy
	NonceMode:        oidc.NonceRequired,
	AllowedAlgs:      []string{"RS256"},
	ExpectedIssuer:   "https://issuer.example.com",
	ExpectedAudience: config.ClientID,
	ClockSkew:        2 * time.Minute,
	RequireExp:       true,
}

client, err := oidc.GetTokenClient(ctx, opts)
if err != nil {
	// In GUI apps, surface the error to the user (dialog/toast) and stop the flow.
	showAuthError(err) // app-specific
	return
}
_ = client

About

go-oidc implements Authorization Code Flow with PKCE and optional nonce.

Resources

License

Stars

0 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors

Languages