From 4594e563ef3464b0c789384f7788e7e65f8516b5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 13:20:08 +0100 Subject: [PATCH] =?UTF-8?q?oidc:=20client=20wrapper=20around=20go-oidc=20?= =?UTF-8?q?=E2=80=94=20discovery,=20exchange,=20claim=20parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 9 +- go.sum | 6 ++ internal/server/oidc/oidc.go | 194 +++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 internal/server/oidc/oidc.go diff --git a/go.mod b/go.mod index b392766..276bf7a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3fcf455..3b106ca 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/server/oidc/oidc.go b/internal/server/oidc/oidc.go new file mode 100644 index 0000000..e7fa3a1 --- /dev/null +++ b/internal/server/oidc/oidc.go @@ -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) +}