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:
parent
329e996619
commit
22f7a7dc50
9 changed files with 1080 additions and 1 deletions
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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue