From 22f7a7dc50db9e52e037a8bd7a5bb8a9bdb158f4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 13 Mar 2026 01:35:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20T05,=20T08,=20T13=20?= =?UTF-8?q?=E2=80=94=20OIDC=20discovery,=20JWKS,=20telemetry=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/go.mod | 11 +- src/go.sum | 15 + src/internal/server/oidc/discovery.go | 86 +++++ src/internal/server/oidc/discovery_test.go | 314 ++++++++++++++++++ src/internal/server/oidc/jwks.go | 123 +++++++ src/internal/server/oidc/jwks_test.go | 214 ++++++++++++ src/internal/server/telemetry/emitter.go | 104 ++++++ src/internal/server/telemetry/emitter_test.go | 179 ++++++++++ src/internal/server/telemetry/events.go | 35 ++ 9 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 src/internal/server/oidc/discovery.go create mode 100644 src/internal/server/oidc/discovery_test.go create mode 100644 src/internal/server/oidc/jwks.go create mode 100644 src/internal/server/oidc/jwks_test.go create mode 100644 src/internal/server/telemetry/emitter.go create mode 100644 src/internal/server/telemetry/emitter_test.go create mode 100644 src/internal/server/telemetry/events.go diff --git a/src/go.mod b/src/go.mod index 01b6073..d84f4aa 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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 +) diff --git a/src/go.sum b/src/go.sum index a62c313..ae93fc4 100644 --- a/src/go.sum +++ b/src/go.sum @@ -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= diff --git a/src/internal/server/oidc/discovery.go b/src/internal/server/oidc/discovery.go new file mode 100644 index 0000000..3960195 --- /dev/null +++ b/src/internal/server/oidc/discovery.go @@ -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) +} diff --git a/src/internal/server/oidc/discovery_test.go b/src/internal/server/oidc/discovery_test.go new file mode 100644 index 0000000..4228a57 --- /dev/null +++ b/src/internal/server/oidc/discovery_test.go @@ -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) + } +} diff --git a/src/internal/server/oidc/jwks.go b/src/internal/server/oidc/jwks.go new file mode 100644 index 0000000..e74bd2e --- /dev/null +++ b/src/internal/server/oidc/jwks.go @@ -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 +} diff --git a/src/internal/server/oidc/jwks_test.go b/src/internal/server/oidc/jwks_test.go new file mode 100644 index 0000000..1fd53f1 --- /dev/null +++ b/src/internal/server/oidc/jwks_test.go @@ -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)) + } + } +} diff --git a/src/internal/server/telemetry/emitter.go b/src/internal/server/telemetry/emitter.go new file mode 100644 index 0000000..54edcb5 --- /dev/null +++ b/src/internal/server/telemetry/emitter.go @@ -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) + } +} diff --git a/src/internal/server/telemetry/emitter_test.go b/src/internal/server/telemetry/emitter_test.go new file mode 100644 index 0000000..3d64e3d --- /dev/null +++ b/src/internal/server/telemetry/emitter_test.go @@ -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) + } + } +} diff --git a/src/internal/server/telemetry/events.go b/src/internal/server/telemetry/events.go new file mode 100644 index 0000000..ba264db --- /dev/null +++ b/src/internal/server/telemetry/events.go @@ -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"` +}