diff --git a/backend/go.mod b/backend/go.mod index cdeaa84..41aaccd 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require github.com/mark3labs/mcp-go v0.56.0 require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-chi/chi/v5 v5.3.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/backend/go.sum b/backend/go.sum index 756981d..15b6999 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -8,6 +8,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go new file mode 100644 index 0000000..5332918 --- /dev/null +++ b/backend/internal/auth/session.go @@ -0,0 +1,150 @@ +// Package auth implements geniusrun's own login gate: an OIDC Authorization +// Code flow against an existing Keycloak realm (see oidc.go), backed by a +// signed session cookie geniusrun mints itself (this file) and a chi +// middleware that checks it (middleware.go). Keycloak's own tokens are never +// stored or refreshed -- once HandleCallback verifies the ID token and role, +// only this package's own cookie matters for subsequent requests. +package auth + +import ( + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // SessionCookieName holds the signed proof that a request's caller + // completed login and passed the required-role check. + SessionCookieName = "geniusrun_session" + // TxnCookieName holds the short-lived OIDC login transaction (state + + // PKCE verifier) between BeginLogin's redirect and HandleCallback. + TxnCookieName = "geniusrun_oidc_txn" + + txnCookieDuration = 10 * time.Minute +) + +// Claims identifies the authenticated user, carried in the signed session +// cookie. +type Claims struct { + Sub string + Name string + Email string +} + +type sessionClaims struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// TxnState is the OIDC login transaction round-tripped via TxnCookieName +// between BeginLogin and HandleCallback. +type TxnState struct { + State string + CodeVerifier string +} + +type txnClaims struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + jwt.RegisteredClaims +} + +// MintSessionCookie signs claims into a JWT valid for duration and wraps it +// in a cookie. secure should be true whenever the app is served over HTTPS. +func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ + Sub: claims.Sub, + Name: claims.Name, + Email: claims.Email, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(duration)), + }, + }) + signed, err := token.SignedString(secret) + if err != nil { + return nil, fmt.Errorf("sign session token: %w", err) + } + return &http.Cookie{ + Name: SessionCookieName, + Value: signed, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: now.Add(duration), + }, nil +} + +// ParseSessionCookie verifies the cookie's signature and expiry and decodes +// its Claims. +func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) { + if cookie == nil { + return Claims{}, fmt.Errorf("no session cookie") + } + var sc sessionClaims + if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { + return Claims{}, fmt.Errorf("parse session token: %w", err) + } + return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil +} + +// MintTxnCookie signs an OIDC login transaction into a short-lived cookie. +func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, txnClaims{ + State: txn.State, + CodeVerifier: txn.CodeVerifier, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(txnCookieDuration)), + }, + }) + signed, err := token.SignedString(secret) + if err != nil { + return nil, fmt.Errorf("sign txn token: %w", err) + } + return &http.Cookie{ + Name: TxnCookieName, + Value: signed, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: now.Add(txnCookieDuration), + }, nil +} + +// ParseTxnCookie verifies and decodes a login transaction cookie. +func ParseTxnCookie(cookie *http.Cookie, secret []byte) (TxnState, error) { + if cookie == nil { + return TxnState{}, fmt.Errorf("no txn cookie") + } + var tc txnClaims + if _, err := jwt.ParseWithClaims(cookie.Value, &tc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { + return TxnState{}, fmt.Errorf("parse txn token: %w", err) + } + return TxnState{State: tc.State, CodeVerifier: tc.CodeVerifier}, nil +} + +// ClearCookie returns a cookie that immediately expires the named cookie. +func ClearCookie(name string, secure bool) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } +} + +func keyfunc(secret []byte) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { return secret, nil } +} diff --git a/backend/internal/auth/session_test.go b/backend/internal/auth/session_test.go new file mode 100644 index 0000000..7b0f3f1 --- /dev/null +++ b/backend/internal/auth/session_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "testing" + "time" +) + +const testSecret = "test-secret-at-least-32-bytes-long!" + +func TestMintAndParseSessionCookie(t *testing.T) { + claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"} + cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true) + if err != nil { + t.Fatalf("mint: %v", err) + } + if cookie.Name != SessionCookieName || !cookie.HttpOnly || !cookie.Secure { + t.Fatalf("cookie = %+v, want name=%s HttpOnly+Secure", cookie, SessionCookieName) + } + + got, err := ParseSessionCookie(cookie, []byte(testSecret)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != claims { + t.Fatalf("got %+v, want %+v", got, claims) + } +} + +func TestParseSessionCookie_Expired(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil { + t.Fatal("expected error for expired cookie") + } +} + +func TestParseSessionCookie_Tampered(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x" + if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil { + t.Fatal("expected error for tampered cookie") + } +} + +func TestParseSessionCookie_WrongSecret(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + if _, err := ParseSessionCookie(cookie, []byte("a-completely-different-secret!!")); err == nil { + t.Fatal("expected error for wrong secret") + } +} + +func TestMintAndParseTxnCookie(t *testing.T) { + txn := TxnState{State: "abc123", CodeVerifier: "verifier-xyz"} + cookie, err := MintTxnCookie(txn, []byte(testSecret), false) + if err != nil { + t.Fatalf("mint: %v", err) + } + if cookie.Name != TxnCookieName { + t.Fatalf("cookie name = %q, want %q", cookie.Name, TxnCookieName) + } + + got, err := ParseTxnCookie(cookie, []byte(testSecret)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != txn { + t.Fatalf("got %+v, want %+v", got, txn) + } +} + +func TestClearCookie(t *testing.T) { + c := ClearCookie(SessionCookieName, true) + if c.Value != "" || c.MaxAge >= 0 { + t.Fatalf("ClearCookie = %+v, want empty value and negative MaxAge", c) + } +}