Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e324-abce-7e51-bb2b-496f097afdb0
175 lines
6.5 KiB
Go
175 lines
6.5 KiB
Go
package oidc_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"keycape/internal/domain"
|
|
"keycape/internal/server/oidc"
|
|
)
|
|
|
|
// The tenant claim is bound at registration time. KeyCape emits exactly the
|
|
// registered value and performs no mapping between tenant vocabularies used by
|
|
// consuming resource servers (approval stores, policy subjects). See
|
|
// docs/tenant-claim-contract.md.
|
|
|
|
func TestServiceTenantIsBoundToRegistrationAndIgnoresRequestParameters(t *testing.T) {
|
|
h := serviceTokenHandler(t)
|
|
req := tokenRequest(url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"scope": {"finance.qonto.read"},
|
|
"tenant": {"tenant:platform"},
|
|
"tenant_hint": {"platform"},
|
|
})
|
|
req.SetBasicAuth("rapp-qonto", "test-service-secret")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
|
}
|
|
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
|
|
claims := parseJWTPayload(t, token)
|
|
if claims["tenant"] != "tenant:friendly:binky" {
|
|
t.Fatalf("tenant claim not bound to registration: %v", claims["tenant"])
|
|
}
|
|
verifyWithJWKS(t, h, token)
|
|
}
|
|
|
|
// A token minted for one registration never carries another registration's
|
|
// tenant: this is the wrong-tenant evidence a resource server needs to reject
|
|
// a caller by exact string comparison.
|
|
func TestServiceTenantsAreDistinctPerRegistration(t *testing.T) {
|
|
h := serviceTokenHandler(t)
|
|
h.ClientConfig["platform-client"] = &domain.Client{
|
|
ClientID: "platform-client",
|
|
AllowedScopes: []string{"finance.qonto.read"},
|
|
GrantTypes: []string{"client_credentials"},
|
|
ClientType: "confidential",
|
|
ClientSecret: "other-service-secret",
|
|
ServiceSubject: "service:platform-client",
|
|
Tenant: "tenant:platform",
|
|
Roles: []string{"platform"},
|
|
}
|
|
for clientID, want := range map[string]string{
|
|
"rapp-qonto": "tenant:friendly:binky",
|
|
"platform-client": "tenant:platform",
|
|
} {
|
|
secret := "test-service-secret"
|
|
if clientID == "platform-client" {
|
|
secret = "other-service-secret"
|
|
}
|
|
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "scope": {"finance.qonto.read"}})
|
|
req.SetBasicAuth(clientID, secret)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s: status %d", clientID, w.Code)
|
|
}
|
|
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
|
|
if got := parseJWTPayload(t, token)["tenant"]; got != want {
|
|
t.Fatalf("%s: tenant %v, want %v", clientID, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A human token carries the directory tenant, defaulting to the platform tenant
|
|
// (tenant:coulomb) rather than an empty claim.
|
|
func TestHumanTenantClaimUsesDirectoryValueThenPlatformDefault(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
tenant string
|
|
want string
|
|
}{
|
|
{"directory", "tenant:friendly:binky", "tenant:friendly:binky"},
|
|
{"default", "", "tenant:coulomb"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
user := aliceUser()
|
|
user.Tenant = tc.tenant
|
|
sessions := oidc.NewSessionStore()
|
|
h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": user}})
|
|
verifier := "test-verifier"
|
|
code := seededSession(sessions, verifier)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}}))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
|
}
|
|
access := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
|
|
if got := parseJWTPayload(t, access)["tenant"]; got != tc.want {
|
|
t.Fatalf("tenant %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Issuance evidence for the aligned approval chain: a token minted for the
|
|
// approval registrations carries tenant:platform exactly, and never the
|
|
// rejected alias spellings, even when the caller asks for them
|
|
// (decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6).
|
|
func TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases(t *testing.T) {
|
|
h := serviceTokenHandler(t)
|
|
h.ClientConfig["secrets-engine-approval"] = &domain.Client{
|
|
ClientID: "secrets-engine-approval",
|
|
Audience: "approval-engine",
|
|
AllowedScopes: []string{"approval:read", "approval:consume"},
|
|
GrantTypes: []string{"client_credentials"},
|
|
ClientType: "confidential",
|
|
ClientSecret: "approval-test-secret",
|
|
ServiceSubject: "service:coulomb-workload-test",
|
|
Tenant: "tenant:platform",
|
|
Roles: []string{"secrets-engine"},
|
|
}
|
|
for _, requested := range []string{"", "platform", "tenant:coulomb", "TENANT:PLATFORM"} {
|
|
values := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
|
|
if requested != "" {
|
|
values.Set("tenant", requested)
|
|
values.Set("tenant_hint", requested)
|
|
}
|
|
req := tokenRequest(values)
|
|
req.SetBasicAuth("secrets-engine-approval", "approval-test-secret")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("requested %q: status %d", requested, w.Code)
|
|
}
|
|
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
|
|
claims := parseJWTPayload(t, token)
|
|
if claims["tenant"] != "tenant:platform" {
|
|
t.Fatalf("requested %q: tenant %v, want tenant:platform", requested, claims["tenant"])
|
|
}
|
|
if claims["aud"] != "approval-engine" {
|
|
t.Fatalf("requested %q: audience %v", requested, claims["aud"])
|
|
}
|
|
verifyWithJWKS(t, h, token)
|
|
}
|
|
}
|
|
|
|
// Platform ownership does not elevate workload clients. Request parameters
|
|
// cannot change a workload registration's tenant.
|
|
func TestUnrelatedServiceClientKeepsCoulombTenant(t *testing.T) {
|
|
h := serviceTokenHandler(t)
|
|
h.ClientConfig["coulomb-workload-test"] = &domain.Client{
|
|
ClientID: "coulomb-workload-test",
|
|
AllowedScopes: []string{"openbao:login"},
|
|
GrantTypes: []string{"client_credentials"},
|
|
ClientType: "confidential",
|
|
ClientSecret: "openbao-test-secret",
|
|
ServiceSubject: "service:coulomb-workload-test",
|
|
Tenant: "tenant:coulomb",
|
|
Roles: []string{"secrets-engine"},
|
|
}
|
|
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "scope": {"openbao:login"}, "tenant": {"tenant:platform"}})
|
|
req.SetBasicAuth("coulomb-workload-test", "openbao-test-secret")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d", w.Code)
|
|
}
|
|
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
|
|
if got := parseJWTPayload(t, token)["tenant"]; got != "tenant:coulomb" {
|
|
t.Fatalf("tenant %v, want tenant:coulomb", got)
|
|
}
|
|
}
|