Move the caller verifier onto internal/jose
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 33s

Completes KEY-WP-0019. Client.Verify now delegates JWK-set parsing and RS256
signature checking to internal/jose, so the caller path and upstream provider
verification share one implementation rather than two copies that drift. Its
claim policy stays put: audience and nonce bindings belong to the caller.

The existing authclient tests pass unchanged, which is the evidence the
migration preserved behaviour. One deliberate strictness increase: a key set
containing any malformed RSA signing key is refused outright rather than used
alongside a good key. KeyCape's /jwks publishes a single key, so no current
deployment is affected.

Adds direct tests for internal/jose. It is now the single verifier behind both
paths, and testing it only through its callers would leave duplicate key ids,
crit headers, even exponents and undersized moduli covered by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
This commit is contained in:
tegwick 2026-09-07 09:01:16 +02:00
parent 8c1a3e052c
commit 3ec8404c87
4 changed files with 197 additions and 59 deletions

View file

@ -4,20 +4,18 @@ package authclient
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"time"
"keycape/internal/jose"
)
type Client struct {
@ -95,66 +93,23 @@ func (c *Client) Discover(ctx context.Context) (Discovery, error) {
// Verify checks RS256 using the discovered issuer's JWKS and exact claim bindings.
func (c *Client) Verify(ctx context.Context, d Discovery, token, audience, nonce string) (map[string]any, error) {
fail := errors.New("token signature or claim validation failed")
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fail
}
decode := base64.RawURLEncoding.DecodeString
header, err := decode(parts[0])
if err != nil {
return nil, fail
}
var h struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Crit []string `json:"crit"`
}
if json.Unmarshal(header, &h) != nil || h.Alg != "RS256" || h.Kid == "" || len(h.Crit) != 0 {
return nil, fail
}
var jwks struct {
Keys []struct{ Kty, Use, Alg, Kid, N, E string } `json:"keys"`
}
if err := c.request(ctx, http.MethodGet, d.JWKS, nil, "", "", &jwks); err != nil {
// Signature checking lives in internal/jose so this path and the upstream
// provider verification in the authelia adapter cannot drift apart
// (KEY-WP-0019-T05). The claim policy below stays here: audience and nonce
// bindings are this caller's, not something a shared verifier can know.
var rawJWKS json.RawMessage
if err := c.request(ctx, http.MethodGet, d.JWKS, nil, "", "", &rawJWKS); err != nil {
return nil, err
}
var pub *rsa.PublicKey
for _, k := range jwks.Keys {
if k.Kid != h.Kid {
continue
}
if pub != nil || k.Kty != "RSA" || (k.Alg != "" && k.Alg != "RS256") || (k.Use != "" && k.Use != "sig") {
return nil, fail
}
n, ne := decode(k.N)
e, ee := decode(k.E)
if ne != nil || ee != nil || len(e) == 0 || len(e) > 4 {
return nil, fail
}
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
if pub.N.BitLen() < 2048 || pub.E < 3 || pub.E%2 == 0 {
return nil, fail
}
}
if pub == nil {
return nil, fail
}
sig, err := decode(parts[2])
keys, err := jose.ParseJWKS(rawJWKS)
if err != nil {
return nil, fail
}
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash[:], sig) != nil {
return nil, fail
}
payload, err := decode(parts[1])
claims, err := jose.Verify(token, keys)
if err != nil {
return nil, fail
}
claims := map[string]any{}
if json.Unmarshal(payload, &claims) != nil {
return nil, fail
}
exp, okExp := claims["exp"].(float64)
iat, okIat := claims["iat"].(float64)
now := float64(time.Now().Unix())