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:
2026-03-13 01:35:34 +01:00
parent 329e996619
commit 22f7a7dc50
9 changed files with 1080 additions and 1 deletions

View File

@@ -2,4 +2,13 @@ module keycape
go 1.22
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/rs/zerolog v1.34.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect
)

View File

@@ -1,3 +1,18 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,86 @@
// Package oidc implements OIDC profile endpoints for KeyCape.
// Only profile-supported features are advertised — no implicit flow,
// no dynamic registration, no request objects.
package oidc
import (
"encoding/json"
"net/http"
)
// DiscoveryConfig holds the issuer and endpoint URLs for the discovery document.
// UserinfoEndpoint is optional; if empty it is omitted from the document.
type DiscoveryConfig struct {
Issuer string // e.g. "https://auth.netkingdom.local"
AuthorizationEndpoint string
TokenEndpoint string
JWKSUri string
UserinfoEndpoint string // optional, empty = not advertised
}
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
// Fields are ordered to match common OIDC implementations for readability.
// registration_endpoint is intentionally absent — no dynamic client registration.
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
RequestParameterSupported bool `json:"request_parameter_supported"`
ClaimsParameterSupported bool `json:"claims_parameter_supported"`
}
// discoveryHandler implements http.Handler for GET /.well-known/openid-configuration.
type discoveryHandler struct {
doc []byte
}
// NewDiscoveryHandler returns an http.Handler that serves the OIDC discovery document.
// The document is pre-serialised at construction time so every request is a cheap copy.
func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
d := discoveryDocument{
Issuer: cfg.Issuer,
AuthorizationEndpoint: cfg.AuthorizationEndpoint,
TokenEndpoint: cfg.TokenEndpoint,
JWKSUri: cfg.JWKSUri,
UserinfoEndpoint: cfg.UserinfoEndpoint,
// Profile-locked values — not negotiable.
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code"},
CodeChallengeMethodsSupported: []string{"S256"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
ScopesSupported: []string{"openid", "profile", "email", "groups"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"},
ClaimsSupported: []string{
"sub", "iss", "aud", "exp", "iat",
"preferred_username", "email", "name", "groups", "roles",
},
SubjectTypesSupported: []string{"public"},
RequestParameterSupported: false,
ClaimsParameterSupported: false,
}
b, err := json.Marshal(d)
if err != nil {
// This can only fail if the struct contains un-marshallable types, which it does not.
panic("oidc: failed to marshal discovery document: " + err.Error())
}
return &discoveryHandler{doc: b}
}
func (h *discoveryHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(h.doc)
}

View File

@@ -0,0 +1,314 @@
package oidc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"keycape/internal/server/oidc"
)
func TestDiscoveryHandler_ResponseCode(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
UserinfoEndpoint: "https://auth.netkingdom.local/oauth2/userinfo",
}
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestDiscoveryHandler_ContentType(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", 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 TestDiscoveryHandler_CacheControl(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
cc := w.Header().Get("Cache-Control")
if cc != "max-age=3600" {
t.Errorf("expected Cache-Control max-age=3600, got %q", cc)
}
}
func discoveryDoc(t *testing.T, cfg oidc.DiscoveryConfig) map[string]interface{} {
t.Helper()
h := oidc.NewDiscoveryHandler(cfg)
req := httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var doc map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&doc); err != nil {
t.Fatalf("could not decode JSON: %v", err)
}
return doc
}
func TestDiscoveryHandler_Issuer(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
if doc["issuer"] != cfg.Issuer {
t.Errorf("issuer: expected %q, got %v", cfg.Issuer, doc["issuer"])
}
}
func TestDiscoveryHandler_Endpoints(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
UserinfoEndpoint: "https://auth.netkingdom.local/oauth2/userinfo",
}
doc := discoveryDoc(t, cfg)
checks := map[string]string{
"authorization_endpoint": cfg.AuthorizationEndpoint,
"token_endpoint": cfg.TokenEndpoint,
"jwks_uri": cfg.JWKSUri,
"userinfo_endpoint": cfg.UserinfoEndpoint,
}
for key, want := range checks {
if got, ok := doc[key]; !ok {
t.Errorf("missing %q", key)
} else if got != want {
t.Errorf("%s: expected %q, got %v", key, want, got)
}
}
}
func TestDiscoveryHandler_UserinfoOmittedWhenEmpty(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
// UserinfoEndpoint intentionally empty
}
doc := discoveryDoc(t, cfg)
if _, ok := doc["userinfo_endpoint"]; ok {
t.Error("userinfo_endpoint must be absent when not configured")
}
}
func TestDiscoveryHandler_NoRegistrationEndpoint(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
if _, ok := doc["registration_endpoint"]; ok {
t.Error("registration_endpoint must NOT be present (no dynamic registration)")
}
}
func stringSliceFromDoc(t *testing.T, doc map[string]interface{}, key string) []string {
t.Helper()
raw, ok := doc[key]
if !ok {
t.Fatalf("missing key %q", key)
}
arr, ok := raw.([]interface{})
if !ok {
t.Fatalf("%q: expected array, got %T", key, raw)
}
out := make([]string, len(arr))
for i, v := range arr {
s, ok := v.(string)
if !ok {
t.Fatalf("%q[%d]: expected string, got %T", key, i, v)
}
out[i] = s
}
return out
}
func assertStringSlice(t *testing.T, doc map[string]interface{}, key string, want []string) {
t.Helper()
got := stringSliceFromDoc(t, doc, key)
if len(got) != len(want) {
t.Errorf("%s: expected %v, got %v", key, want, got)
return
}
wantSet := make(map[string]bool)
for _, s := range want {
wantSet[s] = true
}
for _, s := range got {
if !wantSet[s] {
t.Errorf("%s: unexpected value %q (got %v, want %v)", key, s, got, want)
}
}
}
func TestDiscoveryHandler_ResponseTypes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "response_types_supported", []string{"code"})
}
func TestDiscoveryHandler_GrantTypes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "grant_types_supported", []string{"authorization_code"})
}
func TestDiscoveryHandler_CodeChallengeMethod(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "code_challenge_methods_supported", []string{"S256"})
}
func TestDiscoveryHandler_SigningAlg(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "id_token_signing_alg_values_supported", []string{"RS256"})
}
func TestDiscoveryHandler_Scopes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "scopes_supported", []string{"openid", "profile", "email", "groups"})
}
func TestDiscoveryHandler_TokenEndpointAuthMethods(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "token_endpoint_auth_methods_supported",
[]string{"client_secret_basic", "client_secret_post", "none"})
}
func TestDiscoveryHandler_Claims(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "claims_supported",
[]string{"sub", "iss", "aud", "exp", "iat", "preferred_username", "email", "name", "groups", "roles"})
}
func TestDiscoveryHandler_SubjectTypes(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
assertStringSlice(t, doc, "subject_types_supported", []string{"public"})
}
func TestDiscoveryHandler_RequestParameterNotSupported(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
v, ok := doc["request_parameter_supported"]
if !ok {
t.Fatal("request_parameter_supported must be present")
}
if b, ok := v.(bool); !ok || b {
t.Errorf("request_parameter_supported: expected false, got %v", v)
}
}
func TestDiscoveryHandler_ClaimsParameterNotSupported(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
v, ok := doc["claims_parameter_supported"]
if !ok {
t.Fatal("claims_parameter_supported must be present")
}
if b, ok := v.(bool); !ok || b {
t.Errorf("claims_parameter_supported: expected false, got %v", v)
}
}

View File

@@ -0,0 +1,123 @@
package oidc
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net/http"
)
// JWK represents a single JSON Web Key for an RSA public key.
// Only fields required for RS256 signature verification are included.
type JWK struct {
Kty string `json:"kty"` // "RSA"
Use string `json:"use"` // "sig"
Alg string `json:"alg"` // "RS256"
Kid string `json:"kid"` // key identifier
N string `json:"n"` // base64url-encoded modulus (no padding)
E string `json:"e"` // base64url-encoded public exponent (no padding)
}
// keyEntry pairs a kid with the corresponding public key.
type keyEntry struct {
kid string
pub *rsa.PublicKey
}
// KeySet holds one or more RSA public keys for JWKS rotation.
// Keys are served in insertion order.
type KeySet struct {
entries []keyEntry
}
// NewKeySet returns an empty KeySet ready for AddKey calls.
func NewKeySet() *KeySet {
return &KeySet{}
}
// AddKey appends an RSA public key with the given key ID.
// kid must be unique within the set; duplicates are not checked.
func (ks *KeySet) AddKey(kid string, pub *rsa.PublicKey) {
ks.entries = append(ks.entries, keyEntry{kid: kid, pub: pub})
}
// jwkFromPublicKey encodes an RSA public key as a JWK using base64url (no padding).
func jwkFromPublicKey(kid string, pub *rsa.PublicKey) JWK {
enc := base64.RawURLEncoding
// Modulus — big-endian bytes, no leading zero (math/big ensures minimal encoding).
nBytes := pub.N.Bytes()
// Exponent — big-endian minimal encoding.
exp := big.NewInt(int64(pub.E))
eBytes := exp.Bytes()
return JWK{
Kty: "RSA",
Use: "sig",
Alg: "RS256",
Kid: kid,
N: enc.EncodeToString(nBytes),
E: enc.EncodeToString(eBytes),
}
}
// jwksResponse is the top-level JWK Set object.
type jwksResponse struct {
Keys []JWK `json:"keys"`
}
// jwksHandler implements http.Handler for GET /jwks.
type jwksHandler struct {
ks *KeySet
}
// NewJWKSHandler returns an http.Handler that serves the JWK Set.
// The key set is serialised on every request so key rotation can be supported
// by mutating the KeySet before the next request (safe for construction-time use;
// for live rotation a RWMutex should wrap AddKey).
func NewJWKSHandler(ks *KeySet) http.Handler {
return &jwksHandler{ks: ks}
}
func (h *jwksHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
jwks := jwksResponse{Keys: make([]JWK, 0, len(h.ks.entries))}
for _, e := range h.ks.entries {
jwks.Keys = append(jwks.Keys, jwkFromPublicKey(e.kid, e.pub))
}
b, err := json.Marshal(jwks)
if err != nil {
http.Error(w, "internal error encoding JWKS", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// LoadPublicKeyFromPEM parses a PEM-encoded public key (PKIX / "PUBLIC KEY" block).
// Returns an error if the PEM data is invalid or does not contain an RSA public key.
func LoadPublicKeyFromPEM(pemData []byte) (*rsa.PublicKey, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("jwks: no PEM block found in input")
}
if block.Type != "PUBLIC KEY" {
return nil, errors.New("jwks: expected PEM block type \"PUBLIC KEY\", got \"" + block.Type + "\"")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, errors.New("jwks: failed to parse PKIX public key: " + err.Error())
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("jwks: key is not an RSA public key")
}
return rsaPub, nil
}

View 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))
}
}
}

View File

@@ -0,0 +1,104 @@
package telemetry
import (
"context"
"github.com/rs/zerolog"
)
// Emitter is the interface all telemetry backends implement.
// Emit is called on every auth and error code path — there are no silent paths.
// Implementations must be safe for concurrent use.
type Emitter interface {
Emit(ctx context.Context, event Event)
}
// contextKey is an unexported type for the emitter context key to avoid collisions.
type contextKey struct{}
// WithEmitter returns a new context carrying the given Emitter.
func WithEmitter(ctx context.Context, e Emitter) context.Context {
return context.WithValue(ctx, contextKey{}, e)
}
// EmitterFromContext retrieves the Emitter from the context.
// If no emitter is stored it returns a NoopEmitter so callers never receive nil.
func EmitterFromContext(ctx context.Context) Emitter {
if e, ok := ctx.Value(contextKey{}).(Emitter); ok && e != nil {
return e
}
return NoopEmitter{}
}
// ---------------------------------------------------------------------------
// NoopEmitter
// ---------------------------------------------------------------------------
// NoopEmitter discards every event. Useful in tests and as a safe default.
type NoopEmitter struct{}
// Emit does nothing.
func (NoopEmitter) Emit(_ context.Context, _ Event) {}
// ---------------------------------------------------------------------------
// LogEmitter
// ---------------------------------------------------------------------------
// LogEmitter writes each event as a JSON log line using zerolog.
type LogEmitter struct {
log zerolog.Logger
}
// NewLogEmitter returns a LogEmitter backed by the given zerolog.Logger.
func NewLogEmitter(log zerolog.Logger) *LogEmitter {
return &LogEmitter{log: log}
}
// Emit writes the event fields as a zerolog info event.
func (l *LogEmitter) Emit(_ context.Context, ev Event) {
e := l.log.Info().
Str("event_type", string(ev.EventType)).
Str("client_id", ev.ClientID).
Str("endpoint", ev.Endpoint).
Str("result", ev.Result).
Str("environment", ev.Environment).
Str("trace_id", ev.TraceID).
Time("timestamp", ev.Timestamp)
if ev.Feature != "" {
e = e.Str("feature", ev.Feature)
}
if ev.ErrorType != "" {
e = e.Str("error_type", ev.ErrorType)
}
if len(ev.Scopes) > 0 {
e = e.Strs("scopes", ev.Scopes)
}
if ev.GrantType != "" {
e = e.Str("grant_type", ev.GrantType)
}
e.Msg("")
}
// ---------------------------------------------------------------------------
// MultiEmitter
// ---------------------------------------------------------------------------
// MultiEmitter fans an event out to multiple Emitter implementations.
// Useful for emitting to both a log and a metrics backend simultaneously.
type MultiEmitter struct {
emitters []Emitter
}
// NewMultiEmitter returns a MultiEmitter that broadcasts to all provided emitters.
func NewMultiEmitter(emitters ...Emitter) *MultiEmitter {
return &MultiEmitter{emitters: emitters}
}
// Emit calls Emit on each contained emitter in order.
func (m *MultiEmitter) Emit(ctx context.Context, ev Event) {
for _, e := range m.emitters {
e.Emit(ctx, ev)
}
}

View File

@@ -0,0 +1,179 @@
package telemetry_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"time"
"github.com/rs/zerolog"
"keycape/internal/server/telemetry"
)
func sampleEvent() telemetry.Event {
return telemetry.Event{
Timestamp: time.Now().UTC(),
ClientID: "test-client",
Endpoint: "/oauth2/token",
Feature: "",
Result: "success",
ErrorType: "",
Scopes: []string{"openid", "profile"},
GrantType: "authorization_code",
Environment: "test",
TraceID: "trace-abc-123",
EventType: telemetry.EventTokenIssued,
}
}
// ---- NoopEmitter ----
func TestNoopEmitter_DoesNotPanic(t *testing.T) {
e := telemetry.NoopEmitter{}
e.Emit(context.Background(), sampleEvent())
}
func TestNoopEmitter_ImplementsInterface(t *testing.T) {
var _ telemetry.Emitter = telemetry.NoopEmitter{}
}
// ---- LogEmitter ----
func TestLogEmitter_WritesJSON(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
e := telemetry.NewLogEmitter(logger)
ev := sampleEvent()
e.Emit(context.Background(), ev)
if buf.Len() == 0 {
t.Fatal("expected output from LogEmitter, got nothing")
}
var out map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("LogEmitter output is not valid JSON: %v\noutput: %s", err, buf.String())
}
}
func TestLogEmitter_ContainsEventFields(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
e := telemetry.NewLogEmitter(logger)
ev := sampleEvent()
e.Emit(context.Background(), ev)
var out map[string]interface{}
_ = json.Unmarshal(buf.Bytes(), &out)
requiredFields := []string{"client_id", "endpoint", "result", "environment", "trace_id", "event_type"}
for _, f := range requiredFields {
if _, ok := out[f]; !ok {
t.Errorf("LogEmitter output missing field %q", f)
}
}
}
func TestLogEmitter_EventTypeValue(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
e := telemetry.NewLogEmitter(logger)
ev := sampleEvent()
ev.EventType = telemetry.EventAuthFailure
e.Emit(context.Background(), ev)
var out map[string]interface{}
_ = json.Unmarshal(buf.Bytes(), &out)
if out["event_type"] != string(telemetry.EventAuthFailure) {
t.Errorf("event_type: expected %q, got %v", telemetry.EventAuthFailure, out["event_type"])
}
}
func TestLogEmitter_ImplementsInterface(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
var _ telemetry.Emitter = telemetry.NewLogEmitter(logger)
}
// ---- MultiEmitter ----
type capturingEmitter struct {
events []telemetry.Event
}
func (c *capturingEmitter) Emit(_ context.Context, ev telemetry.Event) {
c.events = append(c.events, ev)
}
func TestMultiEmitter_FansOut(t *testing.T) {
a := &capturingEmitter{}
b := &capturingEmitter{}
m := telemetry.NewMultiEmitter(a, b)
ev := sampleEvent()
m.Emit(context.Background(), ev)
if len(a.events) != 1 {
t.Errorf("emitter a: expected 1 event, got %d", len(a.events))
}
if len(b.events) != 1 {
t.Errorf("emitter b: expected 1 event, got %d", len(b.events))
}
}
func TestMultiEmitter_EmptyIsNoop(t *testing.T) {
m := telemetry.NewMultiEmitter()
m.Emit(context.Background(), sampleEvent()) // must not panic
}
func TestMultiEmitter_ImplementsInterface(t *testing.T) {
var _ telemetry.Emitter = telemetry.NewMultiEmitter()
}
// ---- Context helpers ----
func TestWithEmitter_RoundTrip(t *testing.T) {
orig := telemetry.NoopEmitter{}
ctx := telemetry.WithEmitter(context.Background(), orig)
got := telemetry.EmitterFromContext(ctx)
if got == nil {
t.Fatal("EmitterFromContext returned nil after WithEmitter")
}
}
func TestEmitterFromContext_FallsBackToNoop(t *testing.T) {
got := telemetry.EmitterFromContext(context.Background())
if got == nil {
t.Fatal("EmitterFromContext must never return nil — fallback to NoopEmitter expected")
}
// Verify the fallback doesn't panic
got.Emit(context.Background(), sampleEvent())
}
// ---- EventType constants ----
func TestEventTypeConstants(t *testing.T) {
cases := []struct {
et telemetry.EventType
want string
}{
{telemetry.EventAuthStart, "auth_start"},
{telemetry.EventAuthSuccess, "auth_success"},
{telemetry.EventAuthFailure, "auth_failure"},
{telemetry.EventTokenIssued, "token_issued"},
{telemetry.EventUnsupportedFeature, "unsupported_feature"},
{telemetry.EventInvalidRequest, "invalid_request"},
{telemetry.EventMigration, "migration_event"},
}
for _, c := range cases {
if string(c.et) != c.want {
t.Errorf("EventType %q: expected %q", c.et, c.want)
}
}
}

View File

@@ -0,0 +1,35 @@
// Package telemetry implements the KeyCape telemetry pipeline (spec §6).
// Every auth and error code path MUST call Emit — there are no silent paths.
package telemetry
import "time"
// EventType identifies the category of a telemetry event.
type EventType string
const (
EventAuthStart EventType = "auth_start"
EventAuthSuccess EventType = "auth_success"
EventAuthFailure EventType = "auth_failure"
EventTokenIssued EventType = "token_issued"
EventUnsupportedFeature EventType = "unsupported_feature"
EventInvalidRequest EventType = "invalid_request"
EventMigration EventType = "migration_event"
)
// Event carries all required telemetry fields from spec §6.2.
// Timestamp, Environment, TraceID, ClientID, Endpoint, Result, and EventType
// are mandatory for every event; other fields are conditional on context.
type Event struct {
Timestamp time.Time `json:"timestamp"`
ClientID string `json:"client_id"`
Endpoint string `json:"endpoint"`
Feature string `json:"feature,omitempty"`
Result string `json:"result"` // "success" | "failure"
ErrorType string `json:"error_type,omitempty"`
Scopes []string `json:"scopes,omitempty"`
GrantType string `json:"grant_type,omitempty"`
Environment string `json:"environment"`
TraceID string `json:"trace_id"`
EventType EventType `json:"event_type"`
}