feat: implement T09, T15, T21 — userinfo endpoint, LLDAP export, negative tests

- T09: /userinfo with RS256 JWT validation, scope-filtered claims
- T15: LLDAP→canonical export tool with validation, migration_event telemetry
- T21: Negative test suite (Scenario D) — all 7 unsupported features verified

All go tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 02:08:03 +01:00
parent 4097a7de8b
commit 3ee8090a98
9 changed files with 1156 additions and 2 deletions

View File

@@ -44,6 +44,14 @@ func (m *mockUserRepo) ValidatePassword(_ context.Context, _, _ string) (bool, e
return false, nil
}
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
}
// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,185 @@
package oidc
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// UserinfoHandler implements GET /userinfo (OIDC Core §5.3).
//
// The endpoint validates the Bearer token, extracts the subject, looks up
// 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
SigningKey *rsa.PublicKey // used to verify the incoming access token
Issuer string
Emitter telemetry.Emitter
}
// ServeHTTP handles GET /userinfo.
func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Extract Bearer token.
tokenStr, ok := bearerToken(r)
if !ok {
http.Error(w, `{"error":"missing_token","description":"Authorization: Bearer <token> required"}`, http.StatusUnauthorized)
return
}
// 2. Validate token (signature + expiry) and extract claims.
claims, err := validateJWT(tokenStr, h.SigningKey)
if err != nil {
http.Error(w, `{"error":"invalid_token","description":"token validation failed"}`, http.StatusUnauthorized)
return
}
// 3. Extract sub claim (which is the username in our model).
sub, _ := claims["sub"].(string)
if sub == "" {
http.Error(w, `{"error":"invalid_token","description":"missing sub claim"}`, http.StatusUnauthorized)
return
}
// 4. Look up user by sub (sub IS the username per spec §3.1).
user, err := h.Users.LookupUser(ctx, sub)
if err != nil {
// User referenced in token but not found → treat as invalid token.
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
return
}
// 5. Build response claims filtered by the scopes embedded in the token.
scopeStr, _ := claims["scope"].(string)
scopeSet := parseScopeSet(scopeStr)
resp := map[string]interface{}{
"sub": sub,
}
if scopeSet["profile"] {
resp["preferred_username"] = user.Username
resp["name"] = user.DisplayName
}
if scopeSet["email"] {
resp["email"] = user.Email
}
if scopeSet["groups"] {
resp["groups"] = user.Groups
}
// 6. Emit telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
Endpoint: "/userinfo",
Result: "success",
})
// 7. Write JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
// ---------------------------------------------------------------------------
// JWT validation (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
// validateJWT parses and validates a JWT signed with RS256.
// It checks the signature using pubKey and verifies the exp claim.
// Returns the parsed claims on success.
func validateJWT(tokenStr string, pubKey *rsa.PublicKey) (map[string]interface{}, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, errors.New("malformed JWT: expected 3 parts")
}
// Verify signature over header.payload.
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errors.New("malformed JWT: invalid signature encoding")
}
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes); err != nil {
return nil, errors.New("JWT signature verification failed")
}
// Decode payload.
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, errors.New("malformed JWT: invalid payload encoding")
}
var claims map[string]interface{}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, errors.New("malformed JWT: payload is not valid JSON")
}
// Check exp claim.
exp, ok := claims["exp"].(float64)
if !ok {
return nil, errors.New("JWT missing exp claim")
}
if time.Now().Unix() > int64(exp) {
return nil, errors.New("JWT has expired")
}
return claims, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// bearerToken extracts the token from the Authorization header.
// Returns ("", false) when the header is missing or not a Bearer token.
func bearerToken(r *http.Request) (string, bool) {
hdr := r.Header.Get("Authorization")
if hdr == "" {
return "", false
}
const prefix = "Bearer "
if !strings.HasPrefix(hdr, prefix) {
return "", false
}
tok := strings.TrimSpace(hdr[len(prefix):])
if tok == "" {
return "", false
}
return tok, true
}
// parseScopeSet converts a space-separated scope string to a set.
func parseScopeSet(scope string) map[string]bool {
set := make(map[string]bool)
for _, s := range strings.Fields(scope) {
set[s] = true
}
return set
}
// ---------------------------------------------------------------------------
// BuildJWT — exported for test helpers
// ---------------------------------------------------------------------------
// BuildJWT is an exported wrapper around the internal buildJWT function so
// that tests in the oidc_test package can construct valid tokens for the
// UserinfoHandler without importing an external JWT library.
func BuildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
return buildJWT(claims, kid, key)
}

View File

@@ -0,0 +1,307 @@
package oidc_test
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func newUserinfoHandler(t *testing.T, users domain.UserRepository) (*oidc.UserinfoHandler, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate RSA key: %v", err)
}
capture := &captureEmitter{}
h := &oidc.UserinfoHandler{
Users: users,
SigningKey: &key.PublicKey,
Issuer: "https://auth.netkingdom.local",
Emitter: capture,
}
return h, key
}
// buildToken builds and signs a JWT with the given claims using the private key.
func buildToken(t *testing.T, claims map[string]interface{}, key *rsa.PrivateKey) string {
t.Helper()
tok, err := oidc.BuildJWT(claims, "key-1", key)
if err != nil {
t.Fatalf("buildToken: %v", err)
}
return tok
}
func userinfoRequest(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/userinfo", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req
}
func decodeUserinfoClaims(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("decode userinfo response: %v (body: %q)", err, body)
}
return m
}
// ---------------------------------------------------------------------------
// T09 — Userinfo Endpoint Tests
// ---------------------------------------------------------------------------
func TestUserinfoHandler_ValidToken_ReturnsClaims(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{
"user-alice": aliceUser(), // LookupUser by sub (= user.ID)
"alice": aliceUser(), // also by username
}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"aud": "test-client",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid profile email groups",
}
token := buildToken(t, claims, 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())
}
ct := w.Header().Get("Content-Type")
if ct == "" {
t.Error("Content-Type must be set")
}
resp := decodeUserinfoClaims(t, w.Body.String())
if resp["sub"] != "alice" {
t.Errorf("sub: expected alice, got %v", resp["sub"])
}
}
func TestUserinfoHandler_MissingAuthorization_Returns401(t *testing.T) {
users := &mockUserRepo{}
h, _ := newUserinfoHandler(t, users)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(""))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestUserinfoHandler_ExpiredToken_Returns401(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"aud": "test-client",
"exp": now.Add(-5 * time.Minute).Unix(), // already expired
"iat": now.Add(-10 * time.Minute).Unix(),
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for expired token, got %d", w.Code)
}
}
func TestUserinfoHandler_InvalidSignature_Returns401(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, _ := newUserinfoHandler(t, users) // handler uses key1.Public
// Sign with a DIFFERENT key
wrongKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate wrong key: %v", err)
}
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
}
token := buildToken(t, claims, wrongKey)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for invalid signature, got %d", w.Code)
}
}
func TestUserinfoHandler_WithEmailScope_EmailPresent(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid email",
}
token := buildToken(t, claims, 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["email"] != "alice@example.com" {
t.Errorf("email: expected alice@example.com, got %v", resp["email"])
}
}
func TestUserinfoHandler_WithoutEmailScope_EmailAbsent(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid profile", // no email scope
}
token := buildToken(t, claims, 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 _, ok := resp["email"]; ok {
t.Error("email must be absent when email scope is not present in token")
}
}
func TestUserinfoHandler_WithProfileScope_UsernamePresent(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
"scope": "openid profile",
}
token := buildToken(t, claims, 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["preferred_username"] != "alice" {
t.Errorf("preferred_username: expected alice, got %v", resp["preferred_username"])
}
}
func TestUserinfoHandler_EmitsTelemetry(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
key, _ := rsa.GenerateKey(rand.Reader, 2048)
capture := &captureEmitter{}
h := &oidc.UserinfoHandler{
Users: users,
SigningKey: &key.PublicKey,
Issuer: "https://auth.netkingdom.local",
Emitter: capture,
}
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
}
token, _ := oidc.BuildJWT(claims, "key-1", key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
found := false
for _, ev := range capture.events {
if ev.EventType == telemetry.EventAuthSuccess && ev.Endpoint == "/userinfo" {
found = true
break
}
}
if !found {
t.Error("expected auth_success telemetry event for /userinfo")
}
}
// Ensure mockUserRepo also satisfies the extended interface with ListUsers.
func TestUserinfoHandler_UserNotFound_Returns401(t *testing.T) {
users := &mockUserRepo{users: map[string]*domain.User{}} // empty — no alice
h, key := newUserinfoHandler(t, users)
now := time.Now()
claims := map[string]interface{}{
"iss": "https://auth.netkingdom.local",
"sub": "alice",
"exp": now.Add(10 * time.Minute).Unix(),
"iat": now.Unix(),
}
token := buildToken(t, claims, key)
w := httptest.NewRecorder()
h.ServeHTTP(w, userinfoRequest(token))
// user not found → treat as 401 (token references unknown user)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 when user not found, got %d", w.Code)
}
}
// Compile-time check: mockUserRepo satisfies domain.UserRepository (including ListUsers).
var _ domain.UserRepository = (*mockUserRepo)(nil)