oidcauth.go

v1.4.2
Doc Versions Source
1
package oidcauth
2
3
import (
4
	"context"
5
	"crypto/hmac"
6
	"crypto/rand"
7
	"crypto/sha256"
8
	"encoding/base64"
9
	"encoding/json"
10
	"fmt"
11
	"net/http"
12
	"time"
13
14
	"github.com/coreos/go-oidc/v3/oidc"
15
	"golang.org/x/oauth2"
16
17
	"go.bigb.es/curator/internal/config"
18
)
19
20
const (
21
	stateCookieName    = "curator_oidc_state"
22
	returnCookieName   = "curator_oidc_return"
23
	sessionCookieName  = "curator_session"
24
	sessionDuration    = 8 * time.Hour
25
)
26
27
// Provider handles OIDC authentication for the admin UI.
28
type Provider struct {
29
	oauth2Cfg  oauth2.Config
30
	verifier   *oidc.IDTokenVerifier
31
	sessionKey []byte // HMAC key derived from client secret
32
}
33
34
// New creates an OIDC provider by performing discovery on the issuer.
35
func New(ctx context.Context, cfg *config.OIDCConfig, clientSecret string) (*Provider, error) {
36
	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
37
	if err != nil {
38
		return nil, fmt.Errorf("oidc discovery: %w", err)
39
	}
40
41
	oauth2Cfg := oauth2.Config{
42
		ClientID:     cfg.ClientID,
43
		ClientSecret: clientSecret,
44
		RedirectURL:  cfg.RedirectURL,
45
		Endpoint:     provider.Endpoint(),
46
		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
47
	}
48
49
	verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
50
51
	// Derive session signing key from client secret via SHA-256.
52
	key := sha256.Sum256([]byte("curator-session:" + clientSecret))
53
54
	return &Provider{
55
		oauth2Cfg:  oauth2Cfg,
56
		verifier:   verifier,
57
		sessionKey: key[:],
58
	}, nil
59
}
60
61
// LoginHandler redirects to the OIDC provider's authorization endpoint.
62
func (p *Provider) LoginHandler(w http.ResponseWriter, r *http.Request) {
63
	state := randomString(32)
64
65
	http.SetCookie(w, &http.Cookie{
66
		Name:     stateCookieName,
67
		Value:    state,
68
		Path:     "/-/oidc/",
69
		MaxAge:   300,
70
		HttpOnly: true,
71
		SameSite: http.SameSiteLaxMode,
72
		Secure:   r.TLS != nil,
73
	})
74
75
	// Save return URL for post-login redirect.
76
	returnURL := r.URL.Query().Get("return")
77
	if returnURL == "" {
78
		returnURL = r.Header.Get("Referer")
79
	}
80
	if returnURL != "" {
81
		http.SetCookie(w, &http.Cookie{
82
			Name:     returnCookieName,
83
			Value:    returnURL,
84
			Path:     "/-/oidc/",
85
			MaxAge:   300,
86
			HttpOnly: true,
87
			SameSite: http.SameSiteLaxMode,
88
			Secure:   r.TLS != nil,
89
		})
90
	}
91
92
	http.Redirect(w, r, p.oauth2Cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(state)), http.StatusFound)
93
}
94
95
// CallbackHandler handles the OIDC callback, exchanges the code, and sets a session cookie.
96
func (p *Provider) CallbackHandler(w http.ResponseWriter, r *http.Request) {
97
	// Validate state.
98
	stateCookie, err := r.Cookie(stateCookieName)
99
	if err != nil || stateCookie.Value == "" {
100
		http.Error(w, "missing state cookie", http.StatusBadRequest)
101
		return
102
	}
103
	if r.URL.Query().Get("state") != stateCookie.Value {
104
		http.Error(w, "state mismatch", http.StatusBadRequest)
105
		return
106
	}
107
108
	// Clear state cookie.
109
	http.SetCookie(w, &http.Cookie{
110
		Name:   stateCookieName,
111
		Path:   "/-/oidc/",
112
		MaxAge: -1,
113
	})
114
115
	// Check for error response from provider.
116
	if errCode := r.URL.Query().Get("error"); errCode != "" {
117
		desc := r.URL.Query().Get("error_description")
118
		http.Error(w, fmt.Sprintf("oidc error: %s: %s", errCode, desc), http.StatusForbidden)
119
		return
120
	}
121
122
	// Exchange code for tokens.
123
	token, err := p.oauth2Cfg.Exchange(r.Context(), r.URL.Query().Get("code"),
124
		oauth2.VerifierOption(stateCookie.Value))
125
	if err != nil {
126
		http.Error(w, "token exchange failed", http.StatusInternalServerError)
127
		return
128
	}
129
130
	// Extract and verify ID token.
131
	rawIDToken, ok := token.Extra("id_token").(string)
132
	if !ok {
133
		http.Error(w, "missing id_token", http.StatusInternalServerError)
134
		return
135
	}
136
137
	idToken, err := p.verifier.Verify(r.Context(), rawIDToken)
138
	if err != nil {
139
		http.Error(w, "id_token verification failed", http.StatusForbidden)
140
		return
141
	}
142
143
	var claims struct {
144
		Sub   string `json:"sub"`
145
		Email string `json:"email"`
146
		Name  string `json:"name"`
147
	}
148
	if err := idToken.Claims(&claims); err != nil {
149
		http.Error(w, "failed to parse claims", http.StatusInternalServerError)
150
		return
151
	}
152
153
	// Create session cookie.
154
	sessionValue, err := p.signSession(claims.Sub, claims.Email)
155
	if err != nil {
156
		http.Error(w, "session creation failed", http.StatusInternalServerError)
157
		return
158
	}
159
160
	http.SetCookie(w, &http.Cookie{
161
		Name:     sessionCookieName,
162
		Value:    sessionValue,
163
		Path:     "/-/",
164
		MaxAge:   int(sessionDuration.Seconds()),
165
		HttpOnly: true,
166
		SameSite: http.SameSiteLaxMode,
167
		Secure:   r.TLS != nil,
168
	})
169
170
	// Also set curator_auth cookie for landing page auth.
171
	http.SetCookie(w, &http.Cookie{
172
		Name:     "curator_auth",
173
		Value:    sessionValue,
174
		Path:     "/",
175
		MaxAge:   int(sessionDuration.Seconds()),
176
		HttpOnly: true,
177
		SameSite: http.SameSiteLaxMode,
178
		Secure:   r.TLS != nil,
179
	})
180
181
	// Redirect to saved return URL or default to admin.
182
	returnURL := "/-/admin/"
183
	if cookie, err := r.Cookie(returnCookieName); err == nil && cookie.Value != "" {
184
		returnURL = cookie.Value
185
		http.SetCookie(w, &http.Cookie{
186
			Name:   returnCookieName,
187
			Path:   "/-/oidc/",
188
			MaxAge: -1,
189
		})
190
	}
191
	http.Redirect(w, r, returnURL, http.StatusFound)
192
}
193
194
// LogoutHandler clears the session cookie and redirects to the admin login.
195
func (p *Provider) LogoutHandler(w http.ResponseWriter, r *http.Request) {
196
	http.SetCookie(w, &http.Cookie{
197
		Name:   sessionCookieName,
198
		Path:   "/-/",
199
		MaxAge: -1,
200
	})
201
	http.Redirect(w, r, "/-/admin/", http.StatusFound)
202
}
203
204
// session is the JSON payload stored in the session cookie.
205
type session struct {
206
	Sub   string `json:"sub"`
207
	Email string `json:"email"`
208
	Exp   int64  `json:"exp"`
209
}
210
211
// signSession creates an HMAC-signed session cookie value.
212
func (p *Provider) signSession(sub, email string) (string, error) {
213
	s := session{
214
		Sub:   sub,
215
		Email: email,
216
		Exp:   time.Now().Add(sessionDuration).Unix(),
217
	}
218
219
	payload, err := json.Marshal(s)
220
	if err != nil {
221
		return "", err
222
	}
223
224
	mac := hmac.New(sha256.New, p.sessionKey)
225
	mac.Write(payload)
226
	sig := mac.Sum(nil)
227
228
	// payload.signature, both base64url encoded.
229
	return base64.RawURLEncoding.EncodeToString(payload) + "." +
230
		base64.RawURLEncoding.EncodeToString(sig), nil
231
}
232
233
// ValidateSession verifies an HMAC-signed session cookie value.
234
// Returns the email on success or an empty string on failure.
235
func (p *Provider) ValidateSession(value string) string {
236
	dot := -1
237
	for i := len(value) - 1; i >= 0; i-- {
238
		if value[i] == '.' {
239
			dot = i
240
			break
241
		}
242
	}
243
	if dot < 0 {
244
		return ""
245
	}
246
247
	payload, err := base64.RawURLEncoding.DecodeString(value[:dot])
248
	if err != nil {
249
		return ""
250
	}
251
	sig, err := base64.RawURLEncoding.DecodeString(value[dot+1:])
252
	if err != nil {
253
		return ""
254
	}
255
256
	mac := hmac.New(sha256.New, p.sessionKey)
257
	mac.Write(payload)
258
	if !hmac.Equal(sig, mac.Sum(nil)) {
259
		return ""
260
	}
261
262
	var s session
263
	if err := json.Unmarshal(payload, &s); err != nil {
264
		return ""
265
	}
266
	if time.Now().Unix() > s.Exp {
267
		return ""
268
	}
269
270
	if s.Email != "" {
271
		return s.Email
272
	}
273
	return s.Sub
274
}
275
276
func randomString(n int) string {
277
	b := make([]byte, n)
278
	rand.Read(b)
279
	return base64.RawURLEncoding.EncodeToString(b)
280
}
281

Source Files