Verify upstream Authelia ID tokens
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 29s

Closes the remaining half of gap G01. The adapter decoded upstream ID-token
claims without verifying anything, justified in a comment by a server-to-server
TLS boundary that nothing enforced.

Operator decision: verify the token rather than police the transport. The hop is
to be HTTPS as defence in depth, but KeyCape does not monitor, check or gate on
that -- a transport check helps only when it is configured correctly, which is
the assumption it was meant to remove. Verification holds regardless of how the
token arrived, so no HTTPS validation or opt-in flag is added.

HandleCallback now verifies the RS256 signature against Authelia's published
keys, the issuer Authelia advertises, KeyCape's own client ID in the audience,
and a sane validity window, before any claim is trusted. It fails closed: an
unreachable or unparseable key set denies the login. The advertised jwks_uri
path is rebased onto the server-side token base URL so split-horizon deployments
resolve, with config overrides where that inference is wrong, and an unknown key
id triggers one refresh so provider rotation needs no restart.

The reusable half lives in internal/jose rather than being copied from
authclient's verifier, since duplicated verification is how two copies drift and
one misses a fix. Migrating authclient onto it is tracked as KEY-WP-0019-T05,
kept separate so it does not destabilise a tested path in this change.

Thirteen rejection cases plus algorithm and rotation coverage; with the
unverified parse restored all fifteen fail, so they test the fix rather than
merely passing.

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 08:51:42 +02:00
parent 7d0f6d67a6
commit 91e3d50907
14 changed files with 838 additions and 22 deletions

153
src/internal/jose/jose.go Normal file
View file

@ -0,0 +1,153 @@
// Package jose provides strict RS256 JWT signature verification against a JWK
// set. It is deliberately narrow: it establishes that a token was signed by a
// key in the given set and returns its claims. It applies no claim policy —
// issuer, audience, expiry and nonce rules differ per caller and belong with
// the caller that knows them.
//
// The strictness here is the point. A verifier that accepts an unexpected
// algorithm, an unbounded exponent or an undersized modulus is worse than no
// verifier, because callers stop checking what it appears to guarantee.
package jose
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"strings"
)
// ErrVerification is returned for every verification failure. The cause is
// deliberately not distinguished: a caller acting on why a token failed tends to
// leak that distinction to whoever supplied the token.
var ErrVerification = errors.New("jose: token verification failed")
// KeySet maps key IDs to RSA public keys.
type KeySet map[string]*rsa.PublicKey
// ParseJWKS parses a JWK set, keeping only RSA signing keys usable with RS256.
// Keys that are malformed, too small, or carry an even or trivial exponent are
// rejected rather than skipped, since a key set that is partly wrong is not
// evidence of anything.
func ParseJWKS(raw []byte) (KeySet, error) {
var document struct {
Keys []struct {
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.Unmarshal(raw, &document); err != nil {
return nil, ErrVerification
}
keys := make(KeySet, len(document.Keys))
for _, key := range document.Keys {
// Ignore keys for other algorithms or purposes; they are not errors.
if key.Kty != "RSA" || (key.Use != "" && key.Use != "sig") || (key.Alg != "" && key.Alg != "RS256") {
continue
}
if key.Kid == "" {
return nil, ErrVerification
}
modulus, errN := decode(key.N)
exponent, errE := decode(key.E)
if errN != nil || errE != nil || len(exponent) == 0 || len(exponent) > 4 {
return nil, ErrVerification
}
public := &rsa.PublicKey{
N: new(big.Int).SetBytes(modulus),
E: int(new(big.Int).SetBytes(exponent).Int64()),
}
if public.N.BitLen() < 2048 || public.E < 3 || public.E%2 == 0 {
return nil, ErrVerification
}
if _, duplicate := keys[key.Kid]; duplicate {
return nil, ErrVerification
}
keys[key.Kid] = public
}
if len(keys) == 0 {
return nil, ErrVerification
}
return keys, nil
}
// KeyID returns the key ID from a token's JOSE header, after checking that the
// header names RS256 and carries no critical extensions. Callers use it to
// decide whether to refresh their key set before verifying.
func KeyID(token string) (string, error) {
header, _, _, err := split(token)
if err != nil {
return "", err
}
return header.Kid, nil
}
// Verify checks a token's RS256 signature against the key set and returns its
// claims. It does not inspect any claim.
func Verify(token string, keys KeySet) (map[string]interface{}, error) {
header, signingInput, signature, err := split(token)
if err != nil {
return nil, err
}
public, ok := keys[header.Kid]
if !ok {
return nil, ErrVerification
}
digest := sha256.Sum256([]byte(signingInput))
if rsa.VerifyPKCS1v15(public, crypto.SHA256, digest[:], signature) != nil {
return nil, ErrVerification
}
payload, err := decode(strings.Split(signingInput, ".")[1])
if err != nil {
return nil, ErrVerification
}
claims := map[string]interface{}{}
if json.Unmarshal(payload, &claims) != nil {
return nil, ErrVerification
}
return claims, nil
}
type joseHeader struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Crit []string `json:"crit"`
}
// split validates the token's shape and header and returns the header, the
// signing input, and the decoded signature.
func split(token string) (joseHeader, string, []byte, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return joseHeader{}, "", nil, ErrVerification
}
raw, err := decode(parts[0])
if err != nil {
return joseHeader{}, "", nil, ErrVerification
}
var header joseHeader
if json.Unmarshal(raw, &header) != nil {
return joseHeader{}, "", nil, ErrVerification
}
// An unexpected algorithm must fail before any key is selected, so "none"
// and symmetric algorithms can never reach the RSA path.
if header.Alg != "RS256" || header.Kid == "" || len(header.Crit) != 0 {
return joseHeader{}, "", nil, ErrVerification
}
signature, err := decode(parts[2])
if err != nil {
return joseHeader{}, "", nil, ErrVerification
}
return header, parts[0] + "." + parts[1], signature, nil
}
func decode(segment string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(segment)
}