修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
480 lines
19 KiB
Go
480 lines
19 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const oidcJITTenantID = "6e6d0a0f-8b08-41ca-bda6-f1a58f065bc3"
|
|
|
|
func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
|
|
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT HTTP integration tests")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
applyMigration(t, ctx, databaseURL)
|
|
db, err := store.Connect(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate test signing key: %v", err)
|
|
}
|
|
var issuer string
|
|
var gatewayBaseURL string
|
|
var expectedPKCEChallenge string
|
|
validSubject := ""
|
|
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",
|
|
"authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token",
|
|
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
|
|
})
|
|
case "/jwks":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}})
|
|
case "/authorize":
|
|
if r.URL.Query().Get("response_type") != "code" || r.URL.Query().Get("client_id") != "gateway-public-test" ||
|
|
r.URL.Query().Get("code_challenge_method") != "S256" || r.URL.Query().Get("nonce") == "" {
|
|
http.Error(w, "invalid authorization request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
expectedPKCEChallenge = r.URL.Query().Get("code_challenge")
|
|
callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=test-code&state=" + url.QueryEscape(r.URL.Query().Get("state")) +
|
|
"&nonce=" + url.QueryEscape(r.URL.Query().Get("nonce"))
|
|
http.Redirect(w, r, callback, http.StatusSeeOther)
|
|
case "/token":
|
|
if err := r.ParseForm(); err != nil || r.Form.Get("grant_type") != "authorization_code" ||
|
|
r.Form.Get("client_id") != "gateway-public-test" || r.Form.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
|
|
http.Error(w, "invalid token request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
digest := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
|
|
if base64.RawURLEncoding.EncodeToString(digest[:]) != expectedPKCEChallenge {
|
|
http.Error(w, "invalid PKCE verifier", http.StatusBadRequest)
|
|
return
|
|
}
|
|
accessToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
|
|
idToken := signedOIDCJITToken(t, key, issuer, validSubject, func(claims jwt.MapClaims) {
|
|
claims["aud"] = "gateway-public-test"
|
|
// The nonce is retained from the authorization request by this test issuer.
|
|
claims["nonce"] = currentOIDCTestNonce
|
|
})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": accessToken, "refresh_token": "opaque-test-refresh-token", "id_token": idToken,
|
|
"token_type": "Bearer", "expires_in": 300,
|
|
})
|
|
case "/revoke":
|
|
w.WriteHeader(http.StatusOK)
|
|
case "/logout":
|
|
http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer issuerServer.Close()
|
|
issuer = issuerServer.URL
|
|
|
|
suffix := time.Now().UTC().Format("20060102150405.000000000")
|
|
validSubject = "platform-http-jit-" + suffix
|
|
rejectedSubjects := []string{
|
|
"platform-http-scope-" + suffix,
|
|
"platform-http-role-" + suffix,
|
|
"platform-http-tenant-" + suffix,
|
|
"platform-http-disabled-jit-" + suffix,
|
|
"platform-http-missing-tenant-" + suffix,
|
|
}
|
|
allSubjects := append([]string{validSubject}, rejectedSubjects...)
|
|
t.Cleanup(func() {
|
|
_, _ = db.Pool().Exec(context.Background(), `
|
|
DELETE FROM gateway_audit_logs
|
|
WHERE target_id IN (SELECT id::text FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[]));
|
|
DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[]);`, allSubjects)
|
|
})
|
|
|
|
baseConfig := config.Config{
|
|
AppEnv: "test",
|
|
HTTPAddr: ":0",
|
|
DatabaseURL: databaseURL,
|
|
IdentityMode: "hybrid",
|
|
JWTSecret: "test-only-jwt-secret",
|
|
IdentitySecretStore: "file",
|
|
IdentitySecretDir: t.TempDir(),
|
|
LocalGeneratedStorageDir: t.TempDir(),
|
|
LocalUploadedStorageDir: t.TempDir(),
|
|
LocalTempAssetTTLHours: 1,
|
|
CORSAllowedOrigin: "http://localhost:5178",
|
|
TaskProgressCallbackEnabled: false,
|
|
}
|
|
previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx)
|
|
if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) {
|
|
t.Fatalf("read previous active identity revision: %v", previousErr)
|
|
}
|
|
testRevisionIDs := make([]string, 0, 3)
|
|
t.Cleanup(func() {
|
|
restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, testRevisionIDs)
|
|
})
|
|
activeRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", true)
|
|
testRevisionIDs = append(testRevisionIDs, activeRevision.ID)
|
|
activeRevision, _, err = db.ActivateIdentityRevision(ctx, activeRevision.ID, activeRevision.Version, "oidc-jit-test", "oidc-jit-test")
|
|
if err != nil {
|
|
t.Fatalf("activate OIDC JIT identity revision: %v", err)
|
|
}
|
|
server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
|
defer server.Close()
|
|
gatewayBaseURL = server.URL
|
|
|
|
validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
|
|
var me auth.User
|
|
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/me", validToken, nil, http.StatusOK, &me)
|
|
if me.ID != validSubject || me.Source != "oidc" || me.GatewayUserID == "" || me.GatewayTenantID == "" || me.TenantKey != "default" || me.UserGroupID == "" {
|
|
t.Fatalf("OIDC /me did not include the local Gateway projection")
|
|
}
|
|
sessionCookie := createOIDCBFFSessionCookie(t, server.URL)
|
|
request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.AddCookie(sessionCookie)
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
t.Fatalf("execute cookie-authenticated /me: %v", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
t.Fatalf("cookie-authenticated /me status = %d, want 200", response.StatusCode)
|
|
}
|
|
var cookieMe auth.User
|
|
if err := json.NewDecoder(response.Body).Decode(&cookieMe); err != nil {
|
|
t.Fatalf("decode cookie-authenticated /me: %v", err)
|
|
}
|
|
if cookieMe.GatewayUserID != me.GatewayUserID || cookieMe.ID != me.ID {
|
|
t.Fatalf("new-tab cookie resolved a different Gateway user: %#v", cookieMe)
|
|
}
|
|
var automaticallyCreatedAPIKeys int
|
|
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_api_keys WHERE gateway_user_id = $1::uuid`, me.GatewayUserID).Scan(&automaticallyCreatedAPIKeys); err != nil {
|
|
t.Fatalf("count pre-created API keys: %v", err)
|
|
}
|
|
if automaticallyCreatedAPIKeys != 0 {
|
|
t.Fatalf("OIDC JIT created %d API keys before explicit user action", automaticallyCreatedAPIKeys)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/api/workspace/user-groups",
|
|
"/api/workspace/wallet",
|
|
"/api/workspace/tasks",
|
|
"/api/v1/api-keys",
|
|
} {
|
|
doOIDCJITJSON(t, server.URL, http.MethodGet, path, validToken, nil, http.StatusOK, nil)
|
|
}
|
|
|
|
var createdKey struct {
|
|
Secret string `json:"secret"`
|
|
APIKey struct {
|
|
ID string `json:"id"`
|
|
} `json:"apiKey"`
|
|
}
|
|
doOIDCJITJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", validToken, map[string]any{"name": "OIDC JIT integration key"}, http.StatusCreated, &createdKey)
|
|
if createdKey.Secret == "" || createdKey.APIKey.ID == "" {
|
|
t.Fatal("OIDC user API Key creation returned incomplete data")
|
|
}
|
|
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys", validToken, nil, http.StatusOK, nil)
|
|
doOIDCJITJSON(t, server.URL, http.MethodDelete, "/api/v1/api-keys/"+createdKey.APIKey.ID, validToken, nil, http.StatusNoContent, nil)
|
|
|
|
var users struct {
|
|
Items []store.GatewayUser `json:"items"`
|
|
}
|
|
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/admin/users", validToken, nil, http.StatusOK, &users)
|
|
foundOIDCUser := false
|
|
for _, user := range users.Items {
|
|
if user.ID == me.GatewayUserID {
|
|
foundOIDCUser = user.Source == "oidc" && user.ExternalUserID == validSubject
|
|
break
|
|
}
|
|
}
|
|
if !foundOIDCUser {
|
|
t.Fatal("admin user list did not expose the OIDC Gateway projection")
|
|
}
|
|
|
|
negativeTokens := []struct {
|
|
subject string
|
|
mutate func(jwt.MapClaims)
|
|
}{
|
|
{rejectedSubjects[0], func(claims jwt.MapClaims) { claims["scope"] = "openid" }},
|
|
{rejectedSubjects[1], func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }},
|
|
{rejectedSubjects[2], func(claims jwt.MapClaims) { claims["tid"] = "wrong-tenant" }},
|
|
}
|
|
for _, negative := range negativeTokens {
|
|
token := signedOIDCJITToken(t, key, issuer, negative.subject, negative.mutate)
|
|
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/me", token, nil, http.StatusUnauthorized, nil)
|
|
}
|
|
var rejectedWrites int
|
|
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[])`, rejectedSubjects[:3]).Scan(&rejectedWrites); err != nil {
|
|
t.Fatalf("count rejected OIDC writes: %v", err)
|
|
}
|
|
if rejectedWrites != 0 {
|
|
t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites)
|
|
}
|
|
|
|
disabledJITRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", false)
|
|
testRevisionIDs = append(testRevisionIDs, disabledJITRevision.ID)
|
|
disabledJITRevision, _, err = db.ActivateIdentityRevision(ctx, disabledJITRevision.ID, disabledJITRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled")
|
|
if err != nil {
|
|
t.Fatalf("activate disabled-JIT revision: %v", err)
|
|
}
|
|
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
|
defer disabledJITServer.Close()
|
|
assertOIDCJITError(t, disabledJITServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[3], nil), http.StatusForbidden, errorCodeGatewayUserNotProvisioned)
|
|
|
|
missingTenantRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "missing-tenant-"+suffix, true)
|
|
testRevisionIDs = append(testRevisionIDs, missingTenantRevision.ID)
|
|
if _, _, activateErr := db.ActivateIdentityRevision(ctx, missingTenantRevision.ID, missingTenantRevision.Version, "oidc-jit-missing-tenant", "oidc-jit-missing-tenant"); !errors.Is(activateErr, identity.ErrLocalTenantInvalid) {
|
|
t.Fatalf("missing local tenant activation error = %v, want %v", activateErr, identity.ErrLocalTenantInvalid)
|
|
}
|
|
|
|
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
|
|
t.Fatalf("disable projected user: %v", err)
|
|
}
|
|
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
|
|
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'active' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
|
|
t.Fatalf("restore projected user for delete test: %v", err)
|
|
}
|
|
if err := db.DeleteGatewayUser(ctx, me.GatewayUserID); err != nil {
|
|
t.Fatalf("delete projected user: %v", err)
|
|
}
|
|
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
|
|
}
|
|
|
|
func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, cfg config.Config, issuer, localTenantKey string, jitEnabled bool) identity.Revision {
|
|
t.Helper()
|
|
draft, err := identity.NewDraft(identity.PairingInput{
|
|
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
|
|
LocalTenantKey: localTenantKey, LegacyJWTEnabled: true,
|
|
}, "test")
|
|
if err != nil {
|
|
t.Fatalf("create OIDC JIT draft: %v", err)
|
|
}
|
|
draft.JITEnabled = jitEnabled
|
|
draft, err = db.CreateIdentityConfigurationRevision(ctx, draft)
|
|
if err != nil {
|
|
t.Fatalf("persist OIDC JIT draft: %v", err)
|
|
}
|
|
secrets, err := identitySecretStore(cfg)
|
|
if err != nil {
|
|
t.Fatalf("create identity SecretStore: %v", err)
|
|
}
|
|
sessionReference := "oidc-jit-session-" + draft.ID
|
|
if err := secrets.Put(ctx, sessionReference, bytes.Repeat([]byte{8}, 32)); err != nil {
|
|
t.Fatalf("store OIDC JIT session key: %v", err)
|
|
}
|
|
draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
|
|
Manifest: identity.ManifestV1{
|
|
SchemaVersion: 1, Issuer: issuer, TenantID: oidcJITTenantID, ApplicationID: uuid.NewString(),
|
|
Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"},
|
|
Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}},
|
|
},
|
|
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", AppEnv: "test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("apply OIDC JIT manifest: %v", err)
|
|
}
|
|
draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "oidc-jit-test", "oidc-jit-test")
|
|
if err != nil {
|
|
t.Fatalf("validate OIDC JIT revision: %v", err)
|
|
}
|
|
return draft
|
|
}
|
|
|
|
func restoreOIDCJITIdentityRevision(t *testing.T, ctx context.Context, db *store.Store, previous identity.Revision, hadPrevious bool, testRevisionIDs []string) {
|
|
t.Helper()
|
|
isTestRevision := func(id string) bool {
|
|
for _, testID := range testRevisionIDs {
|
|
if id == testID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if active, err := db.ActiveIdentityConfigurationRevision(ctx); err == nil && isTestRevision(active.ID) {
|
|
if _, disableErr := db.DisableActiveIdentityRevision(ctx, active.Version, "oidc-jit-cleanup", "oidc-jit-cleanup"); disableErr != nil {
|
|
t.Errorf("disable test identity revision during cleanup: %v", disableErr)
|
|
return
|
|
}
|
|
}
|
|
if hadPrevious {
|
|
refreshed, err := db.IdentityConfigurationRevision(ctx, previous.ID)
|
|
if err != nil {
|
|
t.Errorf("reload previous identity revision during cleanup: %v", err)
|
|
return
|
|
}
|
|
if refreshed.State == identity.RevisionSuperseded {
|
|
refreshed, err = db.MarkIdentityRevisionValidated(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
|
|
if err == nil {
|
|
_, _, err = db.ActivateIdentityRevision(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
|
|
}
|
|
if err != nil {
|
|
t.Errorf("restore previous identity revision: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if len(testRevisionIDs) > 0 {
|
|
if _, err := db.Pool().Exec(ctx, `DELETE FROM gateway_identity_configuration_revisions WHERE id = ANY($1::uuid[])`, testRevisionIDs); err != nil {
|
|
t.Errorf("delete test identity revisions: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var currentOIDCTestNonce string
|
|
|
|
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
|
t.Helper()
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
|
if request.URL.Path == "/api/v1/auth/oidc/callback" {
|
|
currentOIDCTestNonce = request.URL.Query().Get("nonce")
|
|
}
|
|
if len(via) > 10 {
|
|
return errors.New("too many redirects")
|
|
}
|
|
return nil
|
|
}}
|
|
response, err := client.Get(baseURL + "/api/v1/auth/oidc/login?returnTo=%2Fapi%2Fv1%2Fme")
|
|
if err != nil {
|
|
t.Fatalf("complete OIDC BFF login: %v", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(response.Body)
|
|
t.Fatalf("OIDC BFF login status = %d, want 200: %s", response.StatusCode, body)
|
|
}
|
|
parsedBaseURL, _ := url.Parse(baseURL)
|
|
for _, cookie := range jar.Cookies(parsedBaseURL) {
|
|
if cookie.Name == auth.OIDCSessionCookieName {
|
|
if strings.Count(cookie.Value, ".") == 2 {
|
|
t.Fatal("browser session cookie contains a JWT instead of an opaque session ID")
|
|
}
|
|
return cookie
|
|
}
|
|
}
|
|
t.Fatal("OIDC browser session cookie was not returned")
|
|
return nil
|
|
}
|
|
|
|
func assertOIDCJITError(t *testing.T, baseURL string, token string, expectedStatus int, expectedCode string) {
|
|
t.Helper()
|
|
var envelope struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
doOIDCJITJSON(t, baseURL, http.MethodGet, "/api/v1/me", token, nil, expectedStatus, &envelope)
|
|
if envelope.Error.Code != expectedCode {
|
|
t.Fatalf("error code = %q, want %q", envelope.Error.Code, expectedCode)
|
|
}
|
|
}
|
|
|
|
func doOIDCJITJSON(t *testing.T, baseURL string, method string, path string, token string, payload any, expectedStatus int, output any) {
|
|
t.Helper()
|
|
var body io.Reader
|
|
if payload != nil {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal OIDC JIT request: %v", err)
|
|
}
|
|
body = bytes.NewReader(raw)
|
|
}
|
|
request, err := http.NewRequest(method, baseURL+path, body)
|
|
if err != nil {
|
|
t.Fatalf("build %s %s request: %v", method, path, err)
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
|
if payload != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
t.Fatalf("execute %s %s: %v", method, path, err)
|
|
}
|
|
defer response.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
if err != nil {
|
|
t.Fatalf("read %s %s response: %v", method, path, err)
|
|
}
|
|
if response.StatusCode != expectedStatus {
|
|
t.Fatalf("%s %s status=%d, want=%d", method, path, response.StatusCode, expectedStatus)
|
|
}
|
|
if output != nil && len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, output); err != nil {
|
|
t.Fatalf("decode %s %s response: %v", method, path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func signedOIDCJITToken(t *testing.T, key *ecdsa.PrivateKey, issuer string, subject string, mutate func(jwt.MapClaims)) string {
|
|
t.Helper()
|
|
now := time.Now()
|
|
claims := jwt.MapClaims{
|
|
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": oidcJITTenantID,
|
|
"preferred_username": "oidc-jit-acceptance", "roles": []string{"gateway.admin"},
|
|
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
|
"exp": now.Add(time.Hour).Unix(),
|
|
}
|
|
if mutate != nil {
|
|
mutate(claims)
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
|
token.Header["kid"] = "jit-key"
|
|
raw, err := token.SignedString(key)
|
|
if err != nil {
|
|
t.Fatalf("sign OIDC JIT test token: %v", err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func oidcJITECJWK(kid string, key *ecdsa.PublicKey) map[string]any {
|
|
return map[string]any{
|
|
"kid": kid,
|
|
"kty": "EC",
|
|
"use": "sig",
|
|
"alg": "ES256",
|
|
"crv": "P-256",
|
|
"x": base64.RawURLEncoding.EncodeToString(key.X.FillBytes(make([]byte, 32))),
|
|
"y": base64.RawURLEncoding.EncodeToString(key.Y.FillBytes(make([]byte, 32))),
|
|
}
|
|
}
|