key-cape/src/internal/authclient/client_test.go
tegwick 8707d375a2
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
Record what the consolidated verifier's tests actually establish
KEY-WP-0019-T05 rested on "existing tests pass unchanged", which shows the move
onto internal/jose preserved behaviour but not that the behaviour is checked.
Disabling the RSA comparison in jose.Verify fails both callers' suites, so the
shared verifier is load-bearing on each path.

Correct the comment on the caller-side cases added with the move. It claimed the
existing tamper case fails on the signature segment's shape before any key is
used; it does not — appending eight characters leaves a decodable segment, so
that case does reach and does check the signature. The two new cases are still
worth their place for what a byte-level tamper cannot reach: a structurally valid
token signed by an unpublished key under a published kid tests that key selection
is bound to the key set, and an undersized modulus in the published set tests
that ParseJWKS strictness denies rather than falling through to the claims.

Record both in the workplan and in G01's status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 867844@bnt-lap001
Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 09:05:16 +02:00

321 lines
11 KiB
Go

package authclient
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
type users struct{}
func (users) LookupUser(context.Context, string) (*domain.User, error) {
return &domain.User{ID: "user:test", Username: "test"}, nil
}
func (users) LookupGroups(context.Context, string) ([]domain.Group, error) { return nil, nil }
func (users) ValidatePassword(context.Context, string, string) (bool, error) { return true, nil }
func (users) ListUsers(context.Context) ([]domain.User, error) { return nil, nil }
func provider(t *testing.T) (*Client, Discovery, *oidc.TokenHandler) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
server := httptest.NewTLSServer(mux)
t.Cleanup(server.Close)
sessions := oidc.NewSessionStore()
h := &oidc.TokenHandler{Issuer: server.URL, SigningKey: key, TokenLifetime: 15 * time.Minute, Sessions: sessions, Users: users{}, Emitter: telemetry.NoopEmitter{}, ClientConfig: map[string]*domain.Client{
"service:consumer": {ClientID: "service:consumer", ClientType: "confidential", ClientSecret: "special+%: secret", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"approval:read"}, Audience: "approval-engine", ServiceSubject: "service:test", Tenant: "tenant:test"},
"human": {ClientID: "human", AllowedScopes: []string{"openid", "approval:approve"}, Audience: "approval-engine"},
}}
mux.Handle("/token", h)
keys := oidc.NewKeySet()
keys.AddKey("key-1", &key.PublicKey)
mux.Handle("/jwks", oidc.NewJWKSHandler(keys))
d := Discovery{Issuer: server.URL, Authorization: server.URL + "/authorize", Token: server.URL + "/token", JWKS: server.URL + "/jwks"}
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(d) })
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// Record the redirect URI as the real /authorize does: the token
// endpoint binds the exchange to it (KEY-WP-0016-T02).
code := sessions.Create(&oidc.PKCESession{ClientID: q.Get("client_id"), Username: "test", Nonce: q.Get("nonce"), Scopes: strings.Fields(q.Get("scope")), PKCEChallenge: q.Get("code_challenge"), RedirectURI: q.Get("redirect_uri"), ExpiresAt: time.Now().Add(time.Minute)})
target, _ := url.Parse(q.Get("redirect_uri"))
params := target.Query()
params.Set("state", q.Get("state"))
params.Set("code", code)
target.RawQuery = params.Encode()
http.Redirect(w, r, target.String(), 302)
})
c, err := New(server.URL)
if err != nil {
t.Fatal(err)
}
c.HTTP.Transport = server.Client().Transport
return c, d, h
}
func TestServiceExchangeAndClaimValidation(t *testing.T) {
c, _, h := provider(t)
ctx := context.Background()
d, err := c.Discover(ctx)
if err != nil {
t.Fatal(err)
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
token, err := c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", "")
if err != nil {
t.Fatal(err)
}
if _, err = c.Verify(ctx, d, token.AccessToken, "other", ""); err == nil {
t.Fatal("wrong audience accepted")
}
if _, err = c.Verify(ctx, d, token.AccessToken+"tampered", "approval-engine", ""); err == nil {
t.Fatal("tampering accepted")
}
if _, err = c.Exchange(ctx, d, form, "service:consumer", "wrong", "approval-engine", ""); err == nil || strings.Contains(err.Error(), "special") {
t.Fatal("wrong secret not safely rejected")
}
form.Set("scope", "approval:consume")
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
t.Fatal("excess scope accepted")
}
form.Set("scope", "approval:read")
h.TokenLifetime = -time.Minute
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
t.Fatal("expired response accepted")
}
}
type urlWriter struct{ urls chan string }
func (w urlWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(string(p), "\n") {
if strings.HasPrefix(line, "https://") {
w.urls <- line
}
}
return len(p), nil
}
func TestBrowserLoginPKCEAndState(t *testing.T) {
c, d, _ := provider(t)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
redirect := "http://" + listener.Addr().String() + "/callback"
listener.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
urls := make(chan string, 1)
completed := make(chan error, 1)
go func() {
_, err := c.Login(ctx, d, "human", "approval-engine", "openid approval:approve", redirect, urlWriter{urls})
completed <- err
}()
var address string
select {
case address = <-urls:
case err := <-completed:
t.Fatal(err)
case <-ctx.Done():
t.Fatal("no login URL")
}
// A forged callback must not consume the real login attempt.
res, err := http.Get(redirect + "?state=forged&code=forged")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 400 {
t.Fatal("forged state accepted")
}
browser := &http.Client{Transport: c.HTTP.Transport, Timeout: 5 * time.Second}
res, err = browser.Get(address)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
select {
case err := <-completed:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("login did not finish")
}
}
func TestLoginRejectsUnsafeCallbacks(t *testing.T) {
c, d, _ := provider(t)
for _, callback := range []string{"http://example.com:8000/callback", "http://localhost:8000/callback", "http://127.0.0.1:0/callback", "http://127.0.0.1:8000/callback?extra=yes"} {
if _, err := c.Login(context.Background(), d, "human", "approval-engine", "openid", callback, io.Discard); err == nil {
t.Fatalf("accepted %s", callback)
}
}
}
func TestOutputProtection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "token.json")
file, err := reserveOutput(path)
if err != nil {
t.Fatalf("reserve %s: %v", path, err)
}
file.Close()
info, _ := os.Stat(path)
if info.Mode().Perm() != 0600 {
t.Fatal("file not private")
}
if _, err = reserveOutput(path); err == nil {
t.Fatal("overwrote existing file")
}
link := filepath.Join(dir, "link")
if err = os.Symlink(path, link); err != nil {
t.Fatal(err)
}
if _, err = reserveOutput(link); err == nil {
t.Fatal("followed output symlink")
}
repo := filepath.Join(dir, "repo")
os.Mkdir(repo, 0700)
os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: elsewhere"), 0600)
if _, err = reserveOutput(filepath.Join(repo, "token")); err == nil {
t.Fatal("allowed token in worktree")
}
}
func TestDiscoveryAndRedirectBoundaries(t *testing.T) {
for _, issuer := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?secret=value"} {
if _, err := New(issuer); err == nil {
t.Fatal("unsafe issuer accepted")
}
}
mux := http.NewServeMux()
server := httptest.NewTLSServer(mux)
defer server.Close()
c, _ := New(server.URL)
c.HTTP.Transport = server.Client().Transport
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":"https://evil.example/a","token_endpoint":"https://evil.example/t","jwks_uri":"https://evil.example/j"}`, server.URL)
})
if _, err := c.Discover(context.Background()); err == nil {
t.Fatal("cross-origin discovery accepted")
}
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://evil.example", 307) })
var out any
if err := c.request(context.Background(), "POST", server.URL+"/redirect", nil, "id", "secret", &out); err == nil {
t.Fatal("followed credential redirect")
}
}
func TestNonceMismatchAndCancelledLogin(t *testing.T) {
c, d, _ := provider(t)
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
token, err := c.Exchange(context.Background(), d, form, "service:consumer", "special+%: secret", "approval-engine", "")
if err != nil {
t.Fatal(err)
}
if _, err = c.Verify(context.Background(), d, token.AccessToken, "approval-engine", "required-nonce"); err == nil {
t.Fatal("missing nonce accepted")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
redirect := "http://" + listener.Addr().String() + "/callback"
listener.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err = c.Login(ctx, d, "human", "approval-engine", "openid", redirect, io.Discard); err == nil {
t.Fatal("cancelled login succeeded")
}
listener, err = net.Listen("tcp", strings.TrimPrefix(strings.TrimSuffix(redirect, "/callback"), "http://"))
if err != nil {
t.Fatal("listener not released")
}
listener.Close()
}
// The caller-side verifier moved onto internal/jose (KEY-WP-0019-T05). The
// existing tamper case proves the signature is checked at all; these two prove
// the parts of the move that a byte-level tamper cannot reach. A token signed
// by an unpublished key under a kid the provider does publish fails only if key
// selection is bound to the published set, not merely if the bytes are intact.
// A key set carrying an undersized modulus must deny the login rather than fall
// through to the claims, which is ParseJWKS strictness being applied on this
// path and not only in the adapter.
func TestVerifyRejectsForeignSignatureAndUnusableKeySet(t *testing.T) {
c, d, _ := provider(t)
ctx := context.Background()
foreign, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
now := time.Now()
forged := signToken(t, foreign, "key-1", map[string]any{
"iss": c.Issuer,
"aud": "approval-engine",
"sub": "service:test",
"iat": now.Unix(),
"exp": now.Add(time.Minute).Unix(),
})
if _, err := c.Verify(ctx, d, forged, "approval-engine", ""); err == nil {
t.Fatal("token signed by an unpublished key accepted")
}
// A key set the client cannot use must deny, not fall through to the
// claims. The token here is otherwise genuine.
genuine := signToken(t, foreign, "key-1", map[string]any{
"iss": c.Issuer, "aud": "approval-engine", "sub": "service:test",
"iat": now.Unix(), "exp": now.Add(time.Minute).Unix(),
})
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"keys":[{"kty":"RSA","kid":"key-1","n":"AQAB","e":"AQAB"}]}`)
}))
t.Cleanup(broken.Close)
if _, err := c.Verify(ctx, Discovery{Issuer: d.Issuer, JWKS: broken.URL}, genuine, "approval-engine", ""); err == nil {
t.Fatal("undersized key in the published set accepted")
}
}
// signToken builds an RS256 JWT with the given kid and claims.
func signToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string {
t.Helper()
enc := func(v any) string {
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
input := enc(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}) + "." + enc(claims)
digest := sha256.Sum256([]byte(input))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return input + "." + base64.RawURLEncoding.EncodeToString(signature)
}