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,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)
}

View 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)
}
}

View 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
}

View 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))
}
}
}