Fix UserInfo canonical subject resolution
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 23s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 23s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a058f3-8ba0-7692-a042-9a870fc3d663
This commit is contained in:
parent
49dabb2d5c
commit
153258b9d3
3 changed files with 112 additions and 2 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
package oidc
|
package oidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto"
|
"crypto"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
|
@ -52,8 +53,11 @@ func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Look up user by sub (sub IS the username per spec §3.1).
|
// 4. Resolve the canonical subject. Human tokens use the stable directory
|
||||||
user, err := h.Users.LookupUser(ctx, sub)
|
// ID (the LDAP DN) as sub, while LookupUser accepts an LDAP uid. Prefer the
|
||||||
|
// scoped username hint but require it to resolve back to the signed subject;
|
||||||
|
// fall back to an ID scan for openid-only tokens and renamed users.
|
||||||
|
user, err := lookupUserBySubject(ctx, h.Users, claims, sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// User referenced in token but not found → treat as invalid token.
|
// User referenced in token but not found → treat as invalid token.
|
||||||
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
|
||||||
|
|
@ -97,6 +101,36 @@ func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lookupUserBySubject(
|
||||||
|
ctx context.Context,
|
||||||
|
users domain.UserRepository,
|
||||||
|
claims map[string]interface{},
|
||||||
|
sub string,
|
||||||
|
) (*domain.User, error) {
|
||||||
|
if username, _ := claims["preferred_username"].(string); username != "" {
|
||||||
|
if user, err := users.LookupUser(ctx, username); err == nil && user.ID == sub {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retain compatibility with older signed tokens whose subject was the uid.
|
||||||
|
if user, err := users.LookupUser(ctx, sub); err == nil &&
|
||||||
|
(user.ID == sub || user.Username == sub) {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := users.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range all {
|
||||||
|
if all[i].ID == sub {
|
||||||
|
return &all[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, domain.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JWT validation (stdlib only — no external JWT library)
|
// JWT validation (stdlib only — no external JWT library)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,36 @@ func TestUserinfoHandler_ValidToken_ReturnsClaims(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserinfoHandler_CanonicalSubjectResolvesViaPreferredUsername(t *testing.T) {
|
||||||
|
user := aliceUser()
|
||||||
|
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": user.ID,
|
||||||
|
"preferred_username": user.Username,
|
||||||
|
"aud": "test-client",
|
||||||
|
"exp": now.Add(10 * time.Minute).Unix(),
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"scope": "openid profile",
|
||||||
|
}, key)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, userinfoRequest(token))
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
resp := decodeUserinfoClaims(t, w.Body.String())
|
||||||
|
if resp["sub"] != user.ID {
|
||||||
|
t.Errorf("sub: expected %q, got %v", user.ID, resp["sub"])
|
||||||
|
}
|
||||||
|
if resp["preferred_username"] != user.Username {
|
||||||
|
t.Errorf("preferred_username: expected %q, got %v", user.Username, resp["preferred_username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserinfoHandler_SuspendedUserInvalidatesToken(t *testing.T) {
|
func TestUserinfoHandler_SuspendedUserInvalidatesToken(t *testing.T) {
|
||||||
user := aliceUser()
|
user := aliceUser()
|
||||||
user.Groups = append(user.Groups, "netkingdom-suspended")
|
user.Groups = append(user.Groups, "netkingdom-suspended")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
id: KEY-WP-0012
|
||||||
|
type: workplan
|
||||||
|
title: "Repair UserInfo canonical subject resolution"
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: active
|
||||||
|
owner: codex
|
||||||
|
topic_slug: userinfo-canonical-subject-resolution
|
||||||
|
created: "2026-08-31"
|
||||||
|
updated: "2026-08-31"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repair subject lookup
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0012-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve the canonical LDAP-DN `sub` emitted by the token endpoint without
|
||||||
|
passing it to the username-only repository lookup. Preserve stable subject
|
||||||
|
semantics and verify any `preferred_username` lookup against the canonical ID.
|
||||||
|
|
||||||
|
## Regression verification
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0012-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Cover canonical-ID, legacy username-sub, missing subject, and suspended-user
|
||||||
|
behavior. Run the KeyCape test suite and image build checks.
|
||||||
|
|
||||||
|
## Deploy and verify OpenBao OIDC
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0012-T03
|
||||||
|
status: progress
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Publish and deploy the corrected KeyCape image, prove `/userinfo` accepts a
|
||||||
|
fresh human access token, then resume the governed Policy Nexus bootstrap.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue