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

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"`
}