feat: 实现 OIDC 服务端会话与请求刷新

使用 AES-256-GCM 保存认证中心令牌,并以随机 Cookie 哈希关联 PostgreSQL 会话。加入闲置与绝对期限、Refresh Token 轮换以及多实例刷新锁。
This commit is contained in:
chengcheng 2026-07-13 19:09:10 +08:00
parent a81a7b5200
commit d345c070ae
16 changed files with 1804 additions and 58 deletions

View File

@ -58,6 +58,18 @@ const userContextKey contextKey = "easyai-auth-user"
var ErrUnauthorized = errors.New("unauthorized") var ErrUnauthorized = errors.New("unauthorized")
type RequestAuthError struct {
Status int
Code string
Message string
}
func (e *RequestAuthError) Error() string { return e.Code }
func NewRequestAuthError(status int, code, message string) error {
return &RequestAuthError{Status: status, Code: code, Message: message}
}
type Authenticator struct { type Authenticator struct {
JWTSecret string JWTSecret string
ServerMainBaseURL string ServerMainBaseURL string
@ -67,6 +79,7 @@ type Authenticator struct {
HTTPClient *http.Client HTTPClient *http.Client
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
OIDCVerifier *OIDCVerifier OIDCVerifier *OIDCVerifier
OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error)
LegacyJWTEnabled bool LegacyJWTEnabled bool
} }
@ -95,13 +108,23 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.Authenticate(r) user, err := a.Authenticate(r)
if err != nil { if err != nil {
if strings.HasPrefix(err.Error(), ErrUnauthorized.Error()+":") {
slog.WarnContext(r.Context(), "OIDC authentication rejected", "reason", err.Error())
}
if permission == PermissionPublic { if permission == PermissionPublic {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
var requestError *RequestAuthError
if errors.As(err, &requestError) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(requestError.Status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"message": requestError.Message, "status": requestError.Status, "code": requestError.Code,
}})
return
}
if strings.HasPrefix(err.Error(), ErrUnauthorized.Error()+":") {
slog.WarnContext(r.Context(), "OIDC authentication rejected", "reason", err.Error())
}
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
@ -115,7 +138,6 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
token := extractBearer(r.Header.Get("Authorization")) token := extractBearer(r.Header.Get("Authorization"))
fromOIDCSessionCookie := false
if token == "" { if token == "" {
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key")) token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
} }
@ -127,16 +149,16 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
} }
if token == "" { if token == "" {
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil { if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
token = strings.TrimSpace(cookie.Value) sessionID := strings.TrimSpace(cookie.Value)
fromOIDCSessionCookie = token != "" if sessionID == "" || a.OIDCSessionResolver == nil {
return nil, ErrUnauthorized
}
return a.OIDCSessionResolver(r.Context(), sessionID)
} }
} }
if token == "" { if token == "" {
return nil, ErrUnauthorized return nil, ErrUnauthorized
} }
if fromOIDCSessionCookie && jwtAlgorithm(token) != "RS256" && jwtAlgorithm(token) != "ES256" {
return nil, ErrUnauthorized
}
if strings.HasPrefix(token, "sk-") { if strings.HasPrefix(token, "sk-") {
return a.verifyAPIKey(r.Context(), token) return a.verifyAPIKey(r.Context(), token)
} }

View File

@ -21,6 +21,7 @@ import (
) )
const maxOIDCResponseBytes = 1 << 20 const maxOIDCResponseBytes = 1 << 20
const defaultOIDCHTTPTimeout = 10 * time.Second
type OIDCConfig struct { type OIDCConfig struct {
Issuer string Issuer string
@ -82,7 +83,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
} }
client := config.HTTPClient client := config.HTTPClient
if client == nil { if client == nil {
client = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}} }}
} }
@ -151,6 +152,45 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
}, nil }, nil
} }
func (v *OIDCVerifier) VerifyIDToken(ctx context.Context, raw, clientID, expectedNonce string) (string, error) {
clientID = strings.TrimSpace(clientID)
expectedNonce = strings.TrimSpace(expectedNonce)
if clientID == "" || expectedNonce == "" {
return "", oidcUnauthorized("ID token validation context is invalid", nil)
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"}))
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil || unverified == nil {
return "", oidcUnauthorized("ID token envelope is invalid", err)
}
kid, _ := unverified.Header["kid"].(string)
if kid == "" {
return "", oidcUnauthorized("ID token kid is missing", nil)
}
key, err := v.key(ctx, kid)
if err != nil {
return "", oidcUnauthorized("ID token signing key lookup failed", err)
}
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) {
if token.Header["kid"] != kid {
return nil, ErrUnauthorized
}
return key, nil
}, jwt.WithValidMethods([]string{"RS256", "ES256"}), jwt.WithIssuer(v.config.Issuer),
jwt.WithAudience(clientID), jwt.WithExpirationRequired(), jwt.WithLeeway(30*time.Second))
if err != nil || !token.Valid {
return "", oidcUnauthorized("ID token signature or registered claims are invalid", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "nonce") != expectedNonce {
return "", oidcUnauthorized("ID token subject or nonce is invalid", nil)
}
if _, ok := numericDateClaim(claims["nbf"]); !ok {
return "", oidcUnauthorized("ID token nbf is missing", nil)
}
return stringClaim(claims, "sub"), nil
}
func oidcUnauthorized(reason string, cause error) error { func oidcUnauthorized(reason string, cause error) error {
if cause != nil { if cause != nil {
return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause) return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause)

View File

@ -0,0 +1,249 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
)
var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid")
type OIDCPublicClientConfig struct {
Issuer string
ClientID string
RedirectURI string
PostLogoutRedirectURI string
Scopes []string
HTTPClient *http.Client
}
type OIDCTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type OIDCPublicClient struct {
config OIDCPublicClientConfig
client *http.Client
mu sync.Mutex
metadata oidcClientDiscovery
}
type oidcClientDiscovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RevocationEndpoint string `json:"revocation_endpoint"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, error) {
config.Issuer = strings.TrimRight(strings.TrimSpace(config.Issuer), "/")
config.ClientID = strings.TrimSpace(config.ClientID)
config.RedirectURI = strings.TrimSpace(config.RedirectURI)
config.PostLogoutRedirectURI = strings.TrimSpace(config.PostLogoutRedirectURI)
config.Scopes = normalizedScopes(config.Scopes)
for _, scope := range config.Scopes {
if strings.EqualFold(scope, "offline_access") {
return nil, errors.New("offline_access is not allowed for Gateway browser sessions")
}
}
if validatePublicURL(config.Issuer) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI) != nil || validatePublicURL(config.PostLogoutRedirectURI) != nil {
return nil, errors.New("issuer, public client id and exact redirect URLs are required")
}
client := config.HTTPClient
if client == nil {
client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}
return &OIDCPublicClient{config: config, client: client}, nil
}
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, codeChallenge string) (string, error) {
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || strings.TrimSpace(codeChallenge) == "" {
return "", errors.New("state, nonce and PKCE challenge are required")
}
metadata, err := c.discovery(ctx)
if err != nil {
return "", err
}
parsed, err := url.Parse(metadata.AuthorizationEndpoint)
if err != nil {
return "", errors.New("OIDC authorization endpoint is invalid")
}
query := parsed.Query()
query.Set("response_type", "code")
query.Set("client_id", c.config.ClientID)
query.Set("redirect_uri", c.config.RedirectURI)
query.Set("scope", strings.Join(c.config.Scopes, " "))
query.Set("state", state)
query.Set("nonce", nonce)
query.Set("code_challenge", codeChallenge)
query.Set("code_challenge_method", "S256")
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func (c *OIDCPublicClient) ExchangeCode(ctx context.Context, code, verifier string) (OIDCTokenResponse, error) {
if strings.TrimSpace(code) == "" || strings.TrimSpace(verifier) == "" {
return OIDCTokenResponse{}, errors.New("authorization code and PKCE verifier are required")
}
return c.token(ctx, url.Values{
"grant_type": {"authorization_code"}, "client_id": {c.config.ClientID},
"redirect_uri": {c.config.RedirectURI}, "code": {code}, "code_verifier": {verifier},
})
}
func (c *OIDCPublicClient) Refresh(ctx context.Context, refreshToken string) (OIDCTokenResponse, error) {
if strings.TrimSpace(refreshToken) == "" {
return OIDCTokenResponse{}, ErrOIDCInvalidGrant
}
return c.token(ctx, url.Values{
"grant_type": {"refresh_token"}, "client_id": {c.config.ClientID}, "refresh_token": {refreshToken},
})
}
func (c *OIDCPublicClient) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
if strings.TrimSpace(refreshToken) == "" {
return nil
}
metadata, err := c.discovery(ctx)
if err != nil {
return err
}
if metadata.RevocationEndpoint == "" {
return errors.New("OIDC revocation endpoint is unavailable")
}
response, err := c.postForm(ctx, metadata.RevocationEndpoint, url.Values{
"client_id": {c.config.ClientID}, "token": {refreshToken}, "token_type_hint": {"refresh_token"},
})
if err != nil {
return err
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOIDCResponseBytes))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("OIDC revocation returned HTTP %d", response.StatusCode)
}
return nil
}
func (c *OIDCPublicClient) EndSessionURL(ctx context.Context, idTokenHint string) (string, error) {
metadata, err := c.discovery(ctx)
if err != nil {
return "", err
}
if metadata.EndSessionEndpoint == "" {
return c.config.PostLogoutRedirectURI, nil
}
parsed, err := url.Parse(metadata.EndSessionEndpoint)
if err != nil {
return "", errors.New("OIDC end session endpoint is invalid")
}
query := parsed.Query()
query.Set("client_id", c.config.ClientID)
query.Set("post_logout_redirect_uri", c.config.PostLogoutRedirectURI)
if strings.TrimSpace(idTokenHint) != "" {
query.Set("id_token_hint", idTokenHint)
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func (c *OIDCPublicClient) token(ctx context.Context, form url.Values) (OIDCTokenResponse, error) {
metadata, err := c.discovery(ctx)
if err != nil {
return OIDCTokenResponse{}, err
}
response, err := c.postForm(ctx, metadata.TokenEndpoint, form)
if err != nil {
return OIDCTokenResponse{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
var oauthError struct {
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes)).Decode(&oauthError)
if oauthError.Error == "invalid_grant" {
return OIDCTokenResponse{}, ErrOIDCInvalidGrant
}
return OIDCTokenResponse{}, fmt.Errorf("OIDC token endpoint returned HTTP %d", response.StatusCode)
}
var result OIDCTokenResponse
if err := json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes)).Decode(&result); err != nil || strings.TrimSpace(result.AccessToken) == "" {
return OIDCTokenResponse{}, errors.New("OIDC token response is invalid")
}
return result, nil
}
func (c *OIDCPublicClient) postForm(ctx context.Context, endpoint string, form url.Values) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
return c.client.Do(request)
}
func (c *OIDCPublicClient) discovery(ctx context.Context) (oidcClientDiscovery, error) {
c.mu.Lock()
if c.metadata.Issuer != "" {
metadata := c.metadata
c.mu.Unlock()
return metadata, nil
}
c.mu.Unlock()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.Issuer+"/.well-known/openid-configuration", nil)
if err != nil {
return oidcClientDiscovery{}, err
}
request.Header.Set("Accept", "application/json")
response, err := c.client.Do(request)
if err != nil {
return oidcClientDiscovery{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return oidcClientDiscovery{}, fmt.Errorf("OIDC discovery returned HTTP %d", response.StatusCode)
}
var metadata oidcClientDiscovery
if err := json.NewDecoder(io.LimitReader(response.Body, maxOIDCResponseBytes)).Decode(&metadata); err != nil ||
metadata.Issuer != c.config.Issuer || validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil ||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint) != nil ||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint) != nil {
return oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
}
c.mu.Lock()
c.metadata = metadata
c.mu.Unlock()
return metadata, nil
}
func normalizedScopes(scopes []string) []string {
result := make([]string, 0, len(scopes)+1)
seen := map[string]struct{}{}
for _, scope := range append([]string{"openid"}, scopes...) {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
result = append(result, scope)
}
return result
}

View File

@ -0,0 +1,125 @@
package auth
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) {
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
})
case "/token":
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("client_secret") != "" || strings.Contains(r.Header.Get("Authorization"), "Basic") {
t.Fatal("public client token request must not contain client credentials")
}
if values.Get("grant_type") != "authorization_code" || values.Get("client_id") != "gateway-public" || values.Get("code_verifier") != "verifier" {
t.Fatalf("unexpected token request: %v", values)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-token", "refresh_token": "refresh-token",
"id_token": "id-token", "expires_in": 300, "token_type": "Bearer",
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", "challenge")
if err != nil {
t.Fatal(err)
}
parsed, _ := url.Parse(authorizationURL)
query := parsed.Query()
if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != "challenge" {
t.Fatalf("authorization request is not PKCE S256: %v", query)
}
if _, err := client.ExchangeCode(context.Background(), "authorization-code", "verifier"); err != nil {
t.Fatal(err)
}
}
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
Issuer: "https://auth.example.com", ClientID: "gateway-public",
RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback",
PostLogoutRedirectURI: "https://gateway.example.com/",
Scopes: []string{"openid", "gateway.access", "offline_access"},
})
if err == nil || !strings.Contains(err.Error(), "offline_access") {
t.Fatalf("NewOIDCPublicClient() error = %v, want offline_access rejection", err)
}
}
func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
var issuer string
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token", "revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
})
case "/token", "/revoke":
requests++
body, _ := io.ReadAll(r.Body)
values, _ := url.ParseQuery(string(body))
if values.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
t.Fatal("public client request contained client authentication")
}
if r.URL.Path == "/token" {
if values.Get("grant_type") != "refresh_token" || values.Get("refresh_token") != "old-refresh" {
t.Fatalf("unexpected refresh request: %v", values)
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 300})
return
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
issuer = server.URL
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if _, err := client.Refresh(context.Background(), "old-refresh"); err != nil {
t.Fatal(err)
}
if err := client.RevokeRefreshToken(context.Background(), "new-refresh"); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("requests = %d, want 2", requests)
}
}

View File

@ -1,50 +1,23 @@
package auth package auth
import ( import (
"crypto/ecdsa" "context"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time" "time"
"github.com/golang-jwt/jwt/v5"
) )
func TestAuthenticateAcceptsValidatedOIDCSessionCookie(t *testing.T) { func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
var issuer string
issuerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"})
case "/jwks":
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
default:
http.NotFound(w, r)
}
}))
defer issuerServer.Close()
issuer = issuerServer.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: issuerServer.Client(),
})
if err != nil {
t.Fatal(err)
}
authenticator := New("local-jwt-secret", "", "") authenticator := New("local-jwt-secret", "", "")
authenticator.OIDCVerifier = verifier var resolved string
raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, nil) authenticator.OIDCSessionResolver = func(_ context.Context, raw string) (*User, error) {
resolved = raw
return &User{ID: "platform-subject", Source: "oidc", Roles: []string{"user"}}, nil
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: raw}) request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
user, err := authenticator.Authenticate(request) user, err := authenticator.Authenticate(request)
if err != nil { if err != nil {
t.Fatalf("authenticate OIDC session cookie: %v", err) t.Fatalf("authenticate OIDC session cookie: %v", err)
@ -52,8 +25,8 @@ func TestAuthenticateAcceptsValidatedOIDCSessionCookie(t *testing.T) {
if user.ID != "platform-subject" || user.Source != "oidc" { if user.ID != "platform-subject" || user.Source != "oidc" {
t.Fatalf("unexpected session user: %#v", user) t.Fatalf("unexpected session user: %#v", user)
} }
if user.TokenExpiresAt.Before(time.Now().Add(50 * time.Minute)) { if resolved != "opaque-session-id" {
t.Fatalf("token expiry was not retained: %v", user.TokenExpiresAt) t.Fatalf("session resolver received %q", resolved)
} }
} }
@ -84,3 +57,19 @@ func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
t.Fatal("invalid OIDC session cookie was accepted") t.Fatal("invalid OIDC session cookie was accepted")
} }
} }
func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) {
authenticator := New("local-jwt-secret", "", "")
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
return nil, &RequestAuthError{Status: http.StatusUnauthorized, Code: "OIDC_SESSION_EXPIRED", Message: "expired"}
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/catalog/providers", nil)
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
recorder := httptest.NewRecorder()
authenticator.Require(PermissionPublic, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("public route status = %d, want %d", recorder.Code, http.StatusNoContent)
}
}

View File

@ -104,6 +104,35 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
} }
} }
func TestOIDCVerifierValidatesIDTokenNonceAndPublicClientAudience(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/.well-known/openid-configuration" {
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
}))
defer server.Close()
issuer = server.URL
verifier, _ := NewOIDCVerifier(OIDCConfig{
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
})
idToken := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) {
claims["aud"] = "gateway-public-client"
claims["nonce"] = "expected-nonce"
})
subject, err := verifier.VerifyIDToken(context.Background(), idToken, "gateway-public-client", "expected-nonce")
if err != nil || subject != "platform-subject" {
t.Fatalf("VerifyIDToken() subject=%q err=%v", subject, err)
}
if _, err := verifier.VerifyIDToken(context.Background(), idToken, "gateway-public-client", "wrong-nonce"); err == nil {
t.Fatal("ID token with mismatched nonce was accepted")
}
}
func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) { func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048) key, _ := rsa.GenerateKey(rand.Reader, 2048)
active := true active := true

View File

@ -1,6 +1,7 @@
package config package config
import ( import (
"encoding/base64"
"errors" "errors"
"log/slog" "log/slog"
"net/url" "net/url"
@ -39,6 +40,13 @@ type Config struct {
OIDCGatewayTenantKey string OIDCGatewayTenantKey string
OIDCBrowserSessionEnabled bool OIDCBrowserSessionEnabled bool
OIDCSessionCookieSecure bool OIDCSessionCookieSecure bool
OIDCClientID string
OIDCRedirectURI string
OIDCPostLogoutRedirectURI string
OIDCSessionEncryptionKey string
OIDCSessionIdleTTLSeconds int
OIDCSessionAbsoluteTTLSeconds int
OIDCSessionRefreshBeforeSeconds int
PublicBaseURL string PublicBaseURL string
WebBaseURL string WebBaseURL string
LocalGeneratedStorageDir string LocalGeneratedStorageDir string
@ -87,12 +95,19 @@ func Load() Config {
OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE", OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE",
strconv.FormatBool(!isLocalEnvironment(appEnv)), strconv.FormatBool(!isLocalEnvironment(appEnv)),
) == "true", ) == "true",
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), OIDCClientID: env("OIDC_CLIENT_ID", ""),
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""),
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""),
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""),
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800),
OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60),
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
), ),
@ -110,6 +125,29 @@ func (c Config) Validate() error {
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true") return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
} }
if c.OIDCEnabled && c.OIDCBrowserSessionEnabled { if c.OIDCEnabled && c.OIDCBrowserSessionEnabled {
for _, scope := range c.OIDCRequiredScopes {
if strings.EqualFold(strings.TrimSpace(scope), "offline_access") {
return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions")
}
}
if strings.TrimSpace(c.OIDCClientID) == "" {
return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled")
}
if !validPublicRedirectURL(c.OIDCRedirectURI) {
return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
}
if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) {
return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
}
if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil {
return err
}
if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds {
return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL")
}
if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds {
return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL")
}
if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure { if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure {
return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests") return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests")
} }
@ -122,6 +160,28 @@ func (c Config) Validate() error {
return nil return nil
} }
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
decoded, err := encoding.DecodeString(raw)
if err == nil && len(decoded) == 32 {
return decoded, nil
}
}
return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key")
}
func validPublicRedirectURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func isLocalEnvironment(value string) bool { func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) { switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test": case "development", "dev", "local", "test":

View File

@ -1,6 +1,8 @@
package config package config
import ( import (
"bytes"
"encoding/base64"
"strings" "strings"
"testing" "testing"
) )
@ -63,11 +65,18 @@ func TestLoadOIDCBrowserSessionUsesSafeEnvironmentDefaults(t *testing.T) {
func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) { func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) {
cfg := Config{ cfg := Config{
AppEnv: "staging", AppEnv: "staging",
OIDCEnabled: true, OIDCEnabled: true,
OIDCBrowserSessionEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false, OIDCSessionCookieSecure: false,
CORSAllowedOrigin: "https://gateway.example.com", CORSAllowedOrigin: "https://gateway.example.com",
OIDCClientID: "gateway-public",
OIDCRedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
OIDCPostLogoutRedirectURI: "https://gateway.example.com/",
OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)),
OIDCSessionIdleTTLSeconds: 1800,
OIDCSessionAbsoluteTTLSeconds: 28800,
OIDCSessionRefreshBeforeSeconds: 60,
} }
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_COOKIE_SECURE") { if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_COOKIE_SECURE") {
t.Fatalf("Validate() error = %v, want insecure non-local cookie rejection", err) t.Fatalf("Validate() error = %v, want insecure non-local cookie rejection", err)
@ -79,3 +88,51 @@ func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) {
t.Fatalf("Validate() error = %v, want wildcard credentialed CORS rejection", err) t.Fatalf("Validate() error = %v, want wildcard credentialed CORS rejection", err)
} }
} }
func TestValidateRequiresCompletePublicClientSessionConfiguration(t *testing.T) {
cfg := Config{
AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCSessionCookieSecure: false, CORSAllowedOrigin: "http://localhost:5178",
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") {
t.Fatalf("Validate() error = %v, want incomplete public client rejection", err)
}
cfg.OIDCClientID = "gateway-public"
cfg.OIDCRedirectURI = "http://localhost:8088/api/v1/auth/oidc/callback"
cfg.OIDCPostLogoutRedirectURI = "http://localhost:5178/"
cfg.OIDCSessionEncryptionKey = base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32))
cfg.OIDCSessionIdleTTLSeconds = 1800
cfg.OIDCSessionAbsoluteTTLSeconds = 28800
cfg.OIDCSessionRefreshBeforeSeconds = 60
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() valid public session config: %v", err)
}
key, err := cfg.OIDCSessionEncryptionKeyBytes()
if err != nil || len(key) != 32 {
t.Fatalf("decoded encryption key len=%d err=%v", len(key), err)
}
}
func TestValidateRejectsPlaintextOrWrongLengthSessionKey(t *testing.T) {
cfg := Config{
AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCClientID: "gateway-public", OIDCRedirectURI: "http://localhost:8088/callback",
OIDCPostLogoutRedirectURI: "http://localhost:5178/", OIDCSessionEncryptionKey: strings.Repeat("x", 32),
OIDCSessionIdleTTLSeconds: 1800, OIDCSessionAbsoluteTTLSeconds: 28800, OIDCSessionRefreshBeforeSeconds: 60,
CORSAllowedOrigin: "http://localhost:5178",
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_ENCRYPTION_KEY") {
t.Fatalf("Validate() error = %v, want encoded AES-256 key rejection", err)
}
}
func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) {
cfg := Config{
AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
OIDCRequiredScopes: []string{"gateway.access", "offline_access"},
}
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "offline_access") {
t.Fatalf("Validate() error = %v, want offline_access rejection", err)
}
}

View File

@ -0,0 +1,82 @@
package oidcsession
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
)
type TokenBundle struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
IDToken string `json:"idToken,omitempty"`
}
type Cipher struct {
aead cipher.AEAD
}
func NewCipher(key []byte) (*Cipher, error) {
if len(key) != 32 {
return nil, errors.New("OIDC session encryption key must be exactly 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Cipher{aead: aead}, nil
}
func (c *Cipher) EncryptBundle(bundle TokenBundle, sessionID, gatewayUserID string) ([]byte, error) {
return c.SealJSON(bundle, tokenAAD(sessionID, gatewayUserID))
}
func (c *Cipher) SealJSON(value any, aad []byte) ([]byte, error) {
plaintext, err := json.Marshal(value)
if err != nil {
return nil, err
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return c.aead.Seal(nonce, nonce, plaintext, aad), nil
}
func (c *Cipher) DecryptBundle(encrypted []byte, sessionID, gatewayUserID string) (TokenBundle, error) {
var bundle TokenBundle
if err := c.OpenJSON(encrypted, tokenAAD(sessionID, gatewayUserID), &bundle); err != nil {
return TokenBundle{}, err
}
if bundle.AccessToken == "" || bundle.RefreshToken == "" {
return TokenBundle{}, errors.New("OIDC session token bundle is invalid")
}
return bundle, nil
}
func (c *Cipher) OpenJSON(encrypted, aad []byte, output any) error {
if len(encrypted) <= c.aead.NonceSize() {
return errors.New("OIDC session ciphertext is invalid")
}
nonce, ciphertext := encrypted[:c.aead.NonceSize()], encrypted[c.aead.NonceSize():]
plaintext, err := c.aead.Open(nil, nonce, ciphertext, aad)
if err != nil {
return errors.New("OIDC session ciphertext authentication failed")
}
if err := json.Unmarshal(plaintext, output); err != nil {
return errors.New("OIDC session ciphertext payload is invalid")
}
return nil
}
func tokenAAD(sessionID, gatewayUserID string) []byte {
return []byte(fmt.Sprintf("easyai-gateway/oidc-session/v1\x00%s\x00%s", sessionID, gatewayUserID))
}

View File

@ -0,0 +1,39 @@
package oidcsession
import (
"bytes"
"strings"
"testing"
)
func TestCipherEncryptsTokenBundleAndAuthenticatesContext(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, 32)
cipher, err := NewCipher(key)
if err != nil {
t.Fatal(err)
}
bundle := TokenBundle{AccessToken: "plain-access", RefreshToken: "plain-refresh", IDToken: "plain-id"}
encrypted, err := cipher.EncryptBundle(bundle, "session-id", "gateway-user-id")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encrypted), "plain-") {
t.Fatal("token bundle was stored in plaintext")
}
decoded, err := cipher.DecryptBundle(encrypted, "session-id", "gateway-user-id")
if err != nil {
t.Fatal(err)
}
if decoded != bundle {
t.Fatalf("decoded bundle = %#v", decoded)
}
if _, err := cipher.DecryptBundle(encrypted, "another-session", "gateway-user-id"); err == nil {
t.Fatal("ciphertext was accepted with different authenticated context")
}
}
func TestCipherRequiresIndependentAES256Key(t *testing.T) {
if _, err := NewCipher(make([]byte, 31)); err == nil {
t.Fatal("31-byte key was accepted")
}
}

View File

@ -0,0 +1,85 @@
package oidcsession
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"net/url"
"strings"
"time"
)
const LoginTransactionCookieName = "easyai_gateway_oidc_login"
var loginTransactionAAD = []byte("easyai-gateway/oidc-login-transaction/v1")
type LoginTransaction struct {
State string `json:"state"`
Nonce string `json:"nonce"`
PKCEVerifier string `json:"pkceVerifier"`
ReturnTo string `json:"returnTo"`
CreatedAt time.Time `json:"createdAt"`
}
func NewLoginTransaction(returnTo string, now time.Time) (LoginTransaction, string, error) {
if !ValidReturnTo(returnTo) {
return LoginTransaction{}, "", errors.New("returnTo must be a same-origin relative path")
}
state, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, "", err
}
nonce, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, "", err
}
verifier, err := randomBase64URL(32)
if err != nil {
return LoginTransaction{}, "", err
}
challengeHash := sha256.Sum256([]byte(verifier))
return LoginTransaction{State: state, Nonce: nonce, PKCEVerifier: verifier, ReturnTo: returnTo, CreatedAt: now.UTC()},
base64.RawURLEncoding.EncodeToString(challengeHash[:]), nil
}
func (c *Cipher) EncodeLoginTransaction(transaction LoginTransaction) (string, error) {
payload, err := c.SealJSON(transaction, loginTransactionAAD)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
func (c *Cipher) DecodeLoginTransaction(encoded string, now time.Time) (LoginTransaction, error) {
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return LoginTransaction{}, errors.New("OIDC login transaction is invalid")
}
var transaction LoginTransaction
if err := c.OpenJSON(payload, loginTransactionAAD, &transaction); err != nil {
return LoginTransaction{}, err
}
if transaction.State == "" || transaction.Nonce == "" || transaction.PKCEVerifier == "" || !ValidReturnTo(transaction.ReturnTo) ||
transaction.CreatedAt.IsZero() || now.Before(transaction.CreatedAt.Add(-time.Minute)) || !now.Before(transaction.CreatedAt.Add(10*time.Minute)) {
return LoginTransaction{}, errors.New("OIDC login transaction has expired or is invalid")
}
return transaction, nil
}
func ValidReturnTo(value string) bool {
value = strings.TrimSpace(value)
if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") {
return false
}
parsed, err := url.Parse(value)
return err == nil && !parsed.IsAbs() && parsed.Host == ""
}
func randomBase64URL(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}

View File

@ -0,0 +1,45 @@
package oidcsession
import (
"bytes"
"strings"
"testing"
"time"
)
func TestLoginTransactionIsEncryptedBoundedAndPKCES256(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
cipher, _ := NewCipher(bytes.Repeat([]byte{9}, 32))
transaction, challenge, err := NewLoginTransaction("/workspace?tab=wallet", now)
if err != nil {
t.Fatal(err)
}
if transaction.State == transaction.Nonce || len(challenge) != 43 || challenge == transaction.PKCEVerifier {
t.Fatal("state, nonce and PKCE values are not independent S256 material")
}
encoded, err := cipher.EncodeLoginTransaction(transaction)
if err != nil {
t.Fatal(err)
}
if strings.Contains(encoded, transaction.State) || strings.Contains(encoded, transaction.PKCEVerifier) {
t.Fatal("login transaction cookie contains plaintext security material")
}
decoded, err := cipher.DecodeLoginTransaction(encoded, now.Add(9*time.Minute))
if err != nil || decoded.ReturnTo != transaction.ReturnTo {
t.Fatalf("decode transaction=%#v err=%v", decoded, err)
}
if _, err := cipher.DecodeLoginTransaction(encoded, now.Add(10*time.Minute)); err == nil {
t.Fatal("10-minute login transaction was accepted")
}
}
func TestValidReturnToRejectsOpenRedirects(t *testing.T) {
for _, value := range []string{"https://evil.example", "//evil.example", "/\\evil", "", "workspace"} {
if ValidReturnTo(value) {
t.Fatalf("unsafe returnTo accepted: %q", value)
}
}
if !ValidReturnTo("/workspace/tasks?from=login#latest") {
t.Fatal("safe relative returnTo was rejected")
}
}

View File

@ -0,0 +1,326 @@
package oidcsession
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
var (
ErrSessionInvalid = errors.New("OIDC session is invalid")
ErrSessionExpired = errors.New("OIDC session has expired")
ErrSessionRefreshUnavailable = errors.New("OIDC session refresh is unavailable")
ErrSessionStoreUnavailable = errors.New("OIDC session store is unavailable")
ErrGatewayUserDisabled = errors.New("gateway user is disabled")
)
type Repository interface {
CreateOIDCSession(context.Context, store.CreateOIDCSessionInput) (store.OIDCSession, error)
FindOIDCSessionByHash(context.Context, []byte) (store.OIDCSession, error)
TouchOIDCSession(context.Context, string, time.Time, time.Time) error
AcquireOIDCSessionRefreshLock(context.Context, string, int64, string, time.Time, time.Time) (bool, error)
CompleteOIDCSessionRefresh(context.Context, string, int64, string, []byte, time.Time) error
DeleteOIDCSessionByHash(context.Context, []byte) error
DeleteOIDCSessionByID(context.Context, string) error
CleanupExpiredOIDCSessions(context.Context, time.Time) (int64, error)
}
type TokenVerifier interface {
Verify(context.Context, string) (*auth.User, error)
}
type PublicClient interface {
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
RevokeRefreshToken(context.Context, string) error
EndSessionURL(context.Context, string) (string, error)
}
type Config struct {
IdleTTL time.Duration
AbsoluteTTL time.Duration
RefreshBefore time.Duration
RefreshLease time.Duration
RefreshWait time.Duration
}
type Service struct {
repository Repository
cipher *Cipher
verifier TokenVerifier
client PublicClient
config Config
now func() time.Time
}
func NewService(repository Repository, cipher *Cipher, verifier TokenVerifier, client PublicClient, config Config) (*Service, error) {
if repository == nil || cipher == nil || verifier == nil || client == nil {
return nil, errors.New("OIDC session dependencies are required")
}
if config.IdleTTL <= 0 || config.AbsoluteTTL <= config.IdleTTL || config.RefreshBefore <= 0 {
return nil, errors.New("OIDC session TTL configuration is invalid")
}
if config.RefreshLease <= 0 {
config.RefreshLease = 5 * time.Second
}
if config.RefreshWait <= 0 {
config.RefreshWait = 2 * time.Second
}
return &Service{repository: repository, cipher: cipher, verifier: verifier, client: client, config: config, now: time.Now}, nil
}
func (s *Service) Create(ctx context.Context, bundle TokenBundle, localUser *auth.User) (string, error) {
if localUser == nil || localUser.GatewayUserID == "" || localUser.GatewayTenantID == "" || bundle.RefreshToken == "" {
return "", ErrSessionInvalid
}
verified, err := s.verifier.Verify(ctx, bundle.AccessToken)
if err != nil || verified == nil || verified.Source != "oidc" || verified.ID == "" || verified.ID != localUser.ID {
return "", ErrSessionInvalid
}
now := s.now()
if !verified.TokenExpiresAt.After(now) {
return "", ErrSessionExpired
}
raw, hash, err := newSessionToken()
if err != nil {
return "", ErrSessionStoreUnavailable
}
aadSessionID := hex.EncodeToString(hash)
ciphertext, err := s.cipher.EncryptBundle(bundle, aadSessionID, localUser.GatewayUserID)
if err != nil {
return "", ErrSessionStoreUnavailable
}
_, err = s.repository.CreateOIDCSession(ctx, store.CreateOIDCSessionInput{
SessionTokenHash: hash, GatewayUserID: localUser.GatewayUserID, GatewayTenantID: localUser.GatewayTenantID,
TokenCiphertext: ciphertext, AccessTokenExpiresAt: verified.TokenExpiresAt,
LastSeenAt: now, IdleExpiresAt: now.Add(s.config.IdleTTL), AbsoluteExpiresAt: now.Add(s.config.AbsoluteTTL),
})
if err != nil {
return "", ErrSessionStoreUnavailable
}
return raw, nil
}
func (s *Service) Resolve(ctx context.Context, raw string) (*auth.User, error) {
hash, err := sessionTokenHash(raw)
if err != nil {
return nil, ErrSessionInvalid
}
record, err := s.repository.FindOIDCSessionByHash(ctx, hash)
if errors.Is(err, store.ErrOIDCSessionNotFound) {
return nil, ErrSessionInvalid
}
if err != nil {
return nil, ErrSessionStoreUnavailable
}
return s.resolveRecord(ctx, hash, record)
}
func (s *Service) resolveRecord(ctx context.Context, hash []byte, record store.OIDCSession) (*auth.User, error) {
now := s.now()
if record.UserDeleted || record.UserStatus != "active" {
return nil, ErrGatewayUserDisabled
}
if !record.IdleExpiresAt.After(now) || !record.AbsoluteExpiresAt.After(now) {
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
return nil, ErrSessionExpired
}
bundle, err := s.cipher.DecryptBundle(record.TokenCiphertext, hex.EncodeToString(hash), record.GatewayUserID)
if err != nil {
return nil, ErrSessionStoreUnavailable
}
if !record.AccessTokenExpiresAt.After(now.Add(s.config.RefreshBefore)) {
return s.refresh(ctx, hash, record, bundle)
}
user, err := s.verifySessionUser(ctx, record, bundle.AccessToken)
if err != nil {
return nil, err
}
if err := s.touch(ctx, record, now); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) refresh(ctx context.Context, hash []byte, record store.OIDCSession, bundle TokenBundle) (*auth.User, error) {
now := s.now()
lockID, err := newUUID()
if err != nil {
return nil, ErrSessionStoreUnavailable
}
acquired, err := s.repository.AcquireOIDCSessionRefreshLock(ctx, record.ID, record.RefreshVersion, lockID, now.Add(s.config.RefreshLease), now)
if err != nil {
return nil, ErrSessionStoreUnavailable
}
if !acquired {
return s.waitForRefresh(ctx, hash, record, bundle)
}
refreshed, err := s.client.Refresh(ctx, bundle.RefreshToken)
if errors.Is(err, auth.ErrOIDCInvalidGrant) {
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
return nil, ErrSessionExpired
}
if err != nil {
// Keep the lease until it expires: an indeterminate refresh response must not
// cause the same rotating refresh token to be replayed immediately.
if record.AccessTokenExpiresAt.After(now) {
user, verifyErr := s.verifySessionUser(ctx, record, bundle.AccessToken)
if verifyErr == nil {
if touchErr := s.touch(ctx, record, now); touchErr != nil {
return nil, touchErr
}
return user, nil
}
}
return nil, ErrSessionRefreshUnavailable
}
user, err := s.verifySessionUser(ctx, record, refreshed.AccessToken)
if err != nil {
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
return nil, ErrSessionExpired
}
if refreshed.RefreshToken == "" {
// OAuth token responses may omit refresh_token when the issuer keeps the
// existing token valid. Persist the old value unless a rotated one exists.
refreshed.RefreshToken = bundle.RefreshToken
}
if refreshed.IDToken == "" {
refreshed.IDToken = bundle.IDToken
}
newBundle := TokenBundle{AccessToken: refreshed.AccessToken, RefreshToken: refreshed.RefreshToken, IDToken: refreshed.IDToken}
ciphertext, err := s.cipher.EncryptBundle(newBundle, hex.EncodeToString(hash), record.GatewayUserID)
if err != nil {
// The remote refresh succeeded, so the previous rotating refresh token may
// already be invalid. Destroy the stale local session instead of replaying it.
_ = s.repository.DeleteOIDCSessionByID(ctx, record.ID)
return nil, ErrSessionStoreUnavailable
}
if err := s.repository.CompleteOIDCSessionRefresh(ctx, record.ID, record.RefreshVersion, lockID, ciphertext, user.TokenExpiresAt); err != nil {
return nil, ErrSessionStoreUnavailable
}
record.AccessTokenExpiresAt = user.TokenExpiresAt
if err := s.touch(ctx, record, now); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) waitForRefresh(ctx context.Context, hash []byte, original store.OIDCSession, oldBundle TokenBundle) (*auth.User, error) {
deadline := time.Now().Add(s.config.RefreshWait)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ErrSessionRefreshUnavailable
case <-time.After(25 * time.Millisecond):
}
record, err := s.repository.FindOIDCSessionByHash(ctx, hash)
if errors.Is(err, store.ErrOIDCSessionNotFound) {
return nil, ErrSessionExpired
}
if err != nil {
return nil, ErrSessionStoreUnavailable
}
if record.RefreshVersion > original.RefreshVersion {
return s.resolveRecord(ctx, hash, record)
}
}
now := s.now()
if original.AccessTokenExpiresAt.After(now) {
user, err := s.verifySessionUser(ctx, original, oldBundle.AccessToken)
if err == nil {
if touchErr := s.touch(ctx, original, now); touchErr != nil {
return nil, touchErr
}
return user, nil
}
}
return nil, ErrSessionRefreshUnavailable
}
func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSession, accessToken string) (*auth.User, error) {
user, err := s.verifier.Verify(ctx, accessToken)
if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID {
return nil, ErrSessionInvalid
}
return user, nil
}
func (s *Service) touch(ctx context.Context, record store.OIDCSession, now time.Time) error {
if err := s.repository.TouchOIDCSession(ctx, record.ID, now, now.Add(s.config.IdleTTL)); err != nil {
if errors.Is(err, store.ErrOIDCSessionNotFound) {
return ErrSessionExpired
}
return ErrSessionStoreUnavailable
}
return nil
}
func (s *Service) Delete(ctx context.Context, raw string) (TokenBundle, error) {
hash, err := sessionTokenHash(raw)
if err != nil {
return TokenBundle{}, nil
}
record, err := s.repository.FindOIDCSessionByHash(ctx, hash)
if errors.Is(err, store.ErrOIDCSessionNotFound) {
return TokenBundle{}, nil
}
if err != nil {
return TokenBundle{}, ErrSessionStoreUnavailable
}
bundle, decryptErr := s.cipher.DecryptBundle(record.TokenCiphertext, hex.EncodeToString(hash), record.GatewayUserID)
if err := s.repository.DeleteOIDCSessionByHash(ctx, hash); err != nil {
return TokenBundle{}, ErrSessionStoreUnavailable
}
if decryptErr != nil {
// The session row is already gone. Treat logout as successful even though
// the unusable refresh token could not be revoked at the issuer.
return TokenBundle{}, nil
}
return bundle, nil
}
func (s *Service) Cleanup(ctx context.Context) (int64, error) {
count, err := s.repository.CleanupExpiredOIDCSessions(ctx, s.now())
if err != nil {
return 0, ErrSessionStoreUnavailable
}
return count, nil
}
func newSessionToken() (string, []byte, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", nil, err
}
encoded := base64.RawURLEncoding.EncodeToString(raw)
hash := sha256.Sum256([]byte(encoded))
return encoded, hash[:], nil
}
func sessionTokenHash(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(decoded) != 32 {
return nil, ErrSessionInvalid
}
hash := sha256.Sum256([]byte(raw))
return hash[:], nil
}
func newUUID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
value[6] = value[6]&0x0f | 0x40
value[8] = value[8]&0x3f | 0x80
return hex.EncodeToString(value[0:4]) + "-" + hex.EncodeToString(value[4:6]) + "-" + hex.EncodeToString(value[6:8]) + "-" + hex.EncodeToString(value[8:10]) + "-" + hex.EncodeToString(value[10:16]), nil
}

View File

@ -0,0 +1,419 @@
package oidcsession
import (
"bytes"
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestServiceStoresOnlyHashedSessionAndEncryptedTokens(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"access-token": {ID: "subject-1", Source: "oidc", Roles: []string{"user"}, TokenExpiresAt: now.Add(5 * time.Minute)},
}}
service := newTestService(t, repository, verifier, &fakePublicClient{})
service.now = func() time.Time { return now }
localUser := &auth.User{ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", GatewayTenantID: "22222222-2222-4222-8222-222222222222"}
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access-token", RefreshToken: "refresh-token", IDToken: "id-token"}, localUser)
if err != nil {
t.Fatal(err)
}
if len(raw) != 43 {
t.Fatalf("opaque session length = %d, want 43", len(raw))
}
record := repository.snapshot()
if bytes.Contains(record.TokenCiphertext, []byte("access-token")) || bytes.Contains(record.TokenCiphertext, []byte("refresh-token")) {
t.Fatal("repository received plaintext token material")
}
hash, _ := sessionTokenHash(raw)
if !bytes.Equal(hash, record.SessionTokenHash) || bytes.Equal([]byte(raw), record.SessionTokenHash) {
t.Fatal("repository did not receive only the SHA-256 session hash")
}
user, err := service.Resolve(context.Background(), raw)
if err != nil || user.ID != "subject-1" {
t.Fatalf("resolve user=%#v err=%v", user, err)
}
}
func TestServiceConcurrentExpiredRequestsRefreshExactlyOnce(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"old-access": {ID: "subject-1", Source: "oidc", Roles: []string{"user"}, TokenExpiresAt: now.Add(5 * time.Minute)},
"new-access": {ID: "subject-1", Source: "oidc", Roles: []string{"user"}, TokenExpiresAt: now.Add(5 * time.Minute)},
}}
client := &fakePublicClient{refreshResponse: auth.OIDCTokenResponse{AccessToken: "new-access", RefreshToken: "rotated-refresh", ExpiresIn: 300}, delay: 40 * time.Millisecond}
service := newTestService(t, repository, verifier, client)
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "old-access", RefreshToken: "old-refresh"}, &auth.User{
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", GatewayTenantID: "22222222-2222-4222-8222-222222222222",
})
if err != nil {
t.Fatal(err)
}
repository.expireAccessToken(now.Add(-time.Second))
var wait sync.WaitGroup
errorsFound := make(chan error, 20)
for range 20 {
wait.Add(1)
go func() {
defer wait.Done()
user, resolveErr := service.Resolve(context.Background(), raw)
if resolveErr != nil {
errorsFound <- resolveErr
return
}
if user.ID != "subject-1" {
errorsFound <- errors.New("wrong resolved subject")
}
}()
}
wait.Wait()
close(errorsFound)
for err := range errorsFound {
t.Errorf("concurrent resolve: %v", err)
}
if got := client.refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls = %d, want exactly 1", got)
}
if repository.snapshot().RefreshVersion != 2 {
t.Fatalf("refresh version = %d, want 2", repository.snapshot().RefreshVersion)
}
}
func TestServiceDoesNotRefreshExpiredIdleSession(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)}}}
client := &fakePublicClient{refreshResponse: auth.OIDCTokenResponse{AccessToken: "new", RefreshToken: "rotated"}}
service := newTestService(t, repository, verifier, client)
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, &auth.User{
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", GatewayTenantID: "22222222-2222-4222-8222-222222222222",
})
if err != nil {
t.Fatal(err)
}
repository.expireIdle(now.Add(-time.Second))
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("Resolve() error = %v, want ErrSessionExpired", err)
}
if client.refreshCalls.Load() != 0 {
t.Fatal("expired idle session attempted a refresh")
}
}
func TestServiceKeepsExistingRefreshTokenWhenIssuerDoesNotRotate(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"old-access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
"new-access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
}}
client := &fakePublicClient{refreshResponse: auth.OIDCTokenResponse{AccessToken: "new-access"}}
service := newTestService(t, repository, verifier, client)
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "old-access", RefreshToken: "existing-refresh"}, testLocalUser())
if err != nil {
t.Fatal(err)
}
repository.expireAccessToken(now.Add(-time.Second))
if _, err := service.Resolve(context.Background(), raw); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
bundle, err := service.Delete(context.Background(), raw)
if err != nil {
t.Fatal(err)
}
if bundle.RefreshToken != "existing-refresh" || bundle.AccessToken != "new-access" {
t.Fatal("refreshed bundle did not retain the existing refresh token")
}
}
func TestServiceInvalidGrantDeletesSession(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
}}
client := &fakePublicClient{refreshError: auth.ErrOIDCInvalidGrant}
service := newTestService(t, repository, verifier, client)
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "revoked-refresh"}, testLocalUser())
if err != nil {
t.Fatal(err)
}
repository.expireAccessToken(now.Add(-time.Second))
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("Resolve() error = %v, want ErrSessionExpired", err)
}
if !repository.isDeleted() || client.refreshCalls.Load() != 1 {
t.Fatal("invalid_grant did not delete the session after exactly one refresh")
}
}
func TestServiceUsesStillValidAccessTokenWhenRefreshIsTemporarilyUnavailable(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(30 * time.Second)},
}}
client := &fakePublicClient{refreshError: context.DeadlineExceeded}
service := newTestService(t, repository, verifier, client)
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
if err != nil {
t.Fatal(err)
}
user, err := service.Resolve(context.Background(), raw)
if err != nil || user.ID != "subject-1" {
t.Fatalf("Resolve() user=%#v error=%v", user, err)
}
if client.refreshCalls.Load() != 1 || repository.isDeleted() {
t.Fatal("temporary refresh failure did not fall back to the valid access token")
}
}
func TestServiceReturnsUnavailableWhenExpiredTokenCannotRefresh(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
}}
service := newTestService(t, repository, verifier, &fakePublicClient{refreshError: context.DeadlineExceeded})
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
if err != nil {
t.Fatal(err)
}
repository.expireAccessToken(now.Add(-time.Second))
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionRefreshUnavailable) {
t.Fatalf("Resolve() error = %v, want ErrSessionRefreshUnavailable", err)
}
}
func TestServiceRejectsWrongEncryptionKeyAndDisabledUser(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
}}
service := newTestService(t, repository, verifier, &fakePublicClient{})
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
if err != nil {
t.Fatal(err)
}
wrongCipher, err := NewCipher(bytes.Repeat([]byte{8}, 32))
if err != nil {
t.Fatal(err)
}
wrongKeyService, err := NewService(repository, wrongCipher, verifier, &fakePublicClient{}, Config{
IdleTTL: 30 * time.Minute, AbsoluteTTL: 8 * time.Hour, RefreshBefore: time.Minute,
})
if err != nil {
t.Fatal(err)
}
wrongKeyService.now = func() time.Time { return now }
if _, err := wrongKeyService.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionStoreUnavailable) {
t.Fatalf("wrong-key Resolve() error = %v, want ErrSessionStoreUnavailable", err)
}
repository.disableUser()
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrGatewayUserDisabled) {
t.Fatalf("disabled-user Resolve() error = %v, want ErrGatewayUserDisabled", err)
}
}
func TestServiceDeletesSessionEvenWhenCiphertextCannotBeDecryptedDuringLogout(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
repository := newFakeRepository("subject-1")
verifier := fakeVerifier{users: map[string]*auth.User{
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
}}
service := newTestService(t, repository, verifier, &fakePublicClient{})
service.now = func() time.Time { return now }
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
if err != nil {
t.Fatal(err)
}
repository.corruptCiphertext()
if _, err := service.Delete(context.Background(), raw); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if !repository.isDeleted() {
t.Fatal("logout left a session with unusable ciphertext in the store")
}
}
func testLocalUser() *auth.User {
return &auth.User{
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111",
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
}
}
func newTestService(t *testing.T, repository Repository, verifier TokenVerifier, client PublicClient) *Service {
t.Helper()
cipher, err := NewCipher(bytes.Repeat([]byte{7}, 32))
if err != nil {
t.Fatal(err)
}
service, err := NewService(repository, cipher, verifier, client, Config{
IdleTTL: 30 * time.Minute, AbsoluteTTL: 8 * time.Hour, RefreshBefore: time.Minute,
RefreshLease: 5 * time.Second, RefreshWait: 2 * time.Second,
})
if err != nil {
t.Fatal(err)
}
return service
}
type fakeVerifier struct{ users map[string]*auth.User }
func (f fakeVerifier) Verify(_ context.Context, token string) (*auth.User, error) {
user := f.users[token]
if user == nil {
return nil, auth.ErrUnauthorized
}
copy := *user
return &copy, nil
}
type fakePublicClient struct {
refreshResponse auth.OIDCTokenResponse
refreshError error
delay time.Duration
refreshCalls atomic.Int64
}
func (f *fakePublicClient) Refresh(_ context.Context, _ string) (auth.OIDCTokenResponse, error) {
f.refreshCalls.Add(1)
if f.delay > 0 {
time.Sleep(f.delay)
}
return f.refreshResponse, f.refreshError
}
func (f *fakePublicClient) RevokeRefreshToken(context.Context, string) error { return nil }
func (f *fakePublicClient) EndSessionURL(context.Context, string) (string, error) {
return "https://gateway.example.com/", nil
}
type fakeRepository struct {
mu sync.Mutex
record store.OIDCSession
deleted bool
external string
}
func newFakeRepository(external string) *fakeRepository { return &fakeRepository{external: external} }
func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.CreateOIDCSessionInput) (store.OIDCSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.record = store.OIDCSession{
ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...),
GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: f.external,
UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt,
LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1,
}
return f.record, nil
}
func (f *fakeRepository) FindOIDCSessionByHash(_ context.Context, hash []byte) (store.OIDCSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleted || !bytes.Equal(hash, f.record.SessionTokenHash) {
return store.OIDCSession{}, store.ErrOIDCSessionNotFound
}
item := f.record
item.SessionTokenHash = append([]byte(nil), f.record.SessionTokenHash...)
item.TokenCiphertext = append([]byte(nil), f.record.TokenCiphertext...)
return item, nil
}
func (f *fakeRepository) TouchOIDCSession(_ context.Context, _ string, lastSeen, idle time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleted || !f.record.IdleExpiresAt.After(lastSeen) || !f.record.AbsoluteExpiresAt.After(lastSeen) {
return store.ErrOIDCSessionNotFound
}
f.record.LastSeenAt, f.record.IdleExpiresAt = lastSeen, idle
return nil
}
func (f *fakeRepository) AcquireOIDCSessionRefreshLock(_ context.Context, _ string, version int64, lockID string, until, now time.Time) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleted || f.record.RefreshVersion != version || f.record.RefreshLockID != "" && f.record.RefreshLockUntil.After(now) {
return false, nil
}
f.record.RefreshLockID, f.record.RefreshLockUntil = lockID, until
return true, nil
}
func (f *fakeRepository) CompleteOIDCSessionRefresh(_ context.Context, _ string, version int64, lockID string, ciphertext []byte, expires time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleted || f.record.RefreshVersion != version || f.record.RefreshLockID != lockID {
return store.ErrOIDCSessionNotFound
}
f.record.TokenCiphertext = append([]byte(nil), ciphertext...)
f.record.AccessTokenExpiresAt = expires
f.record.RefreshVersion++
f.record.RefreshLockID = ""
f.record.RefreshLockUntil = time.Time{}
return nil
}
func (f *fakeRepository) DeleteOIDCSessionByHash(context.Context, []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = true
return nil
}
func (f *fakeRepository) DeleteOIDCSessionByID(context.Context, string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = true
return nil
}
func (f *fakeRepository) CleanupExpiredOIDCSessions(context.Context, time.Time) (int64, error) {
return 0, nil
}
func (f *fakeRepository) snapshot() store.OIDCSession {
f.mu.Lock()
defer f.mu.Unlock()
item := f.record
item.TokenCiphertext = append([]byte(nil), item.TokenCiphertext...)
return item
}
func (f *fakeRepository) expireAccessToken(expiry time.Time) {
f.mu.Lock()
defer f.mu.Unlock()
f.record.AccessTokenExpiresAt = expiry
}
func (f *fakeRepository) expireIdle(expiry time.Time) {
f.mu.Lock()
defer f.mu.Unlock()
f.record.IdleExpiresAt = expiry
}
func (f *fakeRepository) disableUser() {
f.mu.Lock()
defer f.mu.Unlock()
f.record.UserStatus = "disabled"
}
func (f *fakeRepository) corruptCiphertext() {
f.mu.Lock()
defer f.mu.Unlock()
f.record.TokenCiphertext = []byte("corrupt")
}
func (f *fakeRepository) isDeleted() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.deleted
}

View File

@ -0,0 +1,155 @@
package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrOIDCSessionNotFound = errors.New("OIDC session not found")
type OIDCSession struct {
ID string
SessionTokenHash []byte
GatewayUserID string
GatewayTenantID string
ExternalUserID string
UserStatus string
UserDeleted bool
TokenCiphertext []byte
AccessTokenExpiresAt time.Time
LastSeenAt time.Time
IdleExpiresAt time.Time
AbsoluteExpiresAt time.Time
RefreshVersion int64
RefreshLockID string
RefreshLockUntil time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateOIDCSessionInput struct {
SessionTokenHash []byte
GatewayUserID string
GatewayTenantID string
TokenCiphertext []byte
AccessTokenExpiresAt time.Time
LastSeenAt time.Time
IdleExpiresAt time.Time
AbsoluteExpiresAt time.Time
}
func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionInput) (OIDCSession, error) {
var id string
err := s.pool.QueryRow(ctx, `
INSERT INTO gateway_oidc_sessions (
session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext,
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at
)
VALUES ($1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8)
RETURNING id::text`,
input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext,
input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt,
).Scan(&id)
if err != nil {
return OIDCSession{}, err
}
return s.FindOIDCSessionByHash(ctx, input.SessionTokenHash)
}
func (s *Store) FindOIDCSessionByHash(ctx context.Context, hash []byte) (OIDCSession, error) {
item, err := scanOIDCSession(s.pool.QueryRow(ctx, `
SELECT `+oidcSessionColumns+`
FROM gateway_oidc_sessions s
JOIN gateway_users u ON u.id = s.gateway_user_id
WHERE s.session_token_hash = $1`, hash))
if errors.Is(err, pgx.ErrNoRows) {
return OIDCSession{}, ErrOIDCSessionNotFound
}
return item, err
}
func (s *Store) TouchOIDCSession(ctx context.Context, id string, lastSeenAt, idleExpiresAt time.Time) error {
tag, err := s.pool.Exec(ctx, `
UPDATE gateway_oidc_sessions
SET last_seen_at = $2,
idle_expires_at = LEAST($3, absolute_expires_at),
updated_at = now()
WHERE id = $1::uuid
AND idle_expires_at > $2
AND absolute_expires_at > $2`, id, lastSeenAt, idleExpiresAt)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrOIDCSessionNotFound
}
return nil
}
func (s *Store) AcquireOIDCSessionRefreshLock(ctx context.Context, id string, version int64, lockID string, lockUntil, now time.Time) (bool, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE gateway_oidc_sessions
SET refresh_lock_id = $3::uuid, refresh_lock_until = $4, updated_at = now()
WHERE id = $1::uuid
AND refresh_version = $2
AND (refresh_lock_until IS NULL OR refresh_lock_until <= $5)`, id, version, lockID, lockUntil, now)
return tag.RowsAffected() == 1, err
}
func (s *Store) CompleteOIDCSessionRefresh(ctx context.Context, id string, version int64, lockID string, ciphertext []byte, accessTokenExpiresAt time.Time) error {
tag, err := s.pool.Exec(ctx, `
UPDATE gateway_oidc_sessions
SET token_ciphertext = $4,
access_token_expires_at = $5,
refresh_version = refresh_version + 1,
refresh_lock_id = NULL,
refresh_lock_until = NULL,
updated_at = now()
WHERE id = $1::uuid AND refresh_version = $2 AND refresh_lock_id = $3::uuid`,
id, version, lockID, ciphertext, accessTokenExpiresAt)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrOIDCSessionNotFound
}
return nil
}
func (s *Store) DeleteOIDCSessionByHash(ctx context.Context, hash []byte) error {
_, err := s.pool.Exec(ctx, `DELETE FROM gateway_oidc_sessions WHERE session_token_hash = $1`, hash)
return err
}
func (s *Store) DeleteOIDCSessionByID(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM gateway_oidc_sessions WHERE id = $1::uuid`, id)
return err
}
func (s *Store) CleanupExpiredOIDCSessions(ctx context.Context, now time.Time) (int64, error) {
tag, err := s.pool.Exec(ctx, `
DELETE FROM gateway_oidc_sessions
WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now)
return tag.RowsAffected(), err
}
const oidcSessionColumns = `
s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text,
COALESCE(u.external_user_id, ''), u.status, u.deleted_at IS NOT NULL,
s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at,
s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''),
COALESCE(s.refresh_lock_until, 'epoch'::timestamptz), s.created_at, s.updated_at`
func scanOIDCSession(row pgx.Row) (OIDCSession, error) {
var item OIDCSession
err := row.Scan(
&item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID,
&item.ExternalUserID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
&item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt,
&item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt,
)
return item, err
}

View File

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS gateway_oidc_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_token_hash bytea NOT NULL UNIQUE,
gateway_user_id uuid NOT NULL REFERENCES gateway_users(id) ON DELETE CASCADE,
gateway_tenant_id uuid NOT NULL REFERENCES gateway_tenants(id) ON DELETE CASCADE,
token_ciphertext bytea NOT NULL,
access_token_expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL,
idle_expires_at timestamptz NOT NULL,
absolute_expires_at timestamptz NOT NULL,
refresh_version bigint NOT NULL DEFAULT 1,
refresh_lock_id uuid,
refresh_lock_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT gateway_oidc_sessions_hash_length CHECK (octet_length(session_token_hash) = 32),
CONSTRAINT gateway_oidc_sessions_expiry_order CHECK (idle_expires_at <= absolute_expires_at)
);
CREATE INDEX IF NOT EXISTS idx_gateway_oidc_sessions_expiry
ON gateway_oidc_sessions(absolute_expires_at, idle_expires_at);
CREATE INDEX IF NOT EXISTS idx_gateway_oidc_sessions_user
ON gateway_oidc_sessions(gateway_user_id, created_at DESC);