mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
feat(api): validate IAM tokens via JWKS in production (HIP-0111)
The api-go backend only had an HS256 verifier (shared JWT_SECRET). After the
FE completes IAM PKCE it presents an IAM-issued RS256 access token, which the
HMAC verifier rejects (SigningMethodHMAC check) — IAM login fails at the API.
Converge on the canonical IAM validation path:
- iam/jwks.go: JWKSVerifier validates RS256/ES256 tokens against the issuer's
JWKS, resolved via OIDC discovery ({issuer}/.well-known/openid-configuration
-> jwks_uri). Keys cached, refreshed on unknown kid (rate-limited). stdlib
only — no new dependency. Issuer is checked (WithIssuer).
- main.go: production selects JWKSVerifier(IAM_URL); dev keeps HS256 for the
built-in email/password flow. One way per environment.
- config: ValidateSecrets() fails fast if production boots with placeholder/
empty JWT_SECRET or API_KEY_SALT — secrets MUST come from KMS, never the
in-code default. IsProduction() gates it.
- iam.go: drop redundant exp/iat int64 fields from Claims — duplicate json
tags shadowed jwt.RegisteredClaims and silently disabled expiry validation
(latent bug in the HS path too; now exp is enforced).
Tests: JWKS valid/expired/wrong-issuer/unknown-kid/HS-rejected + config
secret guard. go build + go vet + go test all green.
This commit is contained in:
@@ -41,7 +41,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
slog.Info("db ready")
|
slog.Info("db ready")
|
||||||
|
|
||||||
verifier := iam.NewHS(cfg.JWTSecret)
|
// HIP-0111: production verifies IAM-issued access tokens against the
|
||||||
|
// issuer's JWKS (RS256/ES256), resolved via OIDC discovery. Local-dev keeps
|
||||||
|
// the HS256 shared-secret path for the built-in email/password flow.
|
||||||
|
var verifier iam.Verifier
|
||||||
|
if cfg.IsProduction() {
|
||||||
|
if err := cfg.ValidateSecrets(); err != nil {
|
||||||
|
slog.Error("config invalid", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
verifier = iam.NewJWKS(cfg.IAMUrl)
|
||||||
|
slog.Info("auth: IAM JWKS verifier", "issuer", cfg.IAMUrl)
|
||||||
|
} else {
|
||||||
|
verifier = iam.NewHS(cfg.JWTSecret)
|
||||||
|
slog.Info("auth: HS256 dev verifier")
|
||||||
|
}
|
||||||
|
|
||||||
handler := api.New(api.Deps{
|
handler := api.New(api.Deps{
|
||||||
Pool: pool,
|
Pool: pool,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -33,9 +34,9 @@ type Settings struct {
|
|||||||
APIKeySalt string
|
APIKeySalt string
|
||||||
|
|
||||||
// Hanzo IAM
|
// Hanzo IAM
|
||||||
IAMUrl string
|
IAMUrl string
|
||||||
IAMClientID string
|
IAMClientID string
|
||||||
IAMClientSecret string
|
IAMClientSecret string
|
||||||
EnableMultiTenant bool
|
EnableMultiTenant bool
|
||||||
AllowedOrgs []string
|
AllowedOrgs []string
|
||||||
FrontendURL string
|
FrontendURL string
|
||||||
@@ -47,9 +48,9 @@ type Settings struct {
|
|||||||
ZAPPort int
|
ZAPPort int
|
||||||
|
|
||||||
// Observability
|
// Observability
|
||||||
OTELEndpoint string
|
OTELEndpoint string
|
||||||
OTELServiceName string
|
OTELServiceName string
|
||||||
PrometheusPort int
|
PrometheusPort int
|
||||||
|
|
||||||
// Listen
|
// Listen
|
||||||
HTTPHost string
|
HTTPHost string
|
||||||
@@ -112,6 +113,34 @@ func (s *Settings) JWTTTL() time.Duration {
|
|||||||
return time.Duration(s.JWTExpireMinutes) * time.Minute
|
return time.Duration(s.JWTExpireMinutes) * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsProduction reports whether the app runs in a non-development environment.
|
||||||
|
// Drives the auth verifier choice (IAM JWKS vs local HS256).
|
||||||
|
func (s *Settings) IsProduction() bool {
|
||||||
|
return s.AppEnv != "" && s.AppEnv != "development" && s.AppEnv != "dev" && s.AppEnv != "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderSecret is the in-code default for secrets that MUST be injected
|
||||||
|
// from KMS in production. Refusing to boot with this value is fail-fast: a
|
||||||
|
// production pod missing its KMS-synced secret never silently signs/derives
|
||||||
|
// with a known-public default.
|
||||||
|
const placeholderSecret = "change-me-in-production"
|
||||||
|
|
||||||
|
// ValidateSecrets enforces that production secrets are real (KMS-injected),
|
||||||
|
// never the in-code placeholder. Returns an error listing every offender.
|
||||||
|
func (s *Settings) ValidateSecrets() error {
|
||||||
|
var bad []string
|
||||||
|
if s.JWTSecret == "" || s.JWTSecret == placeholderSecret {
|
||||||
|
bad = append(bad, "JWT_SECRET")
|
||||||
|
}
|
||||||
|
if s.APIKeySalt == "" || s.APIKeySalt == placeholderSecret {
|
||||||
|
bad = append(bad, "API_KEY_SALT")
|
||||||
|
}
|
||||||
|
if len(bad) > 0 {
|
||||||
|
return fmt.Errorf("production requires KMS-injected secrets, got placeholder/empty: %s", strings.Join(bad, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func env(key, def string) string {
|
func env(key, def string) string {
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
if v, ok := os.LookupEnv(key); ok {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsProduction(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"production": true,
|
||||||
|
"staging": true,
|
||||||
|
"development": false,
|
||||||
|
"dev": false,
|
||||||
|
"local": false,
|
||||||
|
"": false,
|
||||||
|
}
|
||||||
|
for env, want := range cases {
|
||||||
|
s := &Settings{AppEnv: env}
|
||||||
|
if got := s.IsProduction(); got != want {
|
||||||
|
t.Errorf("IsProduction(%q) = %v, want %v", env, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSecrets(t *testing.T) {
|
||||||
|
// Placeholder / empty secrets must be rejected in production.
|
||||||
|
bad := &Settings{JWTSecret: placeholderSecret, APIKeySalt: ""}
|
||||||
|
if err := bad.ValidateSecrets(); err == nil {
|
||||||
|
t.Fatal("expected error for placeholder/empty secrets, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real KMS-injected secrets pass.
|
||||||
|
good := &Settings{JWTSecret: "a-real-kms-secret", APIKeySalt: "a-real-kms-salt"}
|
||||||
|
if err := good.ValidateSecrets(); err != nil {
|
||||||
|
t.Fatalf("expected no error for real secrets, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,15 +22,19 @@ var (
|
|||||||
|
|
||||||
// Claims is the JWT body Bootnode trusts. Validated server-side
|
// Claims is the JWT body Bootnode trusts. Validated server-side
|
||||||
// against IAM's JWKS in the production verifier; HS256 for local-dev.
|
// against IAM's JWKS in the production verifier; HS256 for local-dev.
|
||||||
|
//
|
||||||
|
// Standard registered claims (exp, iat, iss, aud, sub) live in the embedded
|
||||||
|
// jwt.RegisteredClaims so jwt/v5 enforces them. Only app-specific claims are
|
||||||
|
// declared here. `sub` is mirrored to UserID for ergonomic access by handlers;
|
||||||
|
// do NOT re-declare `exp`/`iat` here — duplicate json tags shadow
|
||||||
|
// RegisteredClaims and silently disable expiry validation.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"sub"`
|
UserID string `json:"sub"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
OrgID string `json:"org_id"`
|
OrgID string `json:"org_id"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
IssuedAt int64 `json:"iat"`
|
|
||||||
ExpiresAt int64 `json:"exp"`
|
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Package iam — production JWT verification against Hanzo IAM's JWKS.
|
||||||
|
//
|
||||||
|
// HIP-0111: IAM-issued access tokens are validated against the issuer's
|
||||||
|
// published JWKS (RS256/ES256), resolved via OIDC discovery. No shared HMAC
|
||||||
|
// secret, no introspection round-trip per request — the JWKS public keys are
|
||||||
|
// fetched once, cached, and refreshed on an unknown `kid`.
|
||||||
|
package iam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ecCurve maps a JWK `crv` value to its elliptic.Curve.
|
||||||
|
func ecCurve(crv string) (elliptic.Curve, error) {
|
||||||
|
switch crv {
|
||||||
|
case "P-256":
|
||||||
|
return elliptic.P256(), nil
|
||||||
|
case "P-384":
|
||||||
|
return elliptic.P384(), nil
|
||||||
|
case "P-521":
|
||||||
|
return elliptic.P521(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported EC curve %q", crv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWKSVerifier validates bearer tokens against an IAM deployment's JWKS.
|
||||||
|
//
|
||||||
|
// The issuer is the canonical OIDC issuer for the brand this deployment serves
|
||||||
|
// (iam.hanzo.ai / lux.id / zoo.id / pars.id / id.bootno.de). The jwks_uri is
|
||||||
|
// read from `{issuer}/.well-known/openid-configuration`; keys are cached and
|
||||||
|
// re-fetched at most once per minTTL when a token presents an unknown `kid`.
|
||||||
|
type JWKSVerifier struct {
|
||||||
|
issuer string
|
||||||
|
clockSkew time.Duration
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
keys map[string]any // kid -> *rsa.PublicKey | *ecdsa.PublicKey
|
||||||
|
fetched time.Time
|
||||||
|
minRefresh time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWKS constructs a JWKS verifier for the given OIDC issuer.
|
||||||
|
func NewJWKS(issuer string) *JWKSVerifier {
|
||||||
|
return &JWKSVerifier{
|
||||||
|
issuer: issuer,
|
||||||
|
clockSkew: 60 * time.Second,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
keys: map[string]any{},
|
||||||
|
minRefresh: 1 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify implements Verifier. It parses the token, resolves the signing key by
|
||||||
|
// `kid` against the cached JWKS (refreshing on a miss), validates the signature
|
||||||
|
// and standard claims, and checks the issuer.
|
||||||
|
func (j *JWKSVerifier) Verify(ctx context.Context, token string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
switch t.Method.(type) {
|
||||||
|
case *jwt.SigningMethodRSA, *jwt.SigningMethodECDSA:
|
||||||
|
default:
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
kid, _ := t.Header["kid"].(string)
|
||||||
|
key, err := j.keyByID(ctx, kid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
},
|
||||||
|
jwt.WithLeeway(j.clockSkew),
|
||||||
|
jwt.WithIssuer(j.issuer),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, jwt.ErrTokenExpired):
|
||||||
|
return nil, ErrTokenExpired
|
||||||
|
default:
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !parsed.Valid {
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyByID returns the public key for kid, refreshing the JWKS once (subject to
|
||||||
|
// minRefresh) if the kid is not cached.
|
||||||
|
func (j *JWKSVerifier) keyByID(ctx context.Context, kid string) (any, error) {
|
||||||
|
j.mu.RLock()
|
||||||
|
key, ok := j.keys[kid]
|
||||||
|
j.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if err := j.refresh(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
j.mu.RLock()
|
||||||
|
key, ok = j.keys[kid]
|
||||||
|
j.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh fetches the JWKS via discovery and replaces the key cache. Rate
|
||||||
|
// limited to one fetch per minRefresh so a flood of bad-kid tokens cannot
|
||||||
|
// hammer the IAM server.
|
||||||
|
func (j *JWKSVerifier) refresh(ctx context.Context) error {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
if time.Since(j.fetched) < j.minRefresh && len(j.keys) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jwksURI, err := j.discoverJWKSURI(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := j.fetchKeys(ctx, jwksURI)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j.keys = keys
|
||||||
|
j.fetched = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type discoveryDoc struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWKSVerifier) discoverJWKSURI(ctx context.Context) (string, error) {
|
||||||
|
url := j.issuer + "/.well-known/openid-configuration"
|
||||||
|
var doc discoveryDoc
|
||||||
|
if err := j.getJSON(ctx, url, &doc); err != nil {
|
||||||
|
return "", fmt.Errorf("iam discovery: %w", err)
|
||||||
|
}
|
||||||
|
if doc.JWKSURI == "" {
|
||||||
|
return "", errors.New("iam discovery: empty jwks_uri")
|
||||||
|
}
|
||||||
|
if doc.Issuer != "" && doc.Issuer != j.issuer {
|
||||||
|
return "", fmt.Errorf("iam discovery: issuer mismatch %q != %q", doc.Issuer, j.issuer)
|
||||||
|
}
|
||||||
|
return doc.JWKSURI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwk is one key from a JWKS document (RSA or EC public key, RFC 7517/7518).
|
||||||
|
type jwk struct {
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Use string `json:"use"`
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
// RSA
|
||||||
|
N string `json:"n"`
|
||||||
|
E string `json:"e"`
|
||||||
|
// EC
|
||||||
|
Crv string `json:"crv"`
|
||||||
|
X string `json:"x"`
|
||||||
|
Y string `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWKSVerifier) fetchKeys(ctx context.Context, jwksURI string) (map[string]any, error) {
|
||||||
|
var doc struct {
|
||||||
|
Keys []jwk `json:"keys"`
|
||||||
|
}
|
||||||
|
if err := j.getJSON(ctx, jwksURI, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("iam jwks: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(doc.Keys))
|
||||||
|
for _, k := range doc.Keys {
|
||||||
|
if k.Use != "" && k.Use != "sig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pub, err := k.publicKey()
|
||||||
|
if err != nil {
|
||||||
|
continue // skip keys we can't parse (e.g. MLDSA65) — RS/ES suffice
|
||||||
|
}
|
||||||
|
if k.Kid != "" {
|
||||||
|
out[k.Kid] = pub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, errors.New("iam jwks: no usable signing keys")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k jwk) publicKey() (any, error) {
|
||||||
|
switch k.Kty {
|
||||||
|
case "RSA":
|
||||||
|
nb, err := base64.RawURLEncoding.DecodeString(k.N)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
eb, err := base64.RawURLEncoding.DecodeString(k.E)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e := 0
|
||||||
|
for _, b := range eb {
|
||||||
|
e = e<<8 | int(b)
|
||||||
|
}
|
||||||
|
if e == 0 {
|
||||||
|
e = int(binary.BigEndian.Uint32(leftPad(eb, 4)))
|
||||||
|
}
|
||||||
|
return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: e}, nil
|
||||||
|
case "EC":
|
||||||
|
crv, err := ecCurve(k.Crv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
xb, err := base64.RawURLEncoding.DecodeString(k.X)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
yb, err := base64.RawURLEncoding.DecodeString(k.Y)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ecdsa.PublicKey{
|
||||||
|
Curve: crv,
|
||||||
|
X: new(big.Int).SetBytes(xb),
|
||||||
|
Y: new(big.Int).SetBytes(yb),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported kty %q", k.Kty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JWKSVerifier) getJSON(ctx context.Context, url string, out any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := j.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("GET %s: status %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leftPad(b []byte, size int) []byte {
|
||||||
|
if len(b) >= size {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
out := make([]byte, size)
|
||||||
|
copy(out[size-len(b):], b)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package iam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newJWKSServer stands up a discovery + JWKS endpoint backed by `key`, with the
|
||||||
|
// given issuer and kid. Returns the issuer URL (the test server's base).
|
||||||
|
func newJWKSServer(t *testing.T, key *rsa.PrivateKey, kid string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": srv.URL,
|
||||||
|
"jwks_uri": srv.URL + "/v1/iam/.well-known/jwks",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/v1/iam/.well-known/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
pub := key.Public().(*rsa.PublicKey)
|
||||||
|
eb := big.NewInt(int64(pub.E)).Bytes()
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"keys": []map[string]string{{
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "RS256",
|
||||||
|
"kid": kid,
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||||
|
"e": base64.RawURLEncoding.EncodeToString(eb),
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func signRS256(t *testing.T, key *rsa.PrivateKey, kid, issuer string, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||||
|
"sub": "user-123",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"iss": issuer,
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"exp": exp.Unix(),
|
||||||
|
})
|
||||||
|
tok.Header["kid"] = kid
|
||||||
|
s, err := tok.SignedString(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKSVerify_ValidRS256(t *testing.T) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("genkey: %v", err)
|
||||||
|
}
|
||||||
|
srv := newJWKSServer(t, key, "cert-test")
|
||||||
|
v := NewJWKS(srv.URL)
|
||||||
|
|
||||||
|
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(time.Hour))
|
||||||
|
claims, err := v.Verify(context.Background(), tokStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != "user-123" || claims.Email != "[email protected]" {
|
||||||
|
t.Fatalf("bad claims: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKSVerify_RejectsHS256(t *testing.T) {
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
srv := newJWKSServer(t, key, "cert-test")
|
||||||
|
v := NewJWKS(srv.URL)
|
||||||
|
|
||||||
|
// HMAC-signed token must be rejected: a JWKS verifier accepts only the
|
||||||
|
// asymmetric algs IAM signs with (RS/ES), never a caller-chosen HS256.
|
||||||
|
hs := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "x", "iss": srv.URL, "exp": time.Now().Add(time.Hour).Unix()})
|
||||||
|
s, _ := hs.SignedString([]byte("attacker-known-secret"))
|
||||||
|
if _, err := v.Verify(context.Background(), s); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Fatalf("expected ErrTokenInvalid for HS256, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKSVerify_RejectsWrongIssuer(t *testing.T) {
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
srv := newJWKSServer(t, key, "cert-test")
|
||||||
|
v := NewJWKS(srv.URL)
|
||||||
|
|
||||||
|
tokStr := signRS256(t, key, "cert-test", "https://evil.example", time.Now().Add(time.Hour))
|
||||||
|
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Fatalf("expected ErrTokenInvalid for wrong issuer, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKSVerify_RejectsExpired(t *testing.T) {
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
srv := newJWKSServer(t, key, "cert-test")
|
||||||
|
v := NewJWKS(srv.URL)
|
||||||
|
|
||||||
|
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(-2*time.Minute))
|
||||||
|
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenExpired) {
|
||||||
|
t.Fatalf("expected ErrTokenExpired, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKSVerify_RejectsUnknownKID(t *testing.T) {
|
||||||
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
srv := newJWKSServer(t, key, "cert-test")
|
||||||
|
v := NewJWKS(srv.URL)
|
||||||
|
|
||||||
|
// Token signed with a kid the JWKS does not publish.
|
||||||
|
tokStr := signRS256(t, key, "cert-unknown", srv.URL, time.Now().Add(time.Hour))
|
||||||
|
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Fatalf("expected ErrTokenInvalid for unknown kid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user