generated from coulomb/repo-seed
- 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>
308 lines
8.5 KiB
Go
308 lines
8.5 KiB
Go
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)
|