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
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue