Files
geniusrun/backend/internal/auth/session.go

151 lines
4.5 KiB
Go
Raw Normal View History

// 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 }
}