Go package to perform OAuth2 Authorization Code Flow with PKCE against an OIDC provider.
go get github.com/phlipse/go-oidc
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
}func GetTokenClient(ctx context.Context, opts oidc.ClientOptions) (*oidc.TokenClient, error)Returns a TokenClient with:
Client: authenticated*http.ClientClaims: parsed ID token claims
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).
Config(*oauth2.Config) requiredDiscoveryURL(*url.URL) required
If either is missing, GetTokenClient returns an error.
All discovery/JWKS requests respect context cancellation and use an HTTP client in this order:
ClientOptions.HTTPClient/ClaimsOptions.HTTPClientoauth2.HTTPClientfromctxhttp.DefaultClient
Set a timeout on your client for production use.
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 is an OIDC concern (not part of PKCE). Control it via:
NonceMode:NonceDisabled(default): nonce is not added and not validatedNonceRequired: nonce is added and validated
NonceProvider:- if nil and
NonceRequired,RandomNonceProvideris used
- if nil and
Example: disable nonce (not recommended for ID tokens)
opts := oidc.ClientOptions{
Config: config,
DiscoveryURL: discoveryURL,
NonceMode: oidc.NonceDisabled,
}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.
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
}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
Both ClientOptions and ClaimsOptions expose:
AllowedAlgs: restrict acceptablealgvalues (defaults to discoveryid_token_signing_alg_values_supportedwhen available)ClockSkew: leeway window for time-based claimsRequireExp: requireexpto be presentDisableTimeValidation: skipexp/nbf/iatvalidation entirelyExpectedIssuer: override issuer check (defaults to discoveryissuer)ExpectedAudience: override audience check (defaults toConfig.ClientIDwhen usingGetTokenClient)
The ID token is validated for:
- signature (JWKS)
issmatches discoveryissuer(orExpectedIssuer)audcontainsConfig.ClientID(orExpectedAudience)noncematches whenNonceRequiredis enabledexp/nbf/iatbased onClockSkewandDisableTimeValidation
If an ID token is missing (e.g., refresh-token-only responses), GetClaims returns ErrIDTokenMissing.
DiscoveryURL is required. If Config.Endpoint.AuthURL or TokenURL is missing/invalid, the discovery document is used to fill them.
If you plan to use this package in production, consider the following:
- Token storage: Persist tokens securely. Always store the
id_token(inExtra) if you rely on claims. - HTTP timeouts: Provide a custom
HTTPClientwith sensible timeouts; avoidhttp.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
NonceRequiredfor ID tokens, generate a fresh nonce per auth request, and never reuse it across sessions. - Validation policy: Set
AllowedAlgs,ExpectedIssuer, andExpectedAudienceexplicitly 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