From 909bb327fc418aa747c6c7774e1441baad4a5fa1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Jul 2026 01:23:22 +0200 Subject: [PATCH] Deny suspended directory identities --- src/internal/server/oidc/token.go | 12 ++++++++++ src/internal/server/oidc/token_test.go | 24 +++++++++++++++++++ src/internal/server/oidc/userinfo.go | 10 +++++--- src/internal/server/oidc/userinfo_test.go | 28 +++++++++++++++++++---- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index a6af32e..915cd26 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -108,6 +108,14 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "user not found", http.StatusInternalServerError) return } + if isSuspended(user) { + h.Sessions.Delete(code) + profileerrors.RejectedForSafety( + "account is suspended", + "account_lifecycle", + ).Write(w, http.StatusForbidden) + return + } // 6. Build JWT claims. now := time.Now() @@ -270,6 +278,10 @@ func containsString(values []string, wanted string) bool { return false } +func isSuspended(user *domain.User) bool { + return containsString(user.Groups, "netkingdom-suspended") +} + // --------------------------------------------------------------------------- // IAM Profile core claims (KEY-WP-0005-T01) // --------------------------------------------------------------------------- diff --git a/src/internal/server/oidc/token_test.go b/src/internal/server/oidc/token_test.go index 96e5b32..1e51b9e 100644 --- a/src/internal/server/oidc/token_test.go +++ b/src/internal/server/oidc/token_test.go @@ -202,6 +202,30 @@ func TestTokenHandler_ValidExchange_ReturnsJWT(t *testing.T) { } } +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") + } +} + func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{} diff --git a/src/internal/server/oidc/userinfo.go b/src/internal/server/oidc/userinfo.go index f465203..ee4512e 100644 --- a/src/internal/server/oidc/userinfo.go +++ b/src/internal/server/oidc/userinfo.go @@ -21,10 +21,10 @@ import ( // the user, and returns claims that are consistent with those in the ID token // for the same scope set. type UserinfoHandler struct { - Users domain.UserRepository + Users domain.UserRepository SigningKey *rsa.PublicKey // used to verify the incoming access token - Issuer string - Emitter telemetry.Emitter + Issuer string + Emitter telemetry.Emitter } // ServeHTTP handles GET /userinfo. @@ -59,6 +59,10 @@ func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized) return } + if isSuspended(user) { + http.Error(w, `{"error":"invalid_token","description":"subject is suspended"}`, http.StatusUnauthorized) + return + } // 5. Build response claims filtered by the scopes embedded in the token. scopeStr, _ := claims["scope"].(string) diff --git a/src/internal/server/oidc/userinfo_test.go b/src/internal/server/oidc/userinfo_test.go index c846e8f..feaf1ca 100644 --- a/src/internal/server/oidc/userinfo_test.go +++ b/src/internal/server/oidc/userinfo_test.go @@ -27,7 +27,7 @@ func newUserinfoHandler(t *testing.T, users domain.UserRepository) (*oidc.Userin capture := &captureEmitter{} h := &oidc.UserinfoHandler{ Users: users, - SigningKey: &key.PublicKey, + SigningKey: &key.PublicKey, Issuer: "https://auth.netkingdom.local", Emitter: capture, } @@ -100,6 +100,26 @@ func TestUserinfoHandler_ValidToken_ReturnsClaims(t *testing.T) { } } +func TestUserinfoHandler_SuspendedUserInvalidatesToken(t *testing.T) { + user := aliceUser() + user.Groups = append(user.Groups, "netkingdom-suspended") + users := &mockUserRepo{users: map[string]*domain.User{"alice": user}} + h, key := newUserinfoHandler(t, users) + now := time.Now() + token := buildToken(t, map[string]interface{}{ + "iss": "https://auth.netkingdom.local", + "sub": "alice", + "aud": "test-client", + "exp": now.Add(10 * time.Minute).Unix(), + "iat": now.Unix(), + }, key) + w := httptest.NewRecorder() + h.ServeHTTP(w, userinfoRequest(token)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String()) + } +} + func TestUserinfoHandler_MissingAuthorization_Returns401(t *testing.T) { users := &mockUserRepo{} h, _ := newUserinfoHandler(t, users) @@ -246,10 +266,10 @@ func TestUserinfoHandler_EmitsTelemetry(t *testing.T) { key, _ := rsa.GenerateKey(rand.Reader, 2048) capture := &captureEmitter{} h := &oidc.UserinfoHandler{ - Users: users, + Users: users, SigningKey: &key.PublicKey, - Issuer: "https://auth.netkingdom.local", - Emitter: capture, + Issuer: "https://auth.netkingdom.local", + Emitter: capture, } now := time.Now()