merge: Dex OIDC session (Worker B, agent/ui-dex-auth)
This commit is contained in:
@@ -3,11 +3,13 @@ module gitea.d-ma.be/mathias/tapir
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/fergusstrange/embedded-postgres v1.34.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -23,6 +25,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s=
|
||||
github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
|
||||
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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -83,8 +87,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// Package oidc implements web.Auth against a Dex OIDC provider using the
|
||||
// standard Authorization Code flow (docs/ui-spec.md §6, ADR-011). It is the
|
||||
// production counterpart to web.StubAuth: handlers depend only on the web.Auth
|
||||
// interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not
|
||||
// a code change (ADR-003).
|
||||
//
|
||||
// Authentication is real (Dex OIDC); authorization is single-user — the ID
|
||||
// token's subject must equal Config.AllowedSubject or the request is refused
|
||||
// with 403. Sessions are server-side (in-memory, fine for the single Stage-0
|
||||
// replica) addressed by an HMAC-signed (HS256) HttpOnly Secure SameSite=Lax
|
||||
// cookie with a short TTL and sliding refresh. Tokens are never logged.
|
||||
//
|
||||
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
|
||||
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// Config is the OIDC + session configuration. cmd/tapir maps these from
|
||||
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET/TAPIR_ALLOWED_SUBJECT; this
|
||||
// package takes the resolved struct.
|
||||
type Config struct {
|
||||
// Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery
|
||||
// (.well-known/openid-configuration) runs against it in New.
|
||||
Issuer string
|
||||
// ClientID / ClientSecret are the registered Dex static client credentials.
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// RedirectURL is the absolute callback URL, e.g.
|
||||
// https://tapir.d-ma.be/auth/callback. Must match the Dex client config.
|
||||
RedirectURL string
|
||||
// SessionSecret keys the HS256 session-cookie signature. Never logged.
|
||||
SessionSecret string
|
||||
// AllowedSubject is the single Dex subject permitted to sign in. Everyone
|
||||
// else is refused 403 (single-user authz, ADR-011).
|
||||
AllowedSubject string
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSessionTTL = time.Hour
|
||||
pendingTTL = 10 * time.Minute
|
||||
sessionCookie = "tapir_session"
|
||||
loginPath = "/auth/login"
|
||||
)
|
||||
|
||||
// DexAuth implements web.Auth against Dex.
|
||||
type DexAuth struct {
|
||||
cfg Config
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
|
||||
sessions *sessionStore
|
||||
pending *pendingStore
|
||||
secret []byte
|
||||
sessionTTL time.Duration
|
||||
now func() time.Time
|
||||
insecure bool // tests only: drop the Secure attribute so cookies flow over http
|
||||
}
|
||||
|
||||
var _ web.Auth = (*DexAuth)(nil)
|
||||
|
||||
// Option tunes a DexAuth. Production wiring passes none; tests inject a clock,
|
||||
// a shorter TTL, or insecure cookies.
|
||||
type Option func(*DexAuth)
|
||||
|
||||
// WithClock overrides the time source (sliding-refresh and expiry tests).
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(d *DexAuth) {
|
||||
if now != nil {
|
||||
d.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessionTTL overrides the session lifetime.
|
||||
func WithSessionTTL(ttl time.Duration) Option {
|
||||
return func(d *DexAuth) {
|
||||
if ttl > 0 {
|
||||
d.sessionTTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithInsecureCookies drops the Secure cookie attribute. TEST ONLY — it lets
|
||||
// the session cookie travel over plain http; never use it in production.
|
||||
func WithInsecureCookies() Option {
|
||||
return func(d *DexAuth) { d.insecure = true }
|
||||
}
|
||||
|
||||
// New discovers the issuer and returns a configured DexAuth. ctx bounds the
|
||||
// discovery request only.
|
||||
func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
|
||||
for name, val := range map[string]string{
|
||||
"issuer": cfg.Issuer,
|
||||
"client id": cfg.ClientID,
|
||||
"client secret": cfg.ClientSecret,
|
||||
"redirect url": cfg.RedirectURL,
|
||||
"session secret": cfg.SessionSecret,
|
||||
"allowed subject": cfg.AllowedSubject,
|
||||
} {
|
||||
if strings.TrimSpace(val) == "" {
|
||||
return nil, fmt.Errorf("oidc: missing %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: discover issuer: %w", err)
|
||||
}
|
||||
|
||||
d := &DexAuth{
|
||||
cfg: cfg,
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
oauth: &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
sessions: newSessionStore(),
|
||||
pending: newPendingStore(),
|
||||
secret: []byte(cfg.SessionSecret),
|
||||
sessionTTL: defaultSessionTTL,
|
||||
now: time.Now,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(d)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Routes mounts the auth endpoints. Patterns are absolute so the handler works
|
||||
// whether the caller mounts it at the root or under the /auth/ subtree.
|
||||
func (d *DexAuth) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/login", d.handleLogin)
|
||||
mux.HandleFunc("/auth/callback", d.handleCallback)
|
||||
mux.HandleFunc("/auth/logout", d.handleLogout)
|
||||
return mux
|
||||
}
|
||||
|
||||
// Middleware redirects unauthenticated requests to the login endpoint and lets
|
||||
// authenticated ones through, sliding the session expiry forward. /healthz and
|
||||
// /auth/* are always public (avoids a redirect loop when the whole mux is
|
||||
// wrapped).
|
||||
func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isPublicPath(r.URL.Path) {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
sid, ok := d.sessionID(r)
|
||||
if !ok {
|
||||
d.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
||||
d.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentUser resolves the authenticated principal from the session cookie.
|
||||
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
|
||||
sid, ok := d.sessionID(r)
|
||||
if !ok {
|
||||
return web.User{}, false
|
||||
}
|
||||
data, ok := d.sessions.get(sid, d.now())
|
||||
if !ok {
|
||||
return web.User{}, false
|
||||
}
|
||||
return data.user, true
|
||||
}
|
||||
|
||||
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := randToken()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
nonce, err := randToken()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.pending.put(state, nonce, d.now().Add(pendingTTL))
|
||||
http.Redirect(w, r, d.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("error") != "" {
|
||||
http.Error(w, "authentication failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := q.Get("state")
|
||||
nonce, ok := d.pending.take(state, d.now())
|
||||
if state == "" || !ok {
|
||||
http.Error(w, "invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := d.oauth.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Error(w, "token exchange failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "no id token in response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
idToken, err := d.verifier.Verify(r.Context(), rawID)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if idToken.Nonce != nonce {
|
||||
http.Error(w, "nonce mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Single-user authz: only the allowlisted subject may sign in.
|
||||
if idToken.Subject != d.cfg.AllowedSubject {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
|
||||
|
||||
sid, err := randToken()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.sessions.put(sid, sessionData{
|
||||
user: web.User{Subject: idToken.Subject, Email: claims.Email},
|
||||
expiry: d.now().Add(d.sessionTTL),
|
||||
})
|
||||
d.setSessionCookie(w, sid)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if sid, ok := d.sessionID(r); ok {
|
||||
d.sessions.delete(sid)
|
||||
}
|
||||
d.clearSessionCookie(w)
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||
}
|
||||
|
||||
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return d.unsign(c.Value)
|
||||
}
|
||||
|
||||
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: d.sign(sid),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: !d.insecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: !d.insecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func isPublicPath(p string) bool {
|
||||
return p == "/healthz" || strings.HasPrefix(p, "/auth/")
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
josev4 "github.com/go-jose/go-jose/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
testClientID = "tapir-web"
|
||||
allowedSub = "allowed-subject-123"
|
||||
)
|
||||
|
||||
// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
|
||||
// document, a JWKS, and a token endpoint that mints an RS256-signed ID token
|
||||
// from its mutable sub/email/nonce fields. No live Dex.
|
||||
type fakeIssuer struct {
|
||||
server *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
clientID string
|
||||
|
||||
sub string
|
||||
email string
|
||||
nonce string
|
||||
}
|
||||
|
||||
const testKID = "test-key"
|
||||
|
||||
func newFakeIssuer(t *testing.T) *fakeIssuer {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
f := &fakeIssuer{key: key, clientID: testClientID}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(t, w, map[string]any{
|
||||
"issuer": f.server.URL,
|
||||
"authorization_endpoint": f.server.URL + "/authorize",
|
||||
"token_endpoint": f.server.URL + "/token",
|
||||
"jwks_uri": f.server.URL + "/jwks",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(t, w, josev4.JSONWebKeySet{Keys: []josev4.JSONWebKey{{
|
||||
Key: &f.key.PublicKey,
|
||||
KeyID: testKID,
|
||||
Algorithm: "RS256",
|
||||
Use: "sig",
|
||||
}}})
|
||||
})
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(t, w, map[string]any{
|
||||
"access_token": "fake-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": f.signIDToken(t),
|
||||
})
|
||||
})
|
||||
|
||||
f.server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.server.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeIssuer) signIDToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
signer, err := josev4.NewSigner(
|
||||
josev4.SigningKey{Algorithm: josev4.RS256, Key: f.key},
|
||||
(&josev4.SignerOptions{}).WithType("JWT").WithHeader("kid", testKID),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"iss": f.server.URL,
|
||||
"sub": f.sub,
|
||||
"aud": f.clientID,
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"nonce": f.nonce,
|
||||
"email": f.email,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
obj, err := signer.Sign(payload)
|
||||
require.NoError(t, err)
|
||||
s, err := obj.CompactSerialize()
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(v))
|
||||
}
|
||||
|
||||
func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth {
|
||||
t.Helper()
|
||||
auth, err := oidc.New(context.Background(), oidc.Config{
|
||||
Issuer: f.server.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: "test-client-secret",
|
||||
RedirectURL: "http://tapir.test/auth/callback",
|
||||
SessionSecret: "test-session-secret-please-change",
|
||||
AllowedSubject: allowedSub,
|
||||
}, oidc.WithInsecureCookies())
|
||||
require.NoError(t, err)
|
||||
return auth
|
||||
}
|
||||
|
||||
// login drives /auth/login and returns the state and nonce from the authorize
|
||||
// redirect, as a real browser hop would surface them.
|
||||
func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
q := loc.Query()
|
||||
return q.Get("state"), q.Get("nonce")
|
||||
}
|
||||
|
||||
// authenticate completes a full login+callback for the allowlisted subject and
|
||||
// returns the resulting session cookie.
|
||||
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
|
||||
t.Helper()
|
||||
state, nonce := login(t, auth)
|
||||
f.sub, f.email, f.nonce = allowedSub, "maintainer@d-ma.be", nonce
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
||||
"/auth/callback?code=valid-code&state="+state, nil))
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||
|
||||
c := sessionCookie(t, rec.Result())
|
||||
require.NotEmpty(t, c.Value)
|
||||
return c
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, resp *http.Response) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "tapir_session" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no tapir_session cookie set")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginRedirectsToAuthorize(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, f.server.URL+"/authorize", loc.Scheme+"://"+loc.Host+loc.Path)
|
||||
|
||||
q := loc.Query()
|
||||
require.Equal(t, "code", q.Get("response_type"))
|
||||
require.Equal(t, testClientID, q.Get("client_id"))
|
||||
require.NotEmpty(t, q.Get("state"))
|
||||
require.NotEmpty(t, q.Get("nonce"))
|
||||
require.Contains(t, q.Get("scope"), "openid")
|
||||
}
|
||||
|
||||
func TestCallbackAllowedSubjectSetsSession(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
|
||||
cookie := authenticate(t, auth, f)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
user, ok := auth.CurrentUser(req)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, allowedSub, user.Subject)
|
||||
require.Equal(t, "maintainer@d-ma.be", user.Email)
|
||||
|
||||
require.True(t, cookie.HttpOnly)
|
||||
require.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestCallbackNonAllowedSubjectForbidden(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
|
||||
state, nonce := login(t, auth)
|
||||
f.sub, f.email, f.nonce = "intruder-999", "intruder@elsewhere.test", nonce
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
||||
"/auth/callback?code=valid-code&state="+state, nil))
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Empty(t, rec.Result().Cookies(), "no session for a rejected subject")
|
||||
}
|
||||
|
||||
func TestCallbackUnknownStateRejected(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
||||
"/auth/callback?code=valid-code&state=forged-state", nil))
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestMiddlewareRedirectsUnauthenticated(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
|
||||
guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestMiddlewarePassesAuthenticated(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
cookie := authenticate(t, auth, f)
|
||||
|
||||
guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot) // a marker the handler actually ran
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
guarded.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusTeapot, rec.Code)
|
||||
}
|
||||
|
||||
func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
|
||||
guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, path := range []string{"/healthz", "/auth/login"} {
|
||||
rec := httptest.NewRecorder()
|
||||
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutClearsSession(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
cookie := authenticate(t, auth, f)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
auth.Routes().ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
cleared := sessionCookie(t, rec.Result())
|
||||
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
|
||||
|
||||
// The server-side session is gone: the original cookie no longer resolves.
|
||||
check := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
check.AddCookie(cookie)
|
||||
_, ok := auth.CurrentUser(check)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestExpiredSessionRejected(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
clock := time.Now()
|
||||
auth, err := oidc.New(context.Background(), oidc.Config{
|
||||
Issuer: f.server.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: "test-client-secret",
|
||||
RedirectURL: "http://tapir.test/auth/callback",
|
||||
SessionSecret: "test-session-secret-please-change",
|
||||
AllowedSubject: allowedSub,
|
||||
}, oidc.WithInsecureCookies(),
|
||||
oidc.WithSessionTTL(time.Minute),
|
||||
oidc.WithClock(func() time.Time { return clock }))
|
||||
require.NoError(t, err)
|
||||
|
||||
cookie := authenticate(t, auth, f)
|
||||
|
||||
clock = clock.Add(2 * time.Minute) // push past the TTL
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
_, ok := auth.CurrentUser(req)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestTamperedCookieRejected(t *testing.T) {
|
||||
f := newFakeIssuer(t)
|
||||
auth := newAuth(t, f)
|
||||
cookie := authenticate(t, auth, f)
|
||||
|
||||
tampered := &http.Cookie{Name: cookie.Name, Value: cookie.Value + "x"}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(tampered)
|
||||
_, ok := auth.CurrentUser(req)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
var _ web.Auth = (*oidc.DexAuth)(nil)
|
||||
@@ -0,0 +1,143 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// randToken returns a URL-safe 256-bit random string for session IDs, OIDC
|
||||
// state, and nonces.
|
||||
func randToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("oidc: read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// sign appends an HMAC-SHA256 (HS256) tag so a tampered cookie value is
|
||||
// rejected. The opaque session ID — not any token — is what travels in the
|
||||
// cookie.
|
||||
func (d *DexAuth) sign(value string) string {
|
||||
mac := hmac.New(sha256.New, d.secret)
|
||||
mac.Write([]byte(value))
|
||||
return value + "." + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// unsign verifies the HMAC tag and returns the value. It uses a constant-time
|
||||
// comparison so a bad tag cannot be probed byte by byte.
|
||||
func (d *DexAuth) unsign(signed string) (string, bool) {
|
||||
i := strings.LastIndex(signed, ".")
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
value, tag := signed[:i], signed[i+1:]
|
||||
want, err := hex.DecodeString(tag)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mac := hmac.New(sha256.New, d.secret)
|
||||
mac.Write([]byte(value))
|
||||
if !hmac.Equal(want, mac.Sum(nil)) {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// sessionData is the server-side session record.
|
||||
type sessionData struct {
|
||||
user web.User
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// sessionStore is an in-memory session table. Single replica at Stage 0, so an
|
||||
// in-process map is sufficient; it is safe for concurrent use.
|
||||
type sessionStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]sessionData
|
||||
}
|
||||
|
||||
func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} }
|
||||
|
||||
func (s *sessionStore) put(id string, d sessionData) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.m[id] = d
|
||||
}
|
||||
|
||||
// get returns the session if present and unexpired; expired entries are evicted.
|
||||
func (s *sessionStore) get(id string, now time.Time) (sessionData, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
d, ok := s.m[id]
|
||||
if !ok {
|
||||
return sessionData{}, false
|
||||
}
|
||||
if !now.Before(d.expiry) {
|
||||
delete(s.m, id)
|
||||
return sessionData{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// refresh slides an existing session's expiry forward; a no-op for unknown ids.
|
||||
func (s *sessionStore) refresh(id string, expiry time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if d, ok := s.m[id]; ok {
|
||||
d.expiry = expiry
|
||||
s.m[id] = d
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) delete(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.m, id)
|
||||
}
|
||||
|
||||
// pendingData holds the nonce bound to an in-flight authorization request.
|
||||
type pendingData struct {
|
||||
nonce string
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// pendingStore maps OIDC state to its nonce between /auth/login and the
|
||||
// callback. Entries are one-time (take deletes) and short-lived, defeating
|
||||
// replay and CSRF on the callback.
|
||||
type pendingStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]pendingData
|
||||
}
|
||||
|
||||
func newPendingStore() *pendingStore { return &pendingStore{m: make(map[string]pendingData)} }
|
||||
|
||||
func (p *pendingStore) put(state, nonce string, expiry time.Time) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.m[state] = pendingData{nonce: nonce, expiry: expiry}
|
||||
}
|
||||
|
||||
// take consumes the nonce for state, returning false if absent or expired.
|
||||
func (p *pendingStore) take(state string, now time.Time) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
d, ok := p.m[state]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
delete(p.m, state)
|
||||
if !now.Before(d.expiry) {
|
||||
return "", false
|
||||
}
|
||||
return d.nonce, true
|
||||
}
|
||||
Reference in New Issue
Block a user