diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index a97c6f3..b93a0b4 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -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) } diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 1708d52..5ece0e0 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -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) diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go new file mode 100644 index 0000000..ff08794 --- /dev/null +++ b/apps/api/internal/auth/oidc_client.go @@ -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 +} diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go new file mode 100644 index 0000000..8e0aae9 --- /dev/null +++ b/apps/api/internal/auth/oidc_client_test.go @@ -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) + } +} diff --git a/apps/api/internal/auth/oidc_session_cookie_test.go b/apps/api/internal/auth/oidc_session_cookie_test.go index e7bfcb1..3167f95 100644 --- a/apps/api/internal/auth/oidc_session_cookie_test.go +++ b/apps/api/internal/auth/oidc_session_cookie_test.go @@ -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) + } +} diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index ad625bf..94c2370 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -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 diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 058e81c..6cd3a36 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -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": diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 523824c..016192c 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -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) + } +} diff --git a/apps/api/internal/oidcsession/crypto.go b/apps/api/internal/oidcsession/crypto.go new file mode 100644 index 0000000..b674636 --- /dev/null +++ b/apps/api/internal/oidcsession/crypto.go @@ -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)) +} diff --git a/apps/api/internal/oidcsession/crypto_test.go b/apps/api/internal/oidcsession/crypto_test.go new file mode 100644 index 0000000..dd35c08 --- /dev/null +++ b/apps/api/internal/oidcsession/crypto_test.go @@ -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") + } +} diff --git a/apps/api/internal/oidcsession/login_transaction.go b/apps/api/internal/oidcsession/login_transaction.go new file mode 100644 index 0000000..f670263 --- /dev/null +++ b/apps/api/internal/oidcsession/login_transaction.go @@ -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 +} diff --git a/apps/api/internal/oidcsession/login_transaction_test.go b/apps/api/internal/oidcsession/login_transaction_test.go new file mode 100644 index 0000000..c2bf43b --- /dev/null +++ b/apps/api/internal/oidcsession/login_transaction_test.go @@ -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") + } +} diff --git a/apps/api/internal/oidcsession/service.go b/apps/api/internal/oidcsession/service.go new file mode 100644 index 0000000..ebb0d84 --- /dev/null +++ b/apps/api/internal/oidcsession/service.go @@ -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 +} diff --git a/apps/api/internal/oidcsession/service_test.go b/apps/api/internal/oidcsession/service_test.go new file mode 100644 index 0000000..dcd0e9a --- /dev/null +++ b/apps/api/internal/oidcsession/service_test.go @@ -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 +} diff --git a/apps/api/internal/store/oidc_sessions.go b/apps/api/internal/store/oidc_sessions.go new file mode 100644 index 0000000..73ac579 --- /dev/null +++ b/apps/api/internal/store/oidc_sessions.go @@ -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 +} diff --git a/apps/api/migrations/0061_oidc_server_sessions.sql b/apps/api/migrations/0061_oidc_server_sessions.sql new file mode 100644 index 0000000..84b36e3 --- /dev/null +++ b/apps/api/migrations/0061_oidc_server_sessions.sql @@ -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);