Add OIDC login and callback against Authentik
internal/auth discovers the provider at MEALQUEST_OIDC_ISSUER with coreos/go-oidc/v3 and runs a confidential-client authorization code flow with golang.org/x/oauth2 (client_secret_basic). GET /auth/login stores state, nonce and a PKCE S256 verifier in a 10-minute HMAC-signed HttpOnly __Host- cookie and redirects with scopes "openid profile email groups offline_access". An optional "return" path must be same-origin; anything else fails the login with 400. GET /auth/callback clears the single-use cookie, checks state, exchanges the code with the verifier, verifies the ID token (RS256 only, issuer, audience, expiry) and nonce, then hands the claims to a SessionStarter. cmd/server wires a placeholder that refuses every login until the session layer lands. Config gains required MEALQUEST_OIDC_ISSUER, MEALQUEST_OIDC_CLIENT_ID, MEALQUEST_OIDC_CLIENT_SECRET_FILE and MEALQUEST_OIDC_ALLOWED_GROUPS. Pins go-oidc v3.21.0 and oauth2 v0.36.0, the newest releases that support Go 1.25 (oauth2 v0.37.0 requires Go 1.26). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,12 @@ export MEALQUEST_PUBLIC_URL=http://localhost:5173
|
||||
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
|
||||
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
|
||||
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
|
||||
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
|
||||
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
|
||||
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
|
||||
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
||||
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
make dev
|
||||
```
|
||||
|
||||
|
||||
+21
-1
@@ -13,6 +13,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -51,7 +52,18 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
}
|
||||
}()
|
||||
|
||||
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||
authFlow, err := auth.New(ctx, auth.Config{
|
||||
Issuer: cfg.OIDCIssuer.String(),
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
||||
PublicURL: cfg.PublicURL,
|
||||
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||
}, sessionsPending{}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, authFlow, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,6 +115,14 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// sessionsPending refuses every login until the session layer (Feature 03
|
||||
// task 2) replaces it.
|
||||
type sessionsPending struct{}
|
||||
|
||||
func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error {
|
||||
return errors.New("session layer not implemented")
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -3,13 +3,16 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/oklog/ulid/v2 v2.1.2
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
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.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.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.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -19,6 +23,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// Package auth signs the household in with Authentik over OpenID Connect.
|
||||
// The service is a confidential client: the authorization code, tokens and
|
||||
// client secret stay on the server and never reach JavaScript.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Routes owned by this package.
|
||||
const (
|
||||
LoginPath = "/auth/login"
|
||||
CallbackPath = "/auth/callback"
|
||||
)
|
||||
|
||||
// providerTimeout bounds every request to the identity provider.
|
||||
const providerTimeout = 10 * time.Second
|
||||
|
||||
// scopes requested at login.
|
||||
var scopes = []string{oidc.ScopeOpenID, "profile", "email", "groups", oidc.ScopeOfflineAccess}
|
||||
|
||||
// Claims are the verified ID token claims handed to the session layer.
|
||||
type Claims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// SessionStarter is the session layer's entry point. StartSession is called
|
||||
// once per successful login with claims from a verified ID token; it must set
|
||||
// the session cookie on w. A returned error aborts the login with 500.
|
||||
type SessionStarter interface {
|
||||
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
|
||||
}
|
||||
|
||||
// Config is what New needs to reach the identity provider.
|
||||
type Config struct {
|
||||
// Issuer is the exact issuer URL; discovery runs against it.
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// PublicURL is the service's public URL; its origin prefixes CallbackPath
|
||||
// to form the redirect URI.
|
||||
PublicURL *url.URL
|
||||
// SecretKey is the server key the login cookie's signing key derives from.
|
||||
SecretKey []byte
|
||||
}
|
||||
|
||||
// Auth serves the login and callback routes.
|
||||
type Auth struct {
|
||||
oauth oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
sessions SessionStarter
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New discovers the provider at cfg.Issuer and returns the login flow. It
|
||||
// fails if discovery fails or the issuer the provider reports differs.
|
||||
func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.Logger) (*Auth, error) {
|
||||
switch {
|
||||
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
|
||||
return nil, errors.New("auth: issuer, client ID and client secret are required")
|
||||
case cfg.PublicURL == nil:
|
||||
return nil, errors.New("auth: public URL is required")
|
||||
case len(cfg.SecretKey) == 0:
|
||||
return nil, errors.New("auth: secret key is required")
|
||||
case sessions == nil:
|
||||
return nil, errors.New("auth: session starter is required")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout}
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
// client_secret_basic, the OIDC default; no auto-detection retries.
|
||||
endpoint.AuthStyle = oauth2.AuthStyleInHeader
|
||||
|
||||
return &Auth{
|
||||
oauth: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: endpoint,
|
||||
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
|
||||
Scopes: scopes,
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
SupportedSigningAlgs: []string{oidc.RS256},
|
||||
}),
|
||||
client: client,
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
sessions: sessions,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Mount registers GET LoginPath and GET CallbackPath on r.
|
||||
func (a *Auth) Mount(r chi.Router) {
|
||||
r.Get(LoginPath, a.login)
|
||||
r.Get(CallbackPath, a.callback)
|
||||
}
|
||||
|
||||
// login starts the authorization code flow with PKCE (S256). The optional
|
||||
// "return" query parameter must be a same-origin path; anything else fails
|
||||
// the login with 400.
|
||||
func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
returnTo, err := returnPath(r.URL.Query().Get("return"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid return path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
state := loginState{
|
||||
State: rand.Text(),
|
||||
Nonce: rand.Text(),
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Return: returnTo,
|
||||
}
|
||||
if err := a.cookie.set(w, state, time.Now()); err != nil {
|
||||
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, a.oauth.AuthCodeURL(state.State,
|
||||
oidc.Nonce(state.Nonce),
|
||||
oauth2.S256ChallengeOption(state.Verifier),
|
||||
), http.StatusFound)
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state against the login cookie,
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session.
|
||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
ctx := r.Context()
|
||||
// The login cookie is single-use.
|
||||
a.cookie.clear(w)
|
||||
|
||||
state, err := a.cookie.read(r, time.Now())
|
||||
if err != nil {
|
||||
a.logger.WarnContext(ctx, "login callback without a valid login cookie", "error", err)
|
||||
http.Error(w, "sign-in expired; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
|
||||
a.logger.WarnContext(ctx, "login callback state mismatch")
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if e := q.Get("error"); e != "" {
|
||||
a.logger.WarnContext(ctx, "identity provider refused sign-in", "error", e, "description", q.Get("error_description"))
|
||||
http.Error(w, "sign-in was refused", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := a.verify(ctx, code, state)
|
||||
if err != nil {
|
||||
a.logger.WarnContext(ctx, "sign-in failed", "error", err)
|
||||
http.Error(w, "sign-in failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := a.sessions.StartSession(w, r, claims); err != nil {
|
||||
a.logger.ErrorContext(ctx, "start session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, state.Return, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||
// here.
|
||||
func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claims, error) {
|
||||
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
|
||||
oauth2.VerifierOption(state.Verifier))
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
raw, ok := token.Extra("id_token").(string)
|
||||
if !ok || raw == "" {
|
||||
return Claims{}, errors.New("token response has no id_token")
|
||||
}
|
||||
idToken, err := a.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify ID token: %w", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||
return Claims{}, errors.New("ID token nonce mismatch")
|
||||
}
|
||||
var claims Claims
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return Claims{}, fmt.Errorf("decode ID token claims: %w", err)
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return Claims{}, errors.New("ID token has no subject")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
testClientID = "mealquest"
|
||||
testClientSecret = "client-s3cret"
|
||||
testKID = "test-key"
|
||||
)
|
||||
|
||||
var testPublicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// fakeProvider is an OIDC provider serving discovery, JWKS and a token
|
||||
// endpoint. Each issued code maps to the ID token the token endpoint returns.
|
||||
type fakeProvider struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
|
||||
mu sync.Mutex
|
||||
codes map[string]issuedCode
|
||||
}
|
||||
|
||||
type issuedCode struct {
|
||||
challenge string
|
||||
idToken string
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &fakeProvider{t: t, key: key, codes: map[string]issuedCode{}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", p.discovery)
|
||||
mux.HandleFunc("GET /jwks", p.jwks)
|
||||
mux.HandleFunc("POST /token", p.token)
|
||||
p.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(p.srv.Close)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *fakeProvider) issuer() string { return p.srv.URL }
|
||||
|
||||
func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{
|
||||
"issuer": p.issuer(),
|
||||
"authorization_endpoint": p.issuer() + "/authorize",
|
||||
"token_endpoint": p.issuer() + "/token",
|
||||
"jwks_uri": p.issuer() + "/jwks",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
|
||||
})
|
||||
}
|
||||
|
||||
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
||||
"kty": "RSA",
|
||||
"kid": testKID,
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": b64(p.key.N.Bytes()),
|
||||
"e": b64(big.NewInt(int64(p.key.E)).Bytes()),
|
||||
}}})
|
||||
}
|
||||
|
||||
// token checks client authentication, the code and the PKCE verifier before
|
||||
// returning the code's ID token.
|
||||
func (p *fakeProvider) token(w http.ResponseWriter, r *http.Request) {
|
||||
id, secret, ok := r.BasicAuth()
|
||||
if !ok || id != testClientID || secret != testClientSecret {
|
||||
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
issued, ok := p.codes[r.PostForm.Get("code")]
|
||||
delete(p.codes, r.PostForm.Get("code"))
|
||||
p.mu.Unlock()
|
||||
if r.PostForm.Get("grant_type") != "authorization_code" || !ok ||
|
||||
challengeS256(r.PostForm.Get("code_verifier")) != issued.challenge {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
io.WriteString(w, `{"error":"invalid_grant"}`)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"access_token": "access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": issued.idToken,
|
||||
})
|
||||
}
|
||||
|
||||
// authorize stands in for the user signing in at the provider: it records a
|
||||
// code bound to the PKCE challenge and returns it.
|
||||
func (p *fakeProvider) authorize(challenge, idToken string) string {
|
||||
code := rand.Text()
|
||||
p.mu.Lock()
|
||||
p.codes[code] = issuedCode{challenge: challenge, idToken: idToken}
|
||||
p.mu.Unlock()
|
||||
return code
|
||||
}
|
||||
|
||||
// claims returns valid ID token claims for nonce.
|
||||
func (p *fakeProvider) claims(nonce string) map[string]any {
|
||||
now := time.Now()
|
||||
return map[string]any{
|
||||
"iss": p.issuer(),
|
||||
"sub": "user-123",
|
||||
"aud": testClientID,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(5 * time.Minute).Unix(),
|
||||
"nonce": nonce,
|
||||
"email": "cook@example.com",
|
||||
"email_verified": true,
|
||||
"name": "Home Cook",
|
||||
"preferred_username": "cook",
|
||||
"groups": []string{"mealquest-admin"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeProvider) signRS256(claims map[string]any) string {
|
||||
return signRS256(p.t, p.key, claims)
|
||||
}
|
||||
|
||||
func signRS256(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string {
|
||||
t.Helper()
|
||||
signing := jwtSigningInput(t, "RS256", claims)
|
||||
digest := sha256.Sum256([]byte(signing))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signing + "." + b64(sig)
|
||||
}
|
||||
|
||||
// signHS256 signs with the client secret, as in an algorithm-confusion attack.
|
||||
func (p *fakeProvider) signHS256(claims map[string]any) string {
|
||||
signing := jwtSigningInput(p.t, "HS256", claims)
|
||||
mac := hmac.New(sha256.New, []byte(testClientSecret))
|
||||
mac.Write([]byte(signing))
|
||||
return signing + "." + b64(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func jwtSigningInput(t *testing.T, alg string, claims map[string]any) string {
|
||||
t.Helper()
|
||||
header, err := json.Marshal(map[string]string{"alg": alg, "typ": "JWT", "kid": testKID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b64(header) + "." + b64(body)
|
||||
}
|
||||
|
||||
func challengeS256(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return b64(sum[:])
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// memorySessions is an in-memory SessionStarter that records the claims it
|
||||
// was handed.
|
||||
type memorySessions struct {
|
||||
mu sync.Mutex
|
||||
claims []Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, claims Claims) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.claims = append(m.claims, claims)
|
||||
http.SetCookie(w, &http.Cookie{Name: "test_session", Value: claims.Subject})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySessions) started() []Claims {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return slices.Clone(m.claims)
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
provider *fakeProvider
|
||||
sessions *memorySessions
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
p := newFakeProvider(t)
|
||||
sessions := &memorySessions{}
|
||||
a, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
}, sessions, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
return &harness{provider: p, sessions: sessions, router: r}
|
||||
}
|
||||
|
||||
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, req)
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
// login is a started login: the authorize URL's query and the login cookie.
|
||||
type login struct {
|
||||
query url.Values
|
||||
cookie *http.Cookie
|
||||
}
|
||||
|
||||
func (h *harness) login(t *testing.T, target string) login {
|
||||
t.Helper()
|
||||
resp := h.get(target)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("GET %s status = %d, want 302", target, resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := loc.Scheme+"://"+loc.Host+loc.Path, h.provider.issuer()+"/authorize"; got != want {
|
||||
t.Fatalf("redirect to %s, want %s", got, want)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == loginCookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatalf("login set no %s cookie", loginCookieName)
|
||||
}
|
||||
return login{query: loc.Query(), cookie: cookie}
|
||||
}
|
||||
|
||||
// callback completes l at the provider with idToken and calls the callback.
|
||||
func (h *harness) callback(l login, state, idToken string) *http.Response {
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), idToken)
|
||||
q := url.Values{"code": {code}, "state": {state}}
|
||||
return h.get(CallbackPath+"?"+q.Encode(), l.cookie)
|
||||
}
|
||||
|
||||
func TestLoginRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath+"?return=/plan")
|
||||
|
||||
want := map[string]string{
|
||||
"client_id": testClientID,
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email groups offline_access",
|
||||
"redirect_uri": "https://meals.example.com/auth/callback",
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got := l.query.Get(k); got != v {
|
||||
t.Errorf("authorize %s = %q, want %q", k, got, v)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
||||
if l.query.Get(k) == "" {
|
||||
t.Errorf("authorize URL has no %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
c := l.cookie
|
||||
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
|
||||
t.Errorf("login cookie HttpOnly=%v Secure=%v SameSite=%v Path=%q, want HttpOnly, Secure, Lax, /",
|
||||
c.HttpOnly, c.Secure, c.SameSite, c.Path)
|
||||
}
|
||||
if c.MaxAge <= 0 || c.MaxAge > 600 {
|
||||
t.Errorf("login cookie MaxAge = %d, want 1-600 seconds", c.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginStateIsFreshEachTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
a, b := h.login(t, LoginPath), h.login(t, LoginPath)
|
||||
for _, k := range []string{"state", "nonce", "code_challenge"} {
|
||||
if a.query.Get(k) == b.query.Get(k) {
|
||||
t.Errorf("%s repeated across logins", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath+"?return="+url.QueryEscape("/plan?week=2026-W40"))
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "/plan?week=2026-W40" {
|
||||
t.Errorf("callback redirect = %q, want the return path", got)
|
||||
}
|
||||
started := h.sessions.started()
|
||||
want := Claims{
|
||||
Subject: "user-123",
|
||||
Email: "cook@example.com",
|
||||
EmailVerified: true,
|
||||
Name: "Home Cook",
|
||||
PreferredUsername: "cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
}
|
||||
if len(started) != 1 || started[0].Subject != want.Subject || started[0].Email != want.Email ||
|
||||
started[0].Name != want.Name || started[0].PreferredUsername != want.PreferredUsername ||
|
||||
!started[0].EmailVerified || !slices.Equal(started[0].Groups, want.Groups) {
|
||||
t.Errorf("session started with %+v, want [%+v]", started, want)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
func TestCallbackDefaultReturnPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
|
||||
t.Errorf("callback = %d to %q, want 303 to /", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// state returns the state sent to the callback.
|
||||
state func(l login) string
|
||||
// token returns the ID token the provider issues.
|
||||
token func(p *fakeProvider, l login) string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "mismatched state",
|
||||
state: func(login) string { return "attacker-state" },
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong nonce",
|
||||
token: func(p *fakeProvider, _ login) string {
|
||||
return p.signRS256(p.claims("other-nonce"))
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "missing nonce",
|
||||
token: func(p *fakeProvider, _ login) string {
|
||||
c := p.claims("")
|
||||
delete(c, "nonce")
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "HS256-signed token",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
return p.signHS256(p.claims(l.query.Get("nonce")))
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
c := p.claims(l.query.Get("nonce"))
|
||||
c["iat"] = time.Now().Add(-2 * time.Hour).Unix()
|
||||
c["exp"] = time.Now().Add(-time.Hour).Unix()
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
c := p.claims(l.query.Get("nonce"))
|
||||
c["iss"] = "https://evil.example.com"
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "wrong audience",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
c := p.claims(l.query.Get("nonce"))
|
||||
c["aud"] = "another-client"
|
||||
return p.signRS256(c)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "signed by an unknown key",
|
||||
token: func(p *fakeProvider, l login) string {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
p.t.Fatal(err)
|
||||
}
|
||||
return signRS256(p.t, key, p.claims(l.query.Get("nonce")))
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
state := l.query.Get("state")
|
||||
if tc.state != nil {
|
||||
state = tc.state(l)
|
||||
}
|
||||
token := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
||||
if tc.token != nil {
|
||||
token = tc.token(h.provider, l)
|
||||
}
|
||||
|
||||
resp := h.callback(l, state, token)
|
||||
|
||||
if resp.StatusCode != tc.wantStatus {
|
||||
t.Errorf("callback status = %d, want %d", resp.StatusCode, tc.wantStatus)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackWithoutLoginCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
l.cookie = &http.Cookie{Name: "unrelated", Value: "x"}
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackTamperedLoginCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
payload, sig, _ := strings.Cut(l.cookie.Value, ".")
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var s loginState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Return = "/somewhere-else"
|
||||
data, err = json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.cookie = &http.Cookie{Name: loginCookieName, Value: b64(data) + "." + sig}
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackProviderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
q := url.Values{"error": {"access_denied"}, "state": {l.query.Get("state")}}
|
||||
resp := h.get(CallbackPath+"?"+q.Encode(), l.cookie)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
l := h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("callback status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCookieExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newLoginCookie([]byte("server-secret-key"))
|
||||
now := time.Now()
|
||||
rec := httptest.NewRecorder()
|
||||
if err := c.set(rec, loginState{State: "s", Nonce: "n", Verifier: "v", Return: "/"}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
||||
req.AddCookie(rec.Result().Cookies()[0])
|
||||
|
||||
if _, err := c.read(req, now.Add(loginCookieTTL-time.Second)); err != nil {
|
||||
t.Errorf("read before expiry: %v", err)
|
||||
}
|
||||
if _, err := c.read(req, now.Add(loginCookieTTL)); err == nil {
|
||||
t.Error("read at expiry succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
for _, ret := range []string{
|
||||
"https://evil.example.com/",
|
||||
"http://meals.example.com/plan",
|
||||
"//evil.example.com/",
|
||||
"/\\evil.example.com",
|
||||
"\\\\evil.example.com",
|
||||
"javascript:alert(1)",
|
||||
"plan",
|
||||
"/plan\r\nSet-Cookie: x=y",
|
||||
} {
|
||||
resp := h.get(LoginPath + "?return=" + url.QueryEscape(ret))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("return=%q: status = %d, want 400", ret, resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
t.Errorf("return=%q: redirected to %q", ret, loc)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("return=%q: set cookies %v", ret, resp.Cookies())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
t.Cleanup(srv.Close)
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
// Discovery trims the trailing slash, so this fetches the provider's
|
||||
// document, which reports the issuer without it.
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer() + "/",
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("New accepted a provider reporting a different issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func assertLoginCookieCleared(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == loginCookieName && c.MaxAge < 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("callback did not clear the %s cookie", loginCookieName)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
loginCookieName = "__Host-mealquest_login"
|
||||
loginCookieTTL = 10 * time.Minute
|
||||
// loginCookieKeyLabel separates the login cookie's signing key from other
|
||||
// keys derived from the same server secret.
|
||||
loginCookieKeyLabel = "mealquest auth login cookie v1"
|
||||
)
|
||||
|
||||
// loginState travels from login to callback in the signed login cookie.
|
||||
type loginState struct {
|
||||
State string `json:"s"`
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
Return string `json:"r"`
|
||||
Expires int64 `json:"e"` // Unix seconds
|
||||
}
|
||||
|
||||
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
|
||||
type loginCookie struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func newLoginCookie(secretKey []byte) loginCookie {
|
||||
mac := hmac.New(sha256.New, secretKey)
|
||||
mac.Write([]byte(loginCookieKeyLabel))
|
||||
return loginCookie{key: mac.Sum(nil)}
|
||||
}
|
||||
|
||||
func (c loginCookie) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
|
||||
s.Expires = now.Add(loginCookieTTL).Unix()
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(data)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: loginCookieName,
|
||||
Value: payload + "." + c.sign(payload),
|
||||
Path: "/",
|
||||
MaxAge: int(loginCookieTTL / time.Second),
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
// Lax so the cookie rides the provider's top-level redirect back.
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c loginCookie) clear(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: loginCookieName,
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
|
||||
cookie, err := r.Cookie(loginCookieName)
|
||||
if err != nil {
|
||||
return loginState{}, errors.New("login cookie missing")
|
||||
}
|
||||
payload, sig, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
|
||||
return loginState{}, errors.New("login cookie signature invalid")
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
|
||||
}
|
||||
var s loginState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
|
||||
}
|
||||
if now.Unix() >= s.Expires {
|
||||
return loginState{}, errors.New("login cookie expired")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// returnPath validates the optional post-login return path. Empty means "/".
|
||||
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
|
||||
// relative "//" prefix, backslashes or control characters.
|
||||
func returnPath(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "/", nil
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') {
|
||||
return "", errors.New("return path must be a same-origin path")
|
||||
}
|
||||
for _, c := range raw {
|
||||
if c < 0x20 || c == 0x7f {
|
||||
return "", errors.New("return path contains a control character")
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
|
||||
return "", errors.New("return path must be a same-origin path")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -20,6 +20,11 @@ const (
|
||||
EnvDataDir = "MEALQUEST_DATA_DIR"
|
||||
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
||||
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
|
||||
|
||||
EnvOIDCIssuer = "MEALQUEST_OIDC_ISSUER"
|
||||
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
|
||||
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
|
||||
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
|
||||
)
|
||||
|
||||
// Config is the validated service configuration.
|
||||
@@ -29,6 +34,13 @@ type Config struct {
|
||||
DataDir string
|
||||
ShutdownTimeout time.Duration
|
||||
SecretKey Secret
|
||||
|
||||
// OIDCIssuer is the Authentik issuer URL used for discovery.
|
||||
OIDCIssuer *url.URL
|
||||
OIDCClientID string
|
||||
OIDCClientSecret Secret
|
||||
// OIDCAllowedGroups lists the groups whose members may sign in.
|
||||
OIDCAllowedGroups []string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -51,6 +63,11 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
DataDir: r.dir(EnvDataDir),
|
||||
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
||||
SecretKey: r.secretFile(EnvSecretKeyFile),
|
||||
|
||||
OIDCIssuer: r.httpURL(EnvOIDCIssuer),
|
||||
OIDCClientID: r.str(EnvOIDCClientID),
|
||||
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
|
||||
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
|
||||
}
|
||||
if len(r.problems) > 0 {
|
||||
return Config{}, &Error{Problems: r.problems}
|
||||
@@ -114,6 +131,29 @@ func (r *reader) required(name string) (string, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (r *reader) str(name string) string {
|
||||
v, _ := r.required(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// list parses a comma-separated list. Every entry must be non-empty after
|
||||
// trimming spaces.
|
||||
func (r *reader) list(name string) []string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := strings.Split(v, ",")
|
||||
for i, item := range items {
|
||||
items[i] = strings.TrimSpace(item)
|
||||
if items[i] == "" {
|
||||
r.fail(name, "empty entry in comma-separated list %q", v)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,6 +22,10 @@ func TestLoad(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unreadablePath := filepath.Join(dir, "missing_secret")
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyPath := filepath.Join(dir, "empty_secret")
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -33,6 +38,11 @@ func TestLoad(t *testing.T) {
|
||||
EnvDataDir: dir,
|
||||
EnvShutdownTimeout: "15s",
|
||||
EnvSecretKeyFile: secretPath,
|
||||
|
||||
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: "mealquest-admin",
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
@@ -70,6 +80,7 @@ func TestLoad(t *testing.T) {
|
||||
env: map[string]string{},
|
||||
badVars: []string{
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,6 +180,45 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
|
||||
badVars: []string{"MEALQUEST_SECRET_KEY"},
|
||||
},
|
||||
{
|
||||
name: "inline OIDC client secret rejected",
|
||||
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
|
||||
badVars: []string{"MEALQUEST_OIDC_CLIENT_SECRET"},
|
||||
},
|
||||
{
|
||||
name: "unreadable OIDC client secret file",
|
||||
env: with(map[string]string{EnvOIDCClientSecretFile: unreadablePath}),
|
||||
badVars: []string{EnvOIDCClientSecretFile},
|
||||
},
|
||||
{
|
||||
name: "relative OIDC issuer",
|
||||
env: with(map[string]string{EnvOIDCIssuer: "auth.example.com"}),
|
||||
badVars: []string{EnvOIDCIssuer},
|
||||
},
|
||||
{
|
||||
name: "non-http OIDC issuer",
|
||||
env: with(map[string]string{EnvOIDCIssuer: "ftp://auth.example.com"}),
|
||||
badVars: []string{EnvOIDCIssuer},
|
||||
},
|
||||
{
|
||||
name: "several allowed groups",
|
||||
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin, family"}),
|
||||
},
|
||||
{
|
||||
name: "empty allowed group entry",
|
||||
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,,family"}),
|
||||
badVars: []string{EnvOIDCAllowedGroups},
|
||||
},
|
||||
{
|
||||
name: "trailing comma in allowed groups",
|
||||
env: with(map[string]string{EnvOIDCAllowedGroups: "mealquest-admin,"}),
|
||||
badVars: []string{EnvOIDCAllowedGroups},
|
||||
},
|
||||
{
|
||||
name: "blank OIDC client ID",
|
||||
env: with(map[string]string{EnvOIDCClientID: " "}),
|
||||
badVars: []string{EnvOIDCClientID},
|
||||
},
|
||||
{
|
||||
name: "several invalid values are all named",
|
||||
env: with(map[string]string{
|
||||
@@ -211,7 +261,7 @@ func TestLoad(t *testing.T) {
|
||||
t.Errorf("error message %q does not name %s", err.Error(), v)
|
||||
}
|
||||
}
|
||||
if cfg != (Config{}) {
|
||||
if !reflect.ValueOf(cfg).IsZero() {
|
||||
t.Errorf("load() returned non-zero Config alongside error: %+v", cfg)
|
||||
}
|
||||
})
|
||||
@@ -226,12 +276,21 @@ func TestLoadValues(t *testing.T) {
|
||||
if err := os.WriteFile(secretPath, []byte("s3cret-value\r\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
EnvDataDir: dir,
|
||||
EnvShutdownTimeout: "1m30s",
|
||||
EnvSecretKeyFile: secretPath,
|
||||
|
||||
EnvOIDCIssuer: "https://auth.example.com/application/o/mealquest/",
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -253,6 +312,18 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.SecretKey.Reveal() != "s3cret-value" {
|
||||
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
|
||||
}
|
||||
if got, want := cfg.OIDCIssuer.String(), "https://auth.example.com/application/o/mealquest/"; got != want {
|
||||
t.Errorf("OIDCIssuer = %q, want %q", got, want)
|
||||
}
|
||||
if cfg.OIDCClientID != "mealquest" {
|
||||
t.Errorf("OIDCClientID = %q", cfg.OIDCClientID)
|
||||
}
|
||||
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
|
||||
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
|
||||
}
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
@@ -260,7 +331,7 @@ func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
s := Secret{value: "s3cret-value"}
|
||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||
cfg := Config{SecretKey: s}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s}
|
||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||
if strings.Contains(out, "s3cret-value") {
|
||||
t.Errorf("format %s leaked secret: %s", format, out)
|
||||
|
||||
@@ -16,9 +16,15 @@ import (
|
||||
// SPA's index.html.
|
||||
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
||||
|
||||
// RouteMounter registers its own routes on the router.
|
||||
type RouteMounter interface {
|
||||
Mount(r chi.Router)
|
||||
}
|
||||
|
||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||
// GET /health; assets is the web build served for every other GET.
|
||||
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
|
||||
// GET /health; auth mounts the /auth routes; assets is the web build served
|
||||
// for every other GET.
|
||||
func NewRouter(logger *slog.Logger, version string, auth RouteMounter, assets fs.FS) (http.Handler, error) {
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -32,12 +38,14 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler,
|
||||
r.NotFound(notFound)
|
||||
|
||||
r.Get("/health", health(logger, version))
|
||||
auth.Mount(r)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
})
|
||||
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
|
||||
// handler passes them to notFound rather than the SPA fallback.
|
||||
// Catch-all for the PWA. Unknown /auth paths and /media (no routes yet)
|
||||
// reach the frontend handler, which passes them to notFound rather than
|
||||
// the SPA fallback.
|
||||
r.Method(http.MethodGet, "/*", web)
|
||||
r.Method(http.MethodHead, "/*", web)
|
||||
return r, nil
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,13 +27,39 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, assets)
|
||||
r, err := NewRouter(logger, version, stubAuth{}, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
|
||||
type stubAuth struct{}
|
||||
|
||||
func (stubAuth) Mount(r chi.Router) {
|
||||
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
io.WriteString(w, "stub login")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthRoutesMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router := newTestRouter(t, "dev")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
|
||||
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -99,7 +127,7 @@ func TestReservedPathsNotFound(t *testing.T) {
|
||||
{http.MethodGet, "/api"},
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodPost, "/api/unknown"},
|
||||
{http.MethodGet, "/auth/callback"},
|
||||
{http.MethodGet, "/auth/unknown"},
|
||||
{http.MethodGet, "/media/photo.jpg"},
|
||||
{http.MethodGet, "/health/deep"},
|
||||
{http.MethodGet, "/auth/../quests/42"},
|
||||
|
||||
Reference in New Issue
Block a user