auth: add signed session/transaction cookie mint and parse
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
150
backend/internal/auth/session.go
Normal file
150
backend/internal/auth/session.go
Normal file
@@ -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 }
|
||||
}
|
||||
84
backend/internal/auth/session_test.go
Normal file
84
backend/internal/auth/session_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user