oidcauth

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jul 24, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

oidcauth

Go Reference

OIDC login for Go web apps using only net/http. A thin wrapper over go-oidc and golang.org/x/oauth2 that handles the authorization-code flow with PKCE (S256), state and nonce validation, and a signed session cookie. It works with any conformant OIDC issuer; nothing here is provider-specific.

go get github.com/xdg-go/oidcauth

Usage

func main() {
	auth, err := oidcauth.NewFromEnv(context.Background())
	if err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	auth.Mount(mux) // /auth/login, /auth/callback, /auth/logout

	mux.Handle("/private", auth.RequireAuth(
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			user, _ := oidcauth.UserFromContext(r.Context())
			fmt.Fprintf(w, "hello %s (%s)", user.Name, user.Sub)
		})))

	log.Fatal(http.ListenAndServe(":8083", mux))
}

Environment variables

Variable Meaning
AUTH_ISSUER OIDC issuer URL, e.g. https://auth.example.com
AUTH_CLIENT_ID OAuth2 client id (also the expected ID-token aud)
AUTH_CLIENT_SECRET OAuth2 client secret
AUTH_REDIRECT_URL Absolute callback URL; its path is where the callback handler mounts. Convention: https://<domain>/auth/callback
AUTH_COOKIE_SECRET HMAC key for cookies, ≥32 bytes (openssl rand -hex 32)

An http:// redirect URL (local dev) disables the cookie Secure flag; https:// enables it. Cookies are always HttpOnly and SameSite=Lax.

What it provides

  • LoginHandler — starts the flow: PKCE S256, random state bound to a signed short-lived cookie, nonce carried into the ID token. Accepts ?next=/relative/path for post-login redirect (open redirects are rejected).
  • CallbackHandler — verifies state, exchanges the code (with the PKCE verifier), verifies the ID token (issuer, audience, expiry, signature via provider JWKS — all delegated to go-oidc) and the nonce, then sets the session cookie.
  • LogoutHandler — clears the app session cookie only. The issuer's session and any upstream (e.g. Google) consent are untouched. POST-only, to prevent CSRF-forced logout; drive it from a form or a fetch POST, not a link.
  • ClearSession — ends the session from inside your own handler (e.g. account deletion), where redirecting the in-flight POST to the POST-only logout endpoint is not possible.
  • RequireAuth middleware + UserFromContext — gate handlers and read the verified iss, sub, email, email_verified, name.
  • WithExtraClaims("claim", ...) — carry additional ID-token claims into the session, raw, on User.Extra. Useful when your issuer emits nonstandard claims apps need at login (e.g. a broker's upstream-identity claims — see the migration caveat below).
  • Auth.User(r) — optional-auth check for public pages (login vs logout links).
  • Session: HMAC-signed cookie holding the verified claims; WithSessionTTL configures lifetime (default 24h).
  • ForceApprovalIfNewUser(func(sub string) bool) — when the callback sees a sub your app has not recorded, the auth request restarts once with a forced consent prompt, so each user gets an explicit consent screen exactly once per app. The default prompt parameters are issuer-neutral; for an issuer that rejects them (e.g. Google used directly), WithForceConsentParams overrides them — see its doc comment.

Key accounts on (iss, sub), never on email

The pair (iss, sub) is the only identifier an app may use as an account key — sub is unique and never reassigned within an issuer, and means nothing outside it. User.Issuer carries the verified iss so apps can store the pair. Store email alongside it for display and recovery, but never key on it and never auto-link accounts by it:

  • OIDC guarantees sub is unique per issuer and never reassigned. It guarantees nothing about email.
  • Emails are mutable. A user who changes their address would be severed from an email-keyed account.
  • Emails are reassignable. Google Workspace recycles addresses; keying on email hands the old account to the address's new holder — an account takeover.
  • A second connector (passkeys, GitHub, ...) can present the same email under a different sub; email-keying silently merges distinct identities.
  • Auto-linking a new sub to an existing account because the emails match is the same takeover with extra steps (the "nOAuth" attack class: some issuers emit attacker-controlled or unverified emails). Email may only ever suggest a link that the user then proves — e.g. by a verification challenge to that address — never establish one by itself.

Issuer-migration caveat: sub is unique and stable only within an issuer, and its value is opaque — issuers commonly derive it from internal or upstream identifiers (which authentication backend the user came through, and that backend's user id). Replacing the issuer implementation, or changing how it authenticates users upstream, can therefore change every sub even when the issuer URL stays the same. A migration must either preserve sub values or map old→new via a stored durable attribute. The best durable attribute is the upstream pair itself — (authentication backend, backend's user id) — captured via WithExtraClaims when the issuer emits it as claims, or derived from sub when the issuer documents its encoding. Email is the fallback mapping attribute, subject to the verification rule above. This is why you store email and any upstream identity alongside (iss, sub) even though you never key on them.

License

Apache 2.0. See LICENSE.

Documentation

Overview

Package oidcauth provides OIDC login for Go web apps using only net/http. It is a thin wrapper over github.com/coreos/go-oidc/v3 and golang.org/x/oauth2 that handles the authorization-code flow with PKCE (S256), state and nonce validation, and a signed session cookie.

It targets any conformant OIDC issuer; nothing in this package is specific to a particular identity provider.

Identity rule: key on sub, never on email

Apps must key user accounts on the ID token's `sub` claim, never on email. OIDC guarantees `sub` is unique per issuer and never reassigned; it guarantees nothing about email. Emails are mutable and reassignable. Store email alongside `sub` for display and migration, but only `sub` is the key. See the README for the full rationale.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Auth

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

Auth performs the OIDC authorization-code flow and manages the app session cookie. Construct with New or NewFromEnv; mount its handlers with Auth.Mount or individually.

func New

func New(ctx context.Context, cfg Config, opts ...Option) (*Auth, error)

New validates cfg, performs OIDC discovery against cfg.Issuer, and returns an Auth ready to serve. The context governs discovery and is retained for background JWKS refreshes.

func NewFromEnv

func NewFromEnv(ctx context.Context, opts ...Option) (*Auth, error)

NewFromEnv is shorthand for FromEnv followed by New.

func (*Auth) CallbackHandler

func (a *Auth) CallbackHandler() http.Handler

CallbackHandler completes the flow: it checks state against the signed cookie, exchanges the code (with the PKCE verifier), verifies the ID token (issuer, audience, expiry, signature) and its nonce, then sets the app session cookie and redirects to the `next` path captured at login.

func (*Auth) CallbackPath

func (a *Auth) CallbackPath() string

CallbackPath returns the path component of the redirect URL, where Auth.CallbackHandler must be mounted.

func (*Auth) ClearSession

func (a *Auth) ClearSession(w http.ResponseWriter)

ClearSession removes the app session cookie without redirecting. Use it inside app handlers that end the session as part of their own flow — e.g. account deletion — where redirecting the in-flight POST to the POST-only logout endpoint is not possible. It only ends the app's session, exactly like LogoutHandler.

func (*Auth) LoginHandler

func (a *Auth) LoginHandler() http.Handler

LoginHandler starts the authorization-code flow: it generates state, nonce, and a PKCE (S256) verifier, binds them to the browser via a signed short-lived cookie, and redirects to the issuer.

Query parameters:

  • next: relative path to return to after login (default "/").
  • consent_restart: "1" marks this login as the one-time forced-consent restart that ForceApprovalIfNewUser triggers from the callback: the auth URL gains the force-consent parameters, and the flag is carried in the signed state cookie so the callback completes instead of restarting again. Anyone can forge a link with this parameter, but harmlessly so — the worst outcome is an unnecessary consent screen.

func (*Auth) LoginPath

func (a *Auth) LoginPath() string

LoginPath returns where Auth.LoginHandler expects to be mounted.

func (*Auth) LogoutHandler

func (a *Auth) LogoutHandler() http.Handler

LogoutHandler clears the app session cookie and redirects. It only ends the app's session: the issuer's own session and any upstream (e.g. Google) consent are unaffected.

Logout is POST-only, for two reasons. CSRF: with SameSite=Lax, cross-site subresources (<img>, script) never carry the session cookie, but top-level GET navigation does — so a GET logout is forceable by any cross-site link, while Lax withholds cookies from cross-site POSTs, making POST + Lax a complete defense with no CSRF token. Accidents: link prefetchers, browser prerendering, and email scanners issue GETs and would randomly end sessions; nothing prefetches a form. Log out from a form (or fetch) that issues a POST.

func (*Auth) LogoutPath

func (a *Auth) LogoutPath() string

LogoutPath returns where Auth.LogoutHandler expects to be mounted.

func (*Auth) Mount

func (a *Auth) Mount(mux *http.ServeMux)

Mount registers the login, callback, and logout handlers on mux at their configured paths.

func (*Auth) RequireAuth

func (a *Auth) RequireAuth(next http.Handler) http.Handler

RequireAuth wraps next so it only runs with a valid app session. The verified User is placed in the request context for UserFromContext. Unauthenticated GET/HEAD requests are redirected to the login handler with `next` set to the requested path; other methods get 401.

func (*Auth) User

func (a *Auth) User(r *http.Request) (u User, ok bool)

User returns the request's session user, if any. Use it on public pages that adapt to login state without requiring it (e.g. showing login vs logout links). Handlers behind Auth.RequireAuth should prefer UserFromContext.

type Config

type Config struct {
	// Issuer is the OIDC issuer URL, e.g. "https://auth.example.com".
	// Discovery and JWKS URLs derive from it.
	Issuer string
	// ClientID is the OAuth2 client id; it is also the expected `aud`
	// of every ID token this Auth accepts.
	ClientID string
	// ClientSecret is the OAuth2 client secret.
	ClientSecret string
	// RedirectURL is the app's absolute OAuth callback URL, e.g.
	// "https://app.example.com/auth/callback". Its path is where
	// [Auth.CallbackHandler] must be mounted ([Auth.Mount] does this).
	// A "http://" scheme (local dev) disables the cookie Secure flag.
	RedirectURL string
	// CookieSecret is the HMAC key for state and session cookies.
	// It must be at least 32 bytes, e.g. from `openssl rand -hex 32`.
	CookieSecret string
}

Config holds the settings required to construct an Auth. Use FromEnv to populate it from AUTH_* environment variables.

func FromEnv

func FromEnv() (Config, error)

FromEnv builds a Config from the environment:

AUTH_ISSUER, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET,
AUTH_REDIRECT_URL, AUTH_COOKIE_SECRET

It returns an error naming every missing variable.

type Option

type Option func(*Auth) error

Option customizes an Auth.

func ForceApprovalIfNewUser

func ForceApprovalIfNewUser(known func(sub string) bool) Option

ForceApprovalIfNewUser makes the callback restart the auth request with a forced consent prompt when known(sub) reports an unfamiliar user, so each user sees an explicit consent screen exactly once per app. known must be fast and non-blocking (e.g. a map or indexed DB lookup). The restart happens at most once per login attempt. The parameters sent on the restart are issuer-neutral by default; see WithForceConsentParams.

func WithCookieName

func WithCookieName(name string) Option

WithCookieName sets the session cookie name (default "_oidcauth"). The state cookie is named "<name>_state".

func WithExtraClaims

func WithExtraClaims(names ...string) Option

WithExtraClaims names additional ID-token claims to carry into the session, exposed raw on User.Extra. Use it when the issuer emits claims beyond the standard profile set that apps need at login — e.g. upstream-identity claims a broker adds for account-migration durability. Claims absent from a token are simply omitted. Keep the list small: the session rides in a cookie.

func WithForceConsentParams

func WithForceConsentParams(params map[string]string) Option

WithForceConsentParams replaces the extra authorization-request parameters sent when ForceApprovalIfNewUser triggers a consent restart. The default sends both the standard OIDC `prompt=consent` and the pre-OIDC `approval_prompt=force`: conformant issuers must ignore parameters they don't recognize (RFC 6749 §3.1), so each issuer honors the one it knows — no issuer-specific configuration needed. Override only for an issuer that rejects the combination (Google, used directly, errors on conflicting prompt parameters):

oidcauth.WithForceConsentParams(map[string]string{"prompt": "consent"})

func WithLoginPath

func WithLoginPath(p string) Option

WithLoginPath sets where Auth.LoginHandler is mounted (default "/auth/login"). Auth.RequireAuth redirects unauthenticated GET requests here.

func WithLogoutPath

func WithLogoutPath(p string) Option

WithLogoutPath sets where Auth.LogoutHandler is mounted (default "/auth/logout").

func WithPostLogoutRedirect

func WithPostLogoutRedirect(p string) Option

WithPostLogoutRedirect sets where Auth.LogoutHandler redirects after clearing the session (default "/").

func WithSessionTTL

func WithSessionTTL(d time.Duration) Option

WithSessionTTL sets the app session lifetime (default 24h).

type User

type User struct {
	// Issuer is the verified `iss` of the ID token. Together with Sub
	// it forms the account key: sub is unique only within an issuer.
	Issuer string `json:"iss"`
	// Sub is the issuer-unique, never-reassigned user identifier.
	// (Issuer, Sub) is the ONLY pair apps may key accounts on.
	Sub string `json:"sub"`
	// Email is for display and recovery only — never an account key,
	// and never a basis for automatically linking accounts.
	Email         string `json:"email"`
	EmailVerified bool   `json:"email_verified"`
	Name          string `json:"name"`
	// Extra holds the raw JSON of claims requested via
	// [WithExtraClaims], keyed by claim name; absent claims have no
	// entry. Unmarshal into app types as needed.
	Extra map[string]json.RawMessage `json:"extra,omitempty"`
}

User holds the verified identity claims stored in the app session.

func UserFromContext

func UserFromContext(ctx context.Context) (u User, ok bool)

UserFromContext returns the User stored by Auth.RequireAuth. ok is false outside a RequireAuth-wrapped handler.

Jump to

Keyboard shortcuts

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