oidc: client wrapper around go-oidc — discovery, exchange, claim parse

This commit is contained in:
2026-05-05 13:20:08 +01:00
parent db2fcdd52e
commit 4594e563ef
3 changed files with 206 additions and 3 deletions
+6 -3
View File
@@ -3,22 +3,25 @@ module gitea.dcglab.co.uk/steve/restic-manager
go 1.25.0
require (
github.com/coder/websocket v1.8.14
github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.0
)
require (
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-oidc/v3 v3.18.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+6
View File
@@ -1,9 +1,13 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -25,6 +29,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+194
View File
@@ -0,0 +1,194 @@
// Package oidc wraps go-oidc + oauth2 in the small surface the
// HTTP handlers need: discovery, code-exchange config, ID-token
// verification, and role-claim resolution.
package oidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strings"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
)
// Client bundles the discovered provider + a pre-built oauth2.Config.
// Constructed once at server start; safe for concurrent use.
type Client struct {
cfg *config.OIDCConfig
provider *gooidc.Provider
verifier *gooidc.IDTokenVerifier
oauth *oauth2.Config
endSession string // discovered end_session_endpoint, "" if none
}
// New discovers the provider's well-known config and builds a Client.
// Network call — should be invoked once at startup with a context
// carrying a sane timeout. Returns an error on a 4xx/5xx from
// discovery so the operator finds out at startup, not on first login.
func New(ctx context.Context, cfg *config.OIDCConfig, baseURL string) (*Client, error) {
if cfg == nil {
return nil, errors.New("oidc: config nil")
}
prov, err := gooidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc: discovery: %w", err)
}
redir := cfg.RedirectURL
if redir == "" {
redir = strings.TrimRight(baseURL, "/") + "/auth/oidc/callback"
}
oa := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: prov.Endpoint(),
RedirectURL: redir,
Scopes: cfg.Scopes,
}
verifier := prov.Verifier(&gooidc.Config{ClientID: cfg.ClientID})
// Pull end_session_endpoint out of the discovery doc — go-oidc
// doesn't expose it as a typed field, but the underlying claims
// blob does.
var doc struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
_ = prov.Claims(&doc)
return &Client{
cfg: cfg,
provider: prov,
verifier: verifier,
oauth: oa,
endSession: doc.EndSessionEndpoint,
}, nil
}
// AuthURL returns the URL to redirect the browser to for the
// Authorization Code + PKCE flow. State + verifier are caller-
// supplied so the caller can persist them in the oidc_state table.
func (c *Client) AuthURL(state, codeChallenge string) string {
return c.oauth.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
}
// Exchange swaps a code+verifier for a token set and verifies the
// id_token. Returns the parsed Claims and the raw id_token (the
// caller stashes the raw on the session for RP-initiated logout).
func (c *Client) Exchange(ctx context.Context, code, verifier string) (*Claims, string, error) {
tok, err := c.oauth.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", verifier))
if err != nil {
return nil, "", fmt.Errorf("oidc: token exchange: %w", err)
}
rawID, ok := tok.Extra("id_token").(string)
if !ok || rawID == "" {
return nil, "", errors.New("oidc: id_token missing from token response")
}
idTok, err := c.verifier.Verify(ctx, rawID)
if err != nil {
return nil, "", fmt.Errorf("oidc: verify id_token: %w", err)
}
var raw map[string]any
if err := idTok.Claims(&raw); err != nil {
return nil, "", fmt.Errorf("oidc: claims: %w", err)
}
return parseClaims(raw, c.cfg.RoleClaim), rawID, nil
}
// EndSessionEndpoint exposes the discovered end_session URL ("" if
// the IdP doesn't advertise one).
func (c *Client) EndSessionEndpoint() string { return c.endSession }
// DisplayName for the SSO button on the login page.
func (c *Client) DisplayName() string { return c.cfg.DisplayName }
// MapRole returns the role for the first matching claim value; "" if
// none match. Caller treats "" as deny.
func (c *Client) MapRole(roles []string) string {
for _, r := range roles {
if mapped, ok := c.cfg.RoleMapping[r]; ok {
return mapped
}
}
return ""
}
// Claims is the minimal projection the callback handler cares about.
type Claims struct {
Subject string
PreferredUsername string
Email string
Roles []string // normalised from string|[]string|csv
}
// parseClaims pulls the four fields we need from the raw id_token
// claims. The 'roles' field is normalised from the three shapes
// IdPs emit (string, []string, comma-separated string).
func parseClaims(raw map[string]any, roleClaim string) *Claims {
c := &Claims{}
if v, ok := raw["sub"].(string); ok {
c.Subject = v
}
if v, ok := raw["preferred_username"].(string); ok {
c.PreferredUsername = v
}
if v, ok := raw["email"].(string); ok {
c.Email = v
}
switch v := raw[roleClaim].(type) {
case string:
for _, p := range strings.Split(v, ",") {
p = strings.TrimSpace(p)
if p != "" {
c.Roles = append(c.Roles, p)
}
}
case []any:
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
c.Roles = append(c.Roles, s)
}
}
}
return c
}
// RandomState generates 32 random bytes URL-safe base64-encoded —
// used as the 'state' parameter on the authorization request.
// Caller is expected to compute sha256(state) for storage.
func RandomState() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// PKCEPair generates a code_verifier (base64-url 64 chars) and the
// corresponding S256 code_challenge.
func PKCEPair() (verifier, challenge string, err error) {
var b [48]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", err
}
verifier = base64.RawURLEncoding.EncodeToString(b[:])
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
}
// HashState returns sha256(state) hex — used as the primary key in
// the oidc_state table (so a DB leak doesn't leak active states).
func HashState(state string) string {
sum := sha256.Sum256([]byte(state))
return fmt.Sprintf("%x", sum)
}