~bigbes/lethe

ref: 7cffe38a672c0f5fc825235f496c72bebc9ee2b3 lethe/internal/server/auth/oidc.go -rw-r--r-- 3.8 KiB
7cffe38a — Eugene Blikh web: add display preference modules a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// OIDC bearer-token verification.
//
// OIDCVerifier wraps coreos/go-oidc/v3 with the lethe conventions: zero-value
// construction, `Init` performs the discovery handshake, `Verify` returns
// culpa-coded errors. The verifier is registered as a steward service in
// main only when `cfg.Auth.OIDC.Enabled` (Phase 9). When disabled, the
// Authenticator's `Verifier` injection point is left nil and the OIDC branch
// of the middleware never fires.
//
// Init reaches out to the issuer's `/.well-known/openid-configuration` and
// caches the JWKS — a hard-fail at startup is the chosen tradeoff: lethe
// refuses to come up rather than silently accept unauthenticated requests
// later if Authelia happens to be down at boot. (Risks doc, Phase 6.)
package auth

import (
	"context"

	"github.com/coreos/go-oidc/v3/oidc"
	"go.bigb.es/auxilia/culpa"

	"sourcecraft.dev/bigbes/lethe/internal/config"
)

// subClaim is the canonical RFC 7519 subject claim name. Used as the
// fallback when the configured username claim is absent or non-string.
const subClaim = "sub"

// OIDCVerifier is the steward-managed bearer-token verifier. It is a
// zero-value type: construct with `&OIDCVerifier{Cfg: ...}` and call Init
// before Verify. Cfg holds the full AuthConfig so steward injection by type
// matches the registered `auth` config section (sibling-consistent with
// Authenticator and OIDCDevStub); the verifier reads `Cfg.OIDC.*` fields.
type OIDCVerifier struct {
	Cfg config.AuthConfig `config:""`

	verifier      *oidc.IDTokenVerifier
	usernameClaim string
}

// Init performs OIDC discovery against Cfg.OIDC.Issuer and constructs the
// JWKS-backed verifier. Failures are wrapped with codes that distinguish the
// discovery handshake (likely a network/issuer problem) from any other
// initialization fault.
func (v *OIDCVerifier) Init(ctx context.Context) error {
	provider, err := oidc.NewProvider(ctx, v.Cfg.OIDC.Issuer)
	if err != nil {
		return culpa.WithCode(culpa.Wrap(err, "oidc discovery"), "OIDC_DISCOVERY")
	}
	v.verifier = provider.Verifier(&oidc.Config{ClientID: v.Cfg.OIDC.Audience})
	v.usernameClaim = v.Cfg.OIDC.UsernameClaim
	if v.usernameClaim == "" {
		// Defense in depth: the validator already requires a non-empty value
		// when OIDC is enabled, but a zero-value here would silently fall
		// through to `sub` on every token, which is not what the operator
		// asked for.
		return culpa.WithCode(
			culpa.New("oidc username_claim is empty"),
			"OIDC_INIT",
		)
	}
	return nil
}

// Verify validates raw against the cached JWKS, then extracts the username.
// The chosen username field is `claims[usernameClaim]` when present and a
// non-empty string, falling back to `claims[sub]`. Any verification failure
// — bad signature, expired, wrong audience, malformed — surfaces with code
// UNAUTHORIZED so the HTTP boundary renders 401.
func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (string, error) {
	idToken, err := v.verifier.Verify(ctx, raw)
	if err != nil {
		return "", culpa.WithCode(culpa.Wrap(err, "verify token"), "UNAUTHORIZED")
	}

	var claims map[string]any
	if err := idToken.Claims(&claims); err != nil {
		return "", culpa.WithCode(culpa.Wrap(err, "decode token claims"), "UNAUTHORIZED")
	}

	if user, ok := stringClaim(claims, v.usernameClaim); ok && user != "" {
		return user, nil
	}
	if sub, ok := stringClaim(claims, subClaim); ok && sub != "" {
		return sub, nil
	}
	return "", culpa.WithCode(
		culpa.New("token has no usable username claim"),
		"UNAUTHORIZED",
	)
}

// stringClaim pulls key from claims as a string. Non-string values (numbers,
// nested objects) are reported as missing rather than coerced — coercion
// would mask a misconfigured username_claim.
func stringClaim(claims map[string]any, key string) (string, bool) {
	v, ok := claims[key]
	if !ok {
		return "", false
	}
	s, ok := v.(string)
	if !ok {
		return "", false
	}
	return s, true
}