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:
11
src/go.mod
11
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
|
||||
)
|
||||
|
||||
15
src/go.sum
15
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=
|
||||
|
||||
86
src/internal/server/oidc/discovery.go
Normal file
86
src/internal/server/oidc/discovery.go
Normal 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)
|
||||
}
|
||||
314
src/internal/server/oidc/discovery_test.go
Normal file
314
src/internal/server/oidc/discovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
123
src/internal/server/oidc/jwks.go
Normal file
123
src/internal/server/oidc/jwks.go
Normal 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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/internal/server/telemetry/emitter.go
Normal file
104
src/internal/server/telemetry/emitter.go
Normal 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)
|
||||
}
|
||||
}
|
||||
179
src/internal/server/telemetry/emitter_test.go
Normal file
179
src/internal/server/telemetry/emitter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/internal/server/telemetry/events.go
Normal file
35
src/internal/server/telemetry/events.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user