All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Closes the local protocol surface of gap G01 from the scope assessment (KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left four bindings unenforced, and UserInfo verified less than the caller CLI does. Authorization-code path: bind the exchange to the redirect URI the code was issued for, refuse clients whose registration does not permit the grant, and authenticate confidential clients with a digest-based constant-time comparison over the same credential sources as the service grant. An empty grantTypes stays an implicit authorization-code client, matching config validation. Code consumption: SessionStore.Consume reads and deletes under one lock. The previous Get/Delete pair spanned JWT signing, and the added test reproduces the race against that version -- 9 of 16 concurrent exchanges succeeded, and a failed exchange left the code replayable. UserInfo: check the JOSE header algorithm before trusting the signature, require the configured issuer, and require an access token rather than accepting an ID token of the right shape. Purpose is decided on the scope claim so the issued token contract, which consumers pin exactly, does not change. SCOPE.md and the assessment record which bindings are now enforced and that the Authelia upstream-trust assumption remains open, so G01 is not fully closed and no profile-conformance claim is made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
358 lines
10 KiB
Go
358 lines
10 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_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) {
|
|
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)
|
|
|
|
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(),
|
|
"scope": "openid",
|
|
}
|
|
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)
|