feat: 实现 OIDC 服务端会话与请求刷新
使用 AES-256-GCM 保存认证中心令牌,并以随机 Cookie 哈希关联 PostgreSQL 会话。加入闲置与绝对期限、Refresh Token 轮换以及多实例刷新锁。
This commit is contained in:
parent
a81a7b5200
commit
d345c070ae
@ -58,6 +58,18 @@ const userContextKey contextKey = "easyai-auth-user"
|
||||
|
||||
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 {
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
@ -67,6 +79,7 @@ type Authenticator struct {
|
||||
HTTPClient *http.Client
|
||||
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
|
||||
OIDCVerifier *OIDCVerifier
|
||||
OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error)
|
||||
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) {
|
||||
user, err := a.Authenticate(r)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), ErrUnauthorized.Error()+":") {
|
||||
slog.WarnContext(r.Context(), "OIDC authentication rejected", "reason", err.Error())
|
||||
}
|
||||
if permission == PermissionPublic {
|
||||
next.ServeHTTP(w, r)
|
||||
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)
|
||||
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) {
|
||||
token := extractBearer(r.Header.Get("Authorization"))
|
||||
fromOIDCSessionCookie := false
|
||||
if token == "" {
|
||||
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 cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
|
||||
token = strings.TrimSpace(cookie.Value)
|
||||
fromOIDCSessionCookie = token != ""
|
||||
sessionID := strings.TrimSpace(cookie.Value)
|
||||
if sessionID == "" || a.OIDCSessionResolver == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return a.OIDCSessionResolver(r.Context(), sessionID)
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if fromOIDCSessionCookie && jwtAlgorithm(token) != "RS256" && jwtAlgorithm(token) != "ES256" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if strings.HasPrefix(token, "sk-") {
|
||||
return a.verifyAPIKey(r.Context(), token)
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
)
|
||||
|
||||
const maxOIDCResponseBytes = 1 << 20
|
||||
const defaultOIDCHTTPTimeout = 10 * time.Second
|
||||
|
||||
type OIDCConfig struct {
|
||||
Issuer string
|
||||
@ -82,7 +83,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
|
||||
}
|
||||
client := config.HTTPClient
|
||||
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
|
||||
}}
|
||||
}
|
||||
@ -151,6 +152,45 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
}, 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 {
|
||||
if cause != nil {
|
||||
return fmt.Errorf("%w: %s: %v", ErrUnauthorized, reason, cause)
|
||||
|
||||
249
apps/api/internal/auth/oidc_client.go
Normal file
249
apps/api/internal/auth/oidc_client.go
Normal 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
|
||||
}
|
||||
125
apps/api/internal/auth/oidc_client_test.go
Normal file
125
apps/api/internal/auth/oidc_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,50 +1,23 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestAuthenticateAcceptsValidatedOIDCSessionCookie(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)
|
||||
}
|
||||
func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
authenticator.OIDCVerifier = verifier
|
||||
raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, nil)
|
||||
var resolved string
|
||||
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.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: raw})
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
|
||||
user, err := authenticator.Authenticate(request)
|
||||
if err != nil {
|
||||
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" {
|
||||
t.Fatalf("unexpected session user: %#v", user)
|
||||
}
|
||||
if user.TokenExpiresAt.Before(time.Now().Add(50 * time.Minute)) {
|
||||
t.Fatalf("token expiry was not retained: %v", user.TokenExpiresAt)
|
||||
if resolved != "opaque-session-id" {
|
||||
t.Fatalf("session resolver received %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,3 +57,19 @@ func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
active := true
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
@ -39,6 +40,13 @@ type Config struct {
|
||||
OIDCGatewayTenantKey string
|
||||
OIDCBrowserSessionEnabled bool
|
||||
OIDCSessionCookieSecure bool
|
||||
OIDCClientID string
|
||||
OIDCRedirectURI string
|
||||
OIDCPostLogoutRedirectURI string
|
||||
OIDCSessionEncryptionKey string
|
||||
OIDCSessionIdleTTLSeconds int
|
||||
OIDCSessionAbsoluteTTLSeconds int
|
||||
OIDCSessionRefreshBeforeSeconds int
|
||||
PublicBaseURL string
|
||||
WebBaseURL string
|
||||
LocalGeneratedStorageDir string
|
||||
@ -87,12 +95,19 @@ func Load() Config {
|
||||
OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE",
|
||||
strconv.FormatBool(!isLocalEnvironment(appEnv)),
|
||||
) == "true",
|
||||
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",
|
||||
OIDCClientID: env("OIDC_CLIENT_ID", ""),
|
||||
OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""),
|
||||
OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""),
|
||||
OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""),
|
||||
OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800),
|
||||
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",
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -63,11 +65,18 @@ func TestLoadOIDCBrowserSessionUsesSafeEnvironmentDefaults(t *testing.T) {
|
||||
|
||||
func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) {
|
||||
cfg := Config{
|
||||
AppEnv: "staging",
|
||||
OIDCEnabled: true,
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
OIDCSessionCookieSecure: false,
|
||||
CORSAllowedOrigin: "https://gateway.example.com",
|
||||
AppEnv: "staging",
|
||||
OIDCEnabled: true,
|
||||
OIDCBrowserSessionEnabled: true,
|
||||
OIDCSessionCookieSecure: false,
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
82
apps/api/internal/oidcsession/crypto.go
Normal file
82
apps/api/internal/oidcsession/crypto.go
Normal 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))
|
||||
}
|
||||
39
apps/api/internal/oidcsession/crypto_test.go
Normal file
39
apps/api/internal/oidcsession/crypto_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
85
apps/api/internal/oidcsession/login_transaction.go
Normal file
85
apps/api/internal/oidcsession/login_transaction.go
Normal 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
|
||||
}
|
||||
45
apps/api/internal/oidcsession/login_transaction_test.go
Normal file
45
apps/api/internal/oidcsession/login_transaction_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
326
apps/api/internal/oidcsession/service.go
Normal file
326
apps/api/internal/oidcsession/service.go
Normal 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
|
||||
}
|
||||
419
apps/api/internal/oidcsession/service_test.go
Normal file
419
apps/api/internal/oidcsession/service_test.go
Normal 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 ©, 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
|
||||
}
|
||||
155
apps/api/internal/store/oidc_sessions.go
Normal file
155
apps/api/internal/store/oidc_sessions.go
Normal 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
|
||||
}
|
||||
24
apps/api/migrations/0061_oidc_server_sessions.sql
Normal file
24
apps/api/migrations/0061_oidc_server_sessions.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user