oidc: client wrapper around go-oidc — discovery, exchange, claim parse
This commit is contained in:
@@ -3,22 +3,25 @@ module gitea.dcglab.co.uk/steve/restic-manager
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coder/websocket v1.8.14
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
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/crypto v0.50.0
|
||||||
|
golang.org/x/sys v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.0
|
modernc.org/sqlite v1.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
|
||||||
modernc.org/libc v1.72.0 // indirect
|
modernc.org/libc v1.72.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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=
|
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/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 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user