~bigbes/lethe

ref: 76a281a0eca48e3ecdafe1663d5fb7ac68286a5f lethe/internal/server/auth/middleware.go -rw-r--r-- 6.2 KiB
76a281a0 — Eugene Blikh server: embed web SPA at /, wire build pipeline 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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// Package auth implements the HTTP authentication boundary for the lethe
// server. It provides two coordinated paths — forward-auth (a trusted
// reverse-proxy header) and OIDC bearer (validated against an issuer's JWKS)
// — and gates every /api/v1/* route on a shared allowlist.
//
// The Authenticator is a steward service: zero-value, configured via the
// `config:""` tag, with all setup performed in Init. It holds the lowercased
// allowlist and admin sets so the request path stays allocation-free.
//
// Resolution order (per spec):
//  1. If OIDC is enabled and the request carries `Authorization: Bearer ...`,
//     the token is verified and its username claim is the resolved user. A
//     present-but-invalid bearer is a hard 401 — never falls back to the
//     header path. This is the "fail-closed" invariant.
//  2. Otherwise, if forward-auth is enabled and the configured user header is
//     non-empty, that header value is the resolved user.
//  3. Otherwise, 401.
//
// After resolution, the user is lowercased and matched against the allowlist
// (403 on miss). The same allowlist applies regardless of which path resolved
// the user — there is no "OIDC bypasses the list" mode. The resolved user is
// stamped onto the request context (for handlers via IdentityFrom and for the
// access logger via observability.WithUser) before next.ServeHTTP.
package auth

import (
	"context"
	"net/http"
	"strings"

	"go.bigb.es/auxilia/culpa"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
	"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
)

// bearerPrefix is the case-sensitive prefix per RFC 6750 §2.1 that selects
// the OIDC verification path.
const bearerPrefix = "Bearer "

// Identity is the authenticated principal stored on the request context for
// downstream handlers. IsAdmin reflects membership in `auth.admins`.
type Identity struct {
	User    string
	IsAdmin bool
}

// identityKey is the unexported context key for Identity values. Using a
// dedicated zero-size type prevents collisions with values stored under
// string keys elsewhere.
type identityKey struct{}

// WithIdentity attaches id to ctx. Handlers downstream of the auth middleware
// can recover it with IdentityFrom or MustIdentity.
func WithIdentity(ctx context.Context, id Identity) context.Context {
	return context.WithValue(ctx, identityKey{}, id)
}

// IdentityFrom returns the Identity previously attached with WithIdentity.
// The boolean is false when no identity is present (i.e. the request was
// dispatched outside the authenticated /api/v1/* group).
func IdentityFrom(ctx context.Context) (Identity, bool) {
	v, ok := ctx.Value(identityKey{}).(Identity)
	return v, ok
}

// MustIdentity returns the Identity stored on ctx and panics if none is
// present. Handlers that mount under the auth middleware can rely on it; a
// missing identity is a programmer error in route mounting.
func MustIdentity(ctx context.Context) Identity {
	id, ok := IdentityFrom(ctx)
	if !ok {
		panic("auth: MustIdentity called outside authenticated route group")
	}
	return id
}

// Authenticator is the steward-managed HTTP middleware that resolves the
// caller's identity. Verifier is optional and only consulted when OIDC is
// enabled; main.go (Phase 9) is responsible for registering OIDCVerifier
// conditionally on `cfg.Auth.OIDC.Enabled`.
type Authenticator struct {
	Cfg config.AuthConfig `config:""`

	Log      *observability.Logger `inject:""`
	Verifier *OIDCVerifier         `inject:"" optional:"true"`

	allowed map[string]struct{}
	admins  map[string]struct{}
}

// Init builds the lowercased allowlist and admin sets and asserts that the
// configured paths have the dependencies they need. Forward-auth without a
// header name and OIDC without a verifier are config invariant breaches —
// both should have been caught earlier (config validator / Phase-9 wiring),
// but defense-in-depth fails closed here too.
func (a *Authenticator) Init(_ context.Context) error {
	if a.Cfg.OIDC.Enabled && a.Verifier == nil {
		return culpa.WithCode(
			culpa.New("OIDC enabled but verifier not registered"),
			"CONFIG_INVALID",
		)
	}
	if a.Cfg.ForwardAuth.Enabled && a.Cfg.ForwardAuth.UserHeader == "" {
		return culpa.WithCode(
			culpa.New("forward_auth enabled but user_header is empty"),
			"CONFIG_INVALID",
		)
	}

	a.allowed = make(map[string]struct{}, len(a.Cfg.AllowedUsers))
	for _, u := range a.Cfg.AllowedUsers {
		a.allowed[strings.ToLower(u)] = struct{}{}
	}
	a.admins = make(map[string]struct{}, len(a.Cfg.Admins))
	for _, u := range a.Cfg.Admins {
		a.admins[strings.ToLower(u)] = struct{}{}
	}
	return nil
}

// Middleware returns an http.Handler that authenticates the request before
// calling next. Failures render an RFC 7807 problem document via apierror
// and short-circuit the chain.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, err := a.resolveUser(r)
		if err != nil {
			apierror.Render(w, r, err)
			return
		}

		user = strings.ToLower(user)
		if _, ok := a.allowed[user]; !ok {
			apierror.Render(w, r, culpa.WithCode(
				culpa.New("user not in allowlist"),
				"FORBIDDEN",
			))
			return
		}

		_, isAdmin := a.admins[user]
		ctx := WithIdentity(r.Context(), Identity{User: user, IsAdmin: isAdmin})
		ctx = observability.WithUser(ctx, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// resolveUser implements the bearer-then-header resolution order described
// in the package doc. A present-but-invalid bearer never falls back to the
// header path — that would silently downgrade auth and is the bedrock test
// case in the suite.
func (a *Authenticator) resolveUser(r *http.Request) (string, error) {
	if a.Cfg.OIDC.Enabled {
		authz := r.Header.Get("Authorization")
		if strings.HasPrefix(authz, bearerPrefix) {
			token := strings.TrimPrefix(authz, bearerPrefix)
			user, err := a.Verifier.Verify(r.Context(), token)
			if err != nil {
				return "", err
			}
			return user, nil
		}
	}

	if a.Cfg.ForwardAuth.Enabled {
		if v := r.Header.Get(a.Cfg.ForwardAuth.UserHeader); v != "" {
			return v, nil
		}
	}

	return "", culpa.WithCode(
		culpa.New("authentication required"),
		"UNAUTHORIZED",
	)
}