2026-03-13 01:56:57 +01:00
|
|
|
package oidc_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"crypto/rsa"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00
|
|
|
"keycape/internal/adapters/tenantengine"
|
2026-03-13 01:56:57 +01:00
|
|
|
"keycape/internal/domain"
|
|
|
|
|
profileerrors "keycape/internal/errors"
|
|
|
|
|
"keycape/internal/server/oidc"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Mock UserRepository
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type mockUserRepo struct {
|
|
|
|
|
users map[string]*domain.User
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockUserRepo) LookupUser(_ context.Context, username string) (*domain.User, error) {
|
|
|
|
|
u, ok := m.users[username]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, domain.ErrUserNotFound
|
|
|
|
|
}
|
|
|
|
|
return u, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockUserRepo) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *mockUserRepo) ValidatePassword(_ context.Context, _, _ string) (bool, error) {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 02:08:03 +01:00
|
|
|
func (m *mockUserRepo) ListUsers(_ context.Context) ([]domain.User, error) {
|
|
|
|
|
users := make([]domain.User, 0, len(m.users))
|
|
|
|
|
for _, u := range m.users {
|
|
|
|
|
users = append(users, *u)
|
|
|
|
|
}
|
|
|
|
|
return users, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 01:56:57 +01:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// PKCE helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// makeVerifierAndChallenge returns a code_verifier and its S256 code_challenge.
|
|
|
|
|
func makeVerifierAndChallenge() (verifier, challenge string) {
|
|
|
|
|
b := make([]byte, 32)
|
|
|
|
|
if _, err := rand.Read(b); err != nil {
|
|
|
|
|
panic(err)
|
|
|
|
|
}
|
|
|
|
|
verifier = base64.RawURLEncoding.EncodeToString(b)
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
h.Write([]byte(verifier))
|
|
|
|
|
challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Test helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
func newTokenHandler(t *testing.T, sessions *oidc.SessionStore, users domain.UserRepository) (*oidc.TokenHandler, *rsa.PrivateKey) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("generate key: %v", err)
|
|
|
|
|
}
|
|
|
|
|
emitter := &captureEmitter{}
|
|
|
|
|
h := &oidc.TokenHandler{
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
ClientConfig: testClient(),
|
|
|
|
|
Sessions: sessions,
|
|
|
|
|
Users: users,
|
2026-03-13 01:56:57 +01:00
|
|
|
SigningKey: key,
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
Issuer: "https://auth.netkingdom.local",
|
2026-03-13 01:56:57 +01:00
|
|
|
TokenLifetime: 15 * time.Minute,
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
Emitter: emitter,
|
2026-03-13 01:56:57 +01:00
|
|
|
}
|
|
|
|
|
return h, key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func tokenRequest(params url.Values) *http.Request {
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/token",
|
|
|
|
|
strings.NewReader(params.Encode()))
|
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
|
return req
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func seededSession(sessions *oidc.SessionStore, verifier string) (code string) {
|
|
|
|
|
challenge := s256Challenge(verifier)
|
|
|
|
|
sess := &oidc.PKCESession{
|
|
|
|
|
ClientID: "test-client",
|
|
|
|
|
RedirectURI: "https://app.example.com/callback",
|
|
|
|
|
PKCEChallenge: challenge,
|
|
|
|
|
PKCEChallengeMethod: "S256",
|
|
|
|
|
State: "state1",
|
2026-06-01 21:20:54 +02:00
|
|
|
Nonce: "nonce1",
|
2026-03-13 01:56:57 +01:00
|
|
|
Username: "alice",
|
|
|
|
|
Scopes: []string{"openid", "profile", "email", "groups"},
|
|
|
|
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
|
|
|
|
}
|
|
|
|
|
return sessions.Create(sess)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func s256Challenge(verifier string) string {
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
h.Write([]byte(verifier))
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decodeTokenResponse(t *testing.T, body string) map[string]interface{} {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var m map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
|
|
|
|
t.Fatalf("could not decode token response: %v (body: %q)", err, body)
|
|
|
|
|
}
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseJWTPayload(t *testing.T, token string) map[string]interface{} {
|
|
|
|
|
t.Helper()
|
|
|
|
|
parts := strings.Split(token, ".")
|
|
|
|
|
if len(parts) != 3 {
|
|
|
|
|
t.Fatalf("expected 3 JWT parts, got %d", len(parts))
|
|
|
|
|
}
|
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("decode JWT payload: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var claims map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal JWT payload: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return claims
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func aliceUser() *domain.User {
|
|
|
|
|
return &domain.User{
|
|
|
|
|
ID: "user-alice",
|
|
|
|
|
Username: "alice",
|
|
|
|
|
Email: "alice@example.com",
|
|
|
|
|
Groups: []string{"admin", "users"},
|
|
|
|
|
Enabled: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// T07 Token Endpoint Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_ValidExchange_ReturnsJWT(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
if _, ok := resp["access_token"]; !ok {
|
|
|
|
|
t.Error("missing access_token")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := resp["id_token"]; !ok {
|
|
|
|
|
t.Error("missing id_token")
|
|
|
|
|
}
|
|
|
|
|
if resp["token_type"] != "Bearer" {
|
|
|
|
|
t.Errorf("expected token_type Bearer, got %v", resp["token_type"])
|
|
|
|
|
}
|
|
|
|
|
if _, ok := resp["expires_in"]; !ok {
|
|
|
|
|
t.Error("missing expires_in")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 01:23:22 +02:00
|
|
|
func TestTokenHandler_SuspendedUserCannotExchangeCode(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
user := aliceUser()
|
|
|
|
|
user.Groups = append(user.Groups, "netkingdom-suspended")
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": user}}
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
verifier := "suspended-verifier-with-enough-entropy"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
req := tokenRequest(url.Values{
|
|
|
|
|
"grant_type": {"authorization_code"},
|
|
|
|
|
"code": {code},
|
|
|
|
|
"client_id": {"test-client"},
|
|
|
|
|
"code_verifier": {verifier},
|
|
|
|
|
})
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if _, ok := sessions.Get(code); ok {
|
|
|
|
|
t.Fatal("authorization code must be consumed when suspension is detected")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 01:56:57 +01:00
|
|
|
func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
2026-07-27 20:03:07 +02:00
|
|
|
"grant_type": []string{"password"},
|
2026-03-13 01:56:57 +01:00
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
errType := decodeProfileError(t, w.Body.String())
|
|
|
|
|
if errType != profileerrors.ErrFeatureNotSupported {
|
|
|
|
|
t.Errorf("expected feature_not_supported_by_profile, got %q", errType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 20:03:07 +02:00
|
|
|
func serviceTokenHandler(t *testing.T) *oidc.TokenHandler {
|
|
|
|
|
t.Helper()
|
|
|
|
|
h, _ := newTokenHandler(t, oidc.NewSessionStore(), &mockUserRepo{})
|
|
|
|
|
h.ClientConfig["rapp-qonto"] = &domain.Client{
|
|
|
|
|
ClientID: "rapp-qonto",
|
|
|
|
|
AllowedScopes: []string{"finance.qonto.read"},
|
|
|
|
|
GrantTypes: []string{"client_credentials"},
|
|
|
|
|
ClientType: "confidential",
|
|
|
|
|
ClientSecret: "test-service-secret",
|
|
|
|
|
ServiceSubject: "service:rapp-qonto",
|
|
|
|
|
Tenant: "tenant:friendly:binky",
|
|
|
|
|
Roles: []string{"finance-reader"},
|
|
|
|
|
}
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T) {
|
|
|
|
|
h := serviceTokenHandler(t)
|
|
|
|
|
req := tokenRequest(url.Values{
|
|
|
|
|
"grant_type": {"client_credentials"},
|
|
|
|
|
"scope": {"finance.qonto.read"},
|
|
|
|
|
})
|
|
|
|
|
req.SetBasicAuth("rapp-qonto", "test-service-secret")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
if _, ok := resp["id_token"]; ok {
|
|
|
|
|
t.Fatal("service exchange must not return id_token")
|
|
|
|
|
}
|
|
|
|
|
claims := parseJWTPayload(t, resp["access_token"].(string))
|
|
|
|
|
if claims["sub"] != "service:rapp-qonto" ||
|
|
|
|
|
claims["tenant"] != "tenant:friendly:binky" ||
|
|
|
|
|
claims["principal_type"] != "service" ||
|
|
|
|
|
claims["scope"] != "finance.qonto.read" {
|
|
|
|
|
t.Fatalf("unexpected service claims: %#v", claims)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) {
|
|
|
|
|
h := serviceTokenHandler(t)
|
|
|
|
|
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}})
|
|
|
|
|
req.SetBasicAuth("rapp-qonto", "wrong")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("expected 401, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_ClientCredentials_RejectsExcessScope(t *testing.T) {
|
|
|
|
|
h := serviceTokenHandler(t)
|
|
|
|
|
req := tokenRequest(url.Values{
|
|
|
|
|
"grant_type": {"client_credentials"},
|
|
|
|
|
"scope": {"finance.qonto.write"},
|
|
|
|
|
})
|
|
|
|
|
req.SetBasicAuth("rapp-qonto", "test-service-secret")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 01:56:57 +01:00
|
|
|
func TestTokenHandler_PKCEMismatch_InvalidProfileUsage(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
realVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, realVerifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{"wrong-verifier-that-does-not-match"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
errType := decodeProfileError(t, w.Body.String())
|
|
|
|
|
if errType != profileerrors.ErrInvalidProfileUsage {
|
|
|
|
|
t.Errorf("expected invalid_profile_usage, got %q", errType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_CodeNotFound_InvalidProfileUsage(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{"no-such-code"},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{"any-verifier"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
errType := decodeProfileError(t, w.Body.String())
|
|
|
|
|
if errType != profileerrors.ErrInvalidProfileUsage {
|
|
|
|
|
t.Errorf("expected invalid_profile_usage, got %q", errType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_JWTClaims_CorrectSubAndIssuer(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
idToken, ok := resp["id_token"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("id_token is not a string")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
claims := parseJWTPayload(t, idToken)
|
|
|
|
|
|
|
|
|
|
if claims["sub"] != "user-alice" {
|
|
|
|
|
t.Errorf("sub: expected user-alice, got %v", claims["sub"])
|
|
|
|
|
}
|
|
|
|
|
if claims["iss"] != "https://auth.netkingdom.local" {
|
|
|
|
|
t.Errorf("iss: expected https://auth.netkingdom.local, got %v", claims["iss"])
|
|
|
|
|
}
|
|
|
|
|
if claims["aud"] != "test-client" {
|
|
|
|
|
t.Errorf("aud: expected test-client, got %v", claims["aud"])
|
|
|
|
|
}
|
2026-06-01 21:20:54 +02:00
|
|
|
if claims["nonce"] != "nonce1" {
|
|
|
|
|
t.Errorf("nonce: expected nonce1, got %v", claims["nonce"])
|
|
|
|
|
}
|
2026-03-13 01:56:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
// Seed session with only openid scope (no email, no groups).
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
challenge := s256Challenge(verifier)
|
|
|
|
|
sess := &oidc.PKCESession{
|
|
|
|
|
ClientID: "test-client",
|
|
|
|
|
RedirectURI: "https://app.example.com/callback",
|
|
|
|
|
PKCEChallenge: challenge,
|
|
|
|
|
PKCEChallengeMethod: "S256",
|
|
|
|
|
Username: "alice",
|
|
|
|
|
Scopes: []string{"openid"}, // no profile/email/groups
|
|
|
|
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
|
|
|
|
}
|
|
|
|
|
code := sessions.Create(sess)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
idToken := resp["id_token"].(string)
|
|
|
|
|
claims := parseJWTPayload(t, idToken)
|
|
|
|
|
|
|
|
|
|
// Without profile scope, preferred_username must not be present.
|
|
|
|
|
if _, ok := claims["preferred_username"]; ok {
|
|
|
|
|
t.Error("preferred_username must be absent when profile scope is not granted")
|
|
|
|
|
}
|
|
|
|
|
// Without email scope, email must not be present.
|
|
|
|
|
if _, ok := claims["email"]; ok {
|
|
|
|
|
t.Error("email must be absent when email scope is not granted")
|
|
|
|
|
}
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
// groups is a required IAM Profile core claim (iam-profile_v0.3.md) --
|
|
|
|
|
// present regardless of scope, unlike preferred_username/email above.
|
|
|
|
|
if _, ok := claims["groups"]; !ok {
|
|
|
|
|
t.Error("groups must be present as a core claim even without a groups scope (KEY-WP-0005-T01)")
|
2026-03-13 01:56:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_ScopeFiltering_AllScopes(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier) // has openid, profile, email, groups
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
idToken := resp["id_token"].(string)
|
|
|
|
|
claims := parseJWTPayload(t, idToken)
|
|
|
|
|
|
|
|
|
|
if claims["preferred_username"] != "alice" {
|
|
|
|
|
t.Errorf("preferred_username: expected alice, got %v", claims["preferred_username"])
|
|
|
|
|
}
|
|
|
|
|
if claims["email"] != "alice@example.com" {
|
|
|
|
|
t.Errorf("email: expected alice@example.com, got %v", claims["email"])
|
|
|
|
|
}
|
|
|
|
|
if _, ok := claims["groups"]; !ok {
|
|
|
|
|
t.Error("groups claim must be present when groups scope is granted")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_TokenIssuedTelemetry(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("generate key: %v", err)
|
|
|
|
|
}
|
|
|
|
|
capture := &captureEmitter{}
|
|
|
|
|
h := &oidc.TokenHandler{
|
|
|
|
|
ClientConfig: testClient(),
|
|
|
|
|
Sessions: sessions,
|
|
|
|
|
Users: users,
|
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.
What shipped for the human Authorization Code + PKCE flow:
- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
always [] not null when empty. One pre-existing test asserted the old
scope-gated groups behavior -- updated to match the new intentional
behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
through completeAuthorization's two call sites in authorize.go) --
whether MFA was actually verified in this session, not static enrollment
state. aal2 only when required-and-passed this time, aal1 otherwise.
go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.
Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:03:31 +02:00
|
|
|
SigningKey: key,
|
2026-03-13 01:56:57 +01:00
|
|
|
Issuer: "https://auth.netkingdom.local",
|
|
|
|
|
TokenLifetime: 15 * time.Minute,
|
|
|
|
|
Emitter: capture,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
found := false
|
|
|
|
|
for _, ev := range capture.events {
|
|
|
|
|
if ev.EventType == telemetry.EventTokenIssued {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Error("expected token_issued telemetry event")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_CodeDeletedAfterUse(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First use — should succeed.
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("first use: expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Second use — code should be gone.
|
|
|
|
|
req2 := tokenRequest(params)
|
|
|
|
|
w2 := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w2, req2)
|
|
|
|
|
if w2.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("second use: expected 400 (code replay), got %d", w2.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// KEY-WP-0005-T02: cached tenant_roles claim
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_TenantRoles_PresentWhenTenantEngineReachable(t *testing.T) {
|
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.Write([]byte(`{"tenant_id":"tenant:coulomb","roles":["IAM","VEN"]}`))
|
|
|
|
|
}))
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
h.TenantEngine = tenantengine.New(server.URL, nil)
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
claims := parseJWTPayload(t, resp["id_token"].(string))
|
|
|
|
|
|
|
|
|
|
roles, ok := claims["tenant_roles"].([]interface{})
|
|
|
|
|
if !ok || len(roles) != 2 || roles[0] != "IAM" || roles[1] != "VEN" {
|
|
|
|
|
t.Errorf("tenant_roles: want [IAM VEN], got %v", claims["tenant_roles"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_TenantRoles_OmittedButIssuanceSucceedsWhenUnreachable(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
// Unreachable: nothing listens on this port.
|
|
|
|
|
h.TenantEngine = tenantengine.New("http://127.0.0.1:1", nil)
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
// Token issuance itself must not fail just because the cache source is
|
|
|
|
|
// down -- this is the literal point of KEY-WP-0005-T02's fail-open rule.
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200 even with tenant-engine unreachable, got %d (body: %s)", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
claims := parseJWTPayload(t, resp["id_token"].(string))
|
|
|
|
|
|
|
|
|
|
if _, present := claims["tenant_roles"]; present {
|
|
|
|
|
t.Errorf("tenant_roles must be omitted (not null, not []), got %v", claims["tenant_roles"])
|
|
|
|
|
}
|
|
|
|
|
// Every other core claim must still be present -- one missing optional
|
|
|
|
|
// claim must not cascade into missing required ones.
|
|
|
|
|
for _, c := range []string{"tenant", "principal_type", "groups", "roles", "assurance"} {
|
|
|
|
|
if _, ok := claims[c]; !ok {
|
|
|
|
|
t.Errorf("required claim %q missing even though only tenant_roles should be affected", c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestTokenHandler_TenantRoles_OmittedWhenTenantEngineNotConfigured(t *testing.T) {
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
|
|
|
|
h, _ := newTokenHandler(t, sessions, users)
|
|
|
|
|
// h.TenantEngine left nil -- must not panic, must simply omit the claim.
|
|
|
|
|
|
|
|
|
|
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
|
|
|
code := seededSession(sessions, verifier)
|
|
|
|
|
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"grant_type": []string{"authorization_code"},
|
|
|
|
|
"code": []string{code},
|
|
|
|
|
"client_id": []string{"test-client"},
|
|
|
|
|
"redirect_uri": []string{"https://app.example.com/callback"},
|
|
|
|
|
"code_verifier": []string{verifier},
|
|
|
|
|
}
|
|
|
|
|
req := tokenRequest(params)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
resp := decodeTokenResponse(t, w.Body.String())
|
|
|
|
|
claims := parseJWTPayload(t, resp["id_token"].(string))
|
|
|
|
|
|
|
|
|
|
if _, present := claims["tenant_roles"]; present {
|
|
|
|
|
t.Errorf("tenant_roles must be omitted when TenantEngine is nil, got %v", claims["tenant_roles"])
|
|
|
|
|
}
|
|
|
|
|
}
|