A small library providing go HTTP handlers for OIDC authentication and API key validation.
Find a file
2026-08-11 02:11:04 -04:00
pkg feat(deps): add argon2id password hashing package 2026-08-01 04:57:00 -04:00
.gitignore build: configure goreleaser to skip builds 2026-03-18 01:56:15 -04:00
.golangci.yml build: add golangci-lint and goreleaser configs 2026-03-18 01:25:43 -04:00
.goreleaser.yaml build: configure goreleaser to skip builds 2026-03-18 01:56:15 -04:00
auth.go feat(auth): make OIDC providers optional for password-only deployments 2026-08-03 16:09:00 -04:00
callback.go feat(auth): support multiple OIDC providers with provider-specific routing 2026-08-01 22:17:32 -04:00
errs.go feat(auth): add session-based authentication with password validation 2026-07-31 16:28:50 -04:00
go.mod fix(auth): update uuid import to standard library 2026-08-01 05:16:30 -04:00
go.sum fix(auth): update uuid import to standard library 2026-08-01 05:16:30 -04:00
handlers.go refactor(auth): simplify session token storage and management 2026-08-01 23:06:29 -04:00
info.go refactor(auth): simplify session token storage and management 2026-08-01 23:06:29 -04:00
LICENSE Initial commit 2026-02-06 18:41:56 -05:00
login.go feat(auth): support multiple OIDC providers with provider-specific routing 2026-08-01 22:17:32 -04:00
logout.go refactor(auth): simplify session token storage and management 2026-08-01 23:06:29 -04:00
middleware.go feat(auth): support multiple OIDC providers with provider-specific routing 2026-08-01 22:17:32 -04:00
oicd.go fix(auth): remove hardcoded OIDC prompt parameter 2026-08-11 00:43:17 -04:00
options.go feat(auth): make login and redirect paths provider-specific 2026-08-11 02:11:04 -04:00
README.md feat(auth): make OIDC providers optional for password-only deployments 2026-08-03 16:09:00 -04:00
session.go refactor(auth): simplify session token storage and management 2026-08-01 23:06:29 -04:00

simple-auth

A lightweight Go library providing HTTP handlers for OIDC, session-based, and API key authentication.

Features

  • OIDC/OAuth 2.0 authentication with automatic token refresh
  • PKCE support for enhanced security
  • API key authentication with BLAKE2b hashing
  • Session-based authentication with password validation and token rotation
  • Cookie-based session management with encryption
  • JWT profile authentication support
  • Pluggable user storage via interface

Installation

go get git.crawford.zone/jd/simple-auth

Quick Start

OIDC Authentication

// Define a User type
type MyUser struct{}

func (u *MyUser) Authorized(path string) bool { return true }
func (u *MyUser) NeedsUpdate(claims *oidc.IDTokenClaims) bool {
    return false
}
func (u *MyUser) GetInfo() (simpleauth.UserInfo, error) {
    return simpleauth.UserInfo{Name: "display name"}, nil
}

// Define a UserHandler
type MyUserHandler struct{}

func (h *MyUserHandler) CreateUser(ctx context.Context, claims *oidc.IDTokenClaims) (string, error) {
    return "", nil
}
func (h *MyUserHandler) GetUserID(ctx context.Context, subject, provider string) (string, error) {
    return "", simpleauth.ErrNoUser
}
func (h *MyUserHandler) GetUser(ctx context.Context, id string) (*MyUser, error) {
    return &MyUser{}, nil
}
func (h *MyUserHandler) UpdateUser(ctx context.Context, id string, claims *oidc.IDTokenClaims) (*MyUser, error) {
    return &MyUser{}, nil
}

// Create Auth instance with one or more OIDC providers
auth, err := simpleauth.New[string, *MyUser](
    context.Background(),
    simpleauth.Handlers[string, *MyUser]{User: &MyUserHandler{}},
    slog.Default(),
    simpleauth.WithProvider("google",
        simpleauth.WithOAuthClient("google-client-id", "google-client-secret", "https://app.com/auth/callback/google"),
        simpleauth.WithIssuer("https://accounts.google.com"),
    ),
    simpleauth.WithProvider("entra",
        simpleauth.WithOAuthClient("entra-client-id", "entra-client-secret", "https://app.com/auth/callback/entra"),
        simpleauth.WithIssuer("https://login.microsoftonline.com/{tenant}/v2.0"),
        simpleauth.WithPkce(true),
    ),
    simpleauth.WithSecurityKeys(hashKey, encryptionKey),
    simpleauth.WithPaths(
        simpleauth.WithHomePath("/"),
        simpleauth.WithLoginPath("/login"),
        simpleauth.WithLogoutPath("/logout"),
        simpleauth.WithRedirectPath("/auth/callback"),
    ),
)

// Register handlers. The {provider} path segment selects the provider and
// must match a provider name configured via WithProvider.
mux := http.NewServeMux()
mux.HandleFunc("/auth/login/{provider}", auth.LoginHandler())
mux.HandleFunc("/auth/logout", auth.LogoutHandler())
mux.HandleFunc("/auth/callback/{provider}", auth.CallbackHandler())
mux.Handle("/", auth.InfoHandler(auth.RequireAuthorizedHandler(handler)))

Each provider needs its own absolute redirect URL containing its path segment (e.g. https://app.com/auth/callback/google), registered at the identity provider. After login the provider name is stored in an encrypted cookie so subsequent requests and token refreshes are verified against the correct provider.

Session Authentication

// Define SessionHandler
type MySessionHandler struct{}

func (h *MySessionHandler) ValidatePassword(ctx context.Context, userID string, password string) (bool, error) {
    return password == "correct-password", nil
}
func (h *MySessionHandler) GenToken(ctx context.Context, userID string, userAgent string) (string, error) {
    return "session-token", nil
}
func (h *MySessionHandler) CheckToken(ctx context.Context, token string, userID string, userAgent string) (bool, error) {
    return token == "session-token", nil
}

// Create Auth instance with session support
auth, err := simpleauth.New[string, *MyUser](
    context.Background(),
    simpleauth.Handlers[string, *MyUser]{
        User:    &MyUserHandler{},
        Session: &MySessionHandler{},
    },
    slog.Default(),
    simpleauth.WithProvider("google",
        simpleauth.WithOAuthClient("google-client-id", "google-client-secret", "https://app.com/auth/callback/google"),
        simpleauth.WithIssuer("https://accounts.google.com"),
    ),
    simpleauth.WithSecurityKeys(hashKey, encryptionKey),
    simpleauth.WithMaxSessionLength(72*time.Hour),
)

// Authenticate user with email/password
func loginHandler(w http.ResponseWriter, r *http.Request) {
    email := r.FormValue("email")
    password := r.FormValue("password")
    if err := auth.NewSession(w, r, email, password); err != nil {
        http.Error(w, "Invalid credentials", http.StatusUnauthorized)
        return
    }
    http.Redirect(w, r, "/dashboard", http.StatusFound)
}

API Key Authentication

// Define KeyHandler
type MyKeyHandler struct{}

func (h *MyKeyHandler) HashKey(key string) string {
    sum := blake2b.Sum256([]byte(key))
    return base64.RawURLEncoding.EncodeToString(sum[:])
}

func (h *MyKeyHandler) Valid(ctx context.Context, hashedKey string) (bool, error) {
    return true, nil
}
func (h *MyKeyHandler) KeyOwner(ctx context.Context, hashedKey string) (string, error) {
    return "owner-id", nil
}

// Create Auth instance with API key support
apiAuth, err := simpleauth.New[string, *MyUser](
    context.Background(),
    simpleauth.Handlers[string, *MyUser]{
        User: &MyUserHandler{},
        Key:  &MyKeyHandler{},
    },
    slog.Default(),
    simpleauth.WithProvider("google",
        simpleauth.WithOAuthClient("google-client-id", "google-client-secret", "https://app.com/auth/callback/google"),
        simpleauth.WithIssuer("https://accounts.google.com"),
    ),
    simpleauth.WithSecurityKeys(hashKey, encryptionKey),
)

// Register middleware
mux := http.NewServeMux()
mux.HandleFunc("/api/data", apiAuth.InfoHandler(handler))
mux.HandleFunc("/api/private", apiAuth.InfoHandler(apiAuth.APIRequireAuthorizedHandler(handler)))

Configuration

Auth Options

Global options passed to New:

Option Description
WithSecurityKeys Hash key (32/64 bytes) and encryption key (16/24/32 bytes)
WithProvider Register an OIDC provider by name with one or more ProviderOptions
WithInsecureHost Allow HTTP cookies
WithCookiePrefix Cookie prefix (default: auth)
WithMinAPIKeyLength Minimum API key length (default: 32)
WithPaths Path configuration
WithMaxSessionLength Max session duration (default: 24h)

ProviderOptions passed to WithProvider(name, ...ProviderOption):

Option Description
WithOAuthClient Client ID, secret, and redirect URL
WithClientID OAuth client ID
WithClientSecret OAuth client secret
WithRedirectURL OAuth redirect URL
WithIssuer OIDC issuer URL
WithScopes OAuth scopes (default: openid, email, profile)
WithPkce Enable PKCE flow
WithJWKSKey JWT signing key path and key ID
WithResponseMode OAuth response mode
WithInsecureProvider Skip TLS verification for this provider

Paths

Field Description Default
Home Post-login redirect /
Login Login page prefix /auth/login
Logout Logout handler /auth/logout
Redirect OAuth callback prefix /auth/callback
Unauthorized Unauthorized redirect -

The Login and Redirect prefixes are registered with a /{provider} segment, e.g. /auth/login/{provider} and /auth/callback/{provider}. Login is also used as the redirect target when an unauthenticated user hits a protected page: with a single provider the library redirects directly to {Login}/{provider}; with zero or multiple providers it redirects to Login, so point it at a password login page or a page that offers a provider chooser.

Migrating to Multi-Provider

This release replaces the single global OIDC configuration with a per-provider configuration. Existing setups need the following changes:

  1. Options become provider options. The single-provider options (WithOAuthClient, WithIssuer, WithClientID, WithClientSecret, WithRedirectURL, WithScopes, WithPkce, WithJWKSKey, WithResponseMode, WithInsecureProvider) are now ProviderOptions and must be passed to WithProvider(name, ...).

    Before:

    simpleauth.WithOAuthClient("id", "secret", "https://app.com/auth/callback"),
    simpleauth.WithIssuer("https://issuer.com"),
    

    After:

    simpleauth.WithProvider("default",
        simpleauth.WithOAuthClient("id", "secret", "https://app.com/auth/callback/default"),
        simpleauth.WithIssuer("https://issuer.com"),
    ),
    
  2. Mux patterns gain a {provider} segment. Register /auth/login/{provider} and /auth/callback/{provider} (Go 1.22+ mux). The segment must match a provider name from WithProvider.

  3. UserHandler.GetUserID signature changed. It now takes (ctx, subject, provider). The subject is only unique within the provider; store the composite (subject, provider) identity.

  4. Providers are now optional. Password-only and API-key deployments may configure zero providers on the same Auth instance.

Existing sessions (cookies from before the upgrade, which lack the provider cookie) are treated as unauthenticated and redirected to login once. Acceptable for a breaking release.

Interfaces

UserHandler

type UserHandler[ID any, U User] interface {
    CreateUser(ctx context.Context, claims *oidc.IDTokenClaims) (ID, error)
    GetUserID(ctx context.Context, subject, provider string) (ID, error)
    GetUser(ctx context.Context, id ID) (U, error)
    UpdateUser(ctx context.Context, id ID, claims *oidc.IDTokenClaims) (U, error)
}

GetUserID's subject is only unique within the given provider, so handlers must store the composite (subject, provider) identity. CreateUser and UpdateUser receive the full ID token claims (including the issuer) and are unchanged.

User

type User interface {
    Authorized(path string) bool
    NeedsUpdate(claims *oidc.IDTokenClaims) bool
    GetInfo() (UserInfo, error)
}

KeyHandler

type KeyHandler[ID any] interface {
    HashKey(key string) string
    Valid(ctx context.Context, hashedKey string) (bool, error)
    KeyOwner(ctx context.Context, hashedKey string) (ID, error)
}

SessionHandler

type SessionHandler[ID any] interface {
    ValidatePassword(ctx context.Context, userID ID, password string) (bool, error)
    GenToken(ctx context.Context, userID ID, userAgent string) (string, error)
    CheckToken(ctx context.Context, token string, userID ID, userAgent string) (bool, error)
}

Middleware

InfoHandler

Extracts authentication info and adds it to the request context. Handles OIDC, session, and API key auth based on request attributes. Must be used before RequireAuthorizedHandler.

mux.Handle("/protected", auth.InfoHandler(handler))

RequireAuthorizedHandler

For web pages - redirects to login/unauthorized pages when not authenticated/authorized.

mux.Handle("/dashboard", auth.InfoHandler(auth.RequireAuthorizedHandler(handler)))

APIRequireAuthorizedHandler

For API endpoints - returns HTTP 401/403 with WWW-Authenticate header instead of redirects.

mux.Handle("/api/data", auth.InfoHandler(auth.APIRequireAuthorizedHandler(handler)))

Session Management

NewSession

Creates a new session for a user given email and password. Validates credentials via SessionHandler.ValidatePassword, generates a token via SessionHandler.GenToken, and sets an encrypted session cookie.

err := auth.NewSession(w, r, email, password)

RefreshSession

Refreshes the session issuance timestamp to extend its lifetime without re-authentication. Automatically called when the session is within 30 minutes of expiry.

err := auth.RefreshSession(w, r, sessionToken)

Errors

Error Description
ErrNoUser Returned by UserHandler.GetUserID when user not found
ErrInvalidApiKey Invalid or expired API key
ErrInvalidSession Session token validation failed
ErrInvalidPassword Invalid email or password
ErrExpiredSession Session has exceeded max lifetime

Accessing User Information

func handler(w http.ResponseWriter, r *http.Request) {
    info := auth.GetInfo(r)
    
    if !info.Authenticated {
        // Redirect to your login page, which typically presents a provider
        // chooser linking to /auth/login/{provider}.
        http.Redirect(w, r, "/login", http.StatusFound)
        return
    }
    
    // info.ID      - User ID
    // info.Name    - Display name
    // info.Picture - Profile URL
    // info.Authorized - Path authorization status
}

Accessing API Key Information

func handler(w http.ResponseWriter, r *http.Request) {
    info := apiAuth.GetInfo(r)
    
    if !info.Authenticated {
        w.Header().Set("WWW-Authenticate", `Bearer realm="Restricted"`)
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }
    
    // info.HashedKey - BLAKE2b-hashed API key
}