generated from coulomb/repo-seed
feat: implement T05, T08, T13 — OIDC discovery, JWKS, telemetry pipeline
- T05: /.well-known/openid-configuration — profile-only features advertised - T08: /jwks — RS256 JWK Set, stdlib crypto only, key rotation support - T13: Structured telemetry — Event types, LogEmitter/NoopEmitter/MultiEmitter, context helpers 38 server tests pass, go vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
214
src/internal/server/oidc/jwks_test.go
Normal file
214
src/internal/server/oidc/jwks_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"keycape/internal/server/oidc"
|
||||
)
|
||||
|
||||
// generateTestKey creates a fresh RSA-2048 key for tests.
|
||||
func generateTestKey(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func privateKeyToPEM(k *rsa.PrivateKey) []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
})
|
||||
}
|
||||
|
||||
func publicKeyToPEM(k *rsa.PublicKey) []byte {
|
||||
b, _ := x509.MarshalPKIXPublicKey(k)
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: b,
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWKSHandler_ResponseCode(t *testing.T) {
|
||||
key := generateTestKey(t)
|
||||
ks := oidc.NewKeySet()
|
||||
ks.AddKey("kid-1", &key.PublicKey)
|
||||
|
||||
h := oidc.NewJWKSHandler(ks)
|
||||
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSHandler_ContentType(t *testing.T) {
|
||||
key := generateTestKey(t)
|
||||
ks := oidc.NewKeySet()
|
||||
ks.AddKey("kid-1", &key.PublicKey)
|
||||
|
||||
h := oidc.NewJWKSHandler(ks)
|
||||
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSHandler_StructureValid(t *testing.T) {
|
||||
key := generateTestKey(t)
|
||||
ks := oidc.NewKeySet()
|
||||
ks.AddKey("kid-abc", &key.PublicKey)
|
||||
|
||||
h := oidc.NewJWKSHandler(ks)
|
||||
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var doc struct {
|
||||
Keys []map[string]interface{} `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
if len(doc.Keys) != 1 {
|
||||
t.Fatalf("expected 1 key, got %d", len(doc.Keys))
|
||||
}
|
||||
|
||||
k := doc.Keys[0]
|
||||
for _, field := range []string{"kty", "use", "alg", "kid", "n", "e"} {
|
||||
if _, ok := k[field]; !ok {
|
||||
t.Errorf("JWK missing field %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSHandler_CorrectAlgorithmFields(t *testing.T) {
|
||||
key := generateTestKey(t)
|
||||
ks := oidc.NewKeySet()
|
||||
ks.AddKey("my-kid", &key.PublicKey)
|
||||
|
||||
h := oidc.NewJWKSHandler(ks)
|
||||
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var doc struct {
|
||||
Keys []oidc.JWK `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
if len(doc.Keys) != 1 {
|
||||
t.Fatalf("expected 1 key")
|
||||
}
|
||||
jwk := doc.Keys[0]
|
||||
if jwk.Kty != "RSA" {
|
||||
t.Errorf("kty: expected RSA, got %q", jwk.Kty)
|
||||
}
|
||||
if jwk.Use != "sig" {
|
||||
t.Errorf("use: expected sig, got %q", jwk.Use)
|
||||
}
|
||||
if jwk.Alg != "RS256" {
|
||||
t.Errorf("alg: expected RS256, got %q", jwk.Alg)
|
||||
}
|
||||
if jwk.Kid != "my-kid" {
|
||||
t.Errorf("kid: expected my-kid, got %q", jwk.Kid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSHandler_MultipleKeys(t *testing.T) {
|
||||
key1 := generateTestKey(t)
|
||||
key2 := generateTestKey(t)
|
||||
ks := oidc.NewKeySet()
|
||||
ks.AddKey("kid-1", &key1.PublicKey)
|
||||
ks.AddKey("kid-2", &key2.PublicKey)
|
||||
|
||||
h := oidc.NewJWKSHandler(ks)
|
||||
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var doc struct {
|
||||
Keys []oidc.JWK `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
if len(doc.Keys) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(doc.Keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPublicKeyFromPEM_Valid(t *testing.T) {
|
||||
key := generateTestKey(t)
|
||||
pemData := publicKeyToPEM(&key.PublicKey)
|
||||
|
||||
pub, err := oidc.LoadPublicKeyFromPEM(pemData)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPublicKeyFromPEM: %v", err)
|
||||
}
|
||||
if pub.N.Cmp(key.PublicKey.N) != 0 {
|
||||
t.Error("modulus mismatch")
|
||||
}
|
||||
if pub.E != key.PublicKey.E {
|
||||
t.Error("exponent mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPublicKeyFromPEM_InvalidPEM(t *testing.T) {
|
||||
_, err := oidc.LoadPublicKeyFromPEM([]byte("not a pem"))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid PEM, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPublicKeyFromPEM_PrivateKeyRejected(t *testing.T) {
|
||||
key := generateTestKey(t)
|
||||
pemData := privateKeyToPEM(key)
|
||||
|
||||
// A private key PEM should not decode as a public key
|
||||
_, err := oidc.LoadPublicKeyFromPEM(pemData)
|
||||
if err == nil {
|
||||
t.Error("expected error when loading private key as public key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSHandler_NEncoding(t *testing.T) {
|
||||
// Ensure N is base64url (no padding, no +/)
|
||||
key := generateTestKey(t)
|
||||
ks := oidc.NewKeySet()
|
||||
ks.AddKey("k1", &key.PublicKey)
|
||||
|
||||
h := oidc.NewJWKSHandler(ks)
|
||||
req := httptest.NewRequest(http.MethodGet, "/jwks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var doc struct {
|
||||
Keys []oidc.JWK `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
n := doc.Keys[0].N
|
||||
for _, c := range n {
|
||||
if c == '+' || c == '/' || c == '=' {
|
||||
t.Errorf("N contains standard base64 character %q — must be base64url without padding", string(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user