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

View file

@ -18,8 +18,9 @@ import (
// AutheliaAdapter implements domain.AuthProvider by delegating to Authelia's
// OIDC endpoints. All Authelia tokens and cookies are confined to this package.
type AutheliaAdapter struct {
cfg Config
client HTTPClient
cfg Config
client HTTPClient
verifier *idTokenVerifier
}
// New returns a production-ready AutheliaAdapter.
@ -28,7 +29,9 @@ func New(cfg Config, httpClient HTTPClient) *AutheliaAdapter {
if httpClient == nil {
httpClient = defaultHTTPClient
}
return &AutheliaAdapter{cfg: cfg, client: httpClient}
a := &AutheliaAdapter{cfg: cfg, client: httpClient}
a.verifier = newIDTokenVerifier(cfg, httpClient, a.tokenBaseURL())
return a
}
// ---------------------------------------------------------------------------
@ -86,15 +89,19 @@ func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.Call
return nil, domain.ErrAuthFailed
}
// Parse the ID token claims (no signature verification — internal service boundary).
claims, err := parseIDTokenClaims(tokenResp.IDToken)
// Verify the ID token before trusting any claim in it: signature against
// Authelia's published keys, expected issuer, our own client ID in the
// audience, and a sane validity window (KEY-WP-0019). This fails closed --
// an unreachable or unparseable key set denies the login rather than
// falling back to unverified claims.
claims, err := a.verifier.Verify(ctx, tokenResp.IDToken)
if err != nil {
emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventAuthFailure,
Endpoint: "/api/oidc/token",
Result: "failure",
ErrorType: "id_token_parse_error",
ErrorType: "id_token_verification_error",
})
return nil, domain.ErrAuthFailed
}
@ -187,9 +194,10 @@ func (a *AutheliaAdapter) tokenBaseURL() string {
return a.cfg.BaseURL
}
// parseIDTokenClaims extracts the JWT payload claims without verifying the
// signature. This is intentional — the token is received directly from the
// upstream OIDC provider over a server-to-server TLS connection.
// parseIDTokenClaims extracts the JWT payload claims without verifying
// anything. It is NOT part of the authentication path: HandleCallback verifies
// through idTokenVerifier. Kept for tests and diagnostics that need to read a
// token's payload without asserting it is trustworthy.
func parseIDTokenClaims(idToken string) (map[string]interface{}, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {

View file

@ -2,14 +2,20 @@ package authelia_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/adapters/authelia"
"keycape/internal/domain"
@ -25,6 +31,16 @@ type mockHTTPClient struct {
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
// The adapter now verifies upstream ID tokens (KEY-WP-0019), so the fixture
// serves the provider metadata and signing keys. Each test keeps supplying
// its own token-endpoint behaviour through doFn.
switch {
case strings.HasSuffix(req.URL.Path, "/.well-known/openid-configuration"):
return jsonResponse(fmt.Sprintf(
`{"issuer":%q,"jwks_uri":%q}`, testIssuer, testIssuer+"/jwks.json")), nil
case strings.HasSuffix(req.URL.Path, "/jwks.json"):
return jsonResponse(testJWKS(nil)), nil
}
if m.doFn != nil {
return m.doFn(req)
}
@ -48,17 +64,70 @@ func testConfig() authelia.Config {
}
}
// buildTokenResponse builds a fake token endpoint JSON response.
// The ID token is a minimal unsigned JWT (header.claims.signature) with the given claims.
func buildTokenResponse(claims map[string]interface{}) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
claimsJSON, _ := json.Marshal(claims)
claimsEnc := base64.RawURLEncoding.EncodeToString(claimsJSON)
idToken := header + "." + claimsEnc + ".fakesig"
const (
testIssuer = "https://authelia.local"
testKeyID = "authelia-key-1"
)
body := fmt.Sprintf(`{"access_token":"at","token_type":"Bearer","id_token":%q}`,
idToken)
return body
// testKey signs every fixture ID token. Generated once: RSA key generation is
// slow enough to dominate this package's test time otherwise.
var testKey = func() *rsa.PrivateKey {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return key
}()
// testJWKS renders a JWK set for the given key under the default key id.
func testJWKS(key *rsa.PrivateKey) string {
return testJWKSKid(testKeyID, key)
}
// testJWKSKid renders a JWK set publishing key under an explicit key id, so a
// test can model provider key rotation.
func testJWKSKid(kid string, key *rsa.PrivateKey) string {
if key == nil {
key = testKey
}
return fmt.Sprintf(`{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":%q,"n":%q,"e":%q}]}`,
kid,
base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()))
}
// signIDToken builds a genuine RS256 token, filling in the registered-claim
// defaults a test does not care about so each case states only what it varies.
func signIDToken(claims map[string]interface{}, kid string, key *rsa.PrivateKey) string {
if key == nil {
key = testKey
}
full := map[string]interface{}{
"iss": testIssuer,
"aud": "keycape",
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": time.Now().Add(10 * time.Minute).Unix(),
}
for name, value := range claims {
full[name] = value
}
headerJSON, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": kid})
claimsJSON, _ := json.Marshal(full)
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." +
base64.RawURLEncoding.EncodeToString(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
panic(err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
}
// buildTokenResponse builds a token endpoint JSON response carrying a genuine
// signed ID token with the given claims.
func buildTokenResponse(claims map[string]interface{}) string {
return fmt.Sprintf(`{"access_token":"at","token_type":"Bearer","id_token":%q}`,
signIDToken(claims, testKeyID, nil))
}
// jsonResponse returns a *http.Response with a JSON body and status 200.

View file

@ -27,6 +27,17 @@ type Config struct {
// RedirectURI is the callback URL registered in Authelia that points back
// to KeyCape's callback handler.
RedirectURI string `yaml:"redirectURI"`
// Issuer pins the expected iss claim of upstream ID tokens. Empty means the
// value Authelia advertises in its discovery document is used, which is
// correct for an ordinary deployment. Set it only where that inference is
// wrong.
Issuer string `yaml:"issuer,omitempty"`
// JWKSURL pins where Authelia's signing keys are fetched from. Empty means
// the advertised jwks_uri path, rebased onto TokenBaseURL so a split-horizon
// deployment still resolves server-side.
JWKSURL string `yaml:"jwksUrl,omitempty"`
}
// HTTPClient is a minimal interface over net/http.Client for test injection.

View file

@ -0,0 +1,220 @@
package authelia
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"keycape/internal/jose"
)
// idTokenVerifier verifies upstream Authelia ID tokens against Authelia's
// published signing keys (KEY-WP-0019).
//
// KeyCape signs everything downstream on the strength of these claims, so they
// are verified independently of how they arrived. The KeyCape→Authelia hop is
// expected to be HTTPS as defence in depth, but nothing here checks or gates on
// that: a transport check only helps when it is configured correctly, which is
// precisely the assumption it was supposed to remove. Verification holds whether
// or not the transport is what we believe it is.
type idTokenVerifier struct {
client HTTPClient
tokenBaseURL string
clientID string
// issuerOverride and jwksOverride pin metadata for deployments where it
// cannot be inferred from the token base URL. Empty means "discover".
issuerOverride string
jwksOverride string
mu sync.Mutex
issuer string
jwksURL string
keys jose.KeySet
}
// leeway absorbs ordinary clock skew between KeyCape and the provider. It is
// small on purpose: it is a tolerance for imperfect clocks, not a grace period.
const leeway = 30 * time.Second
func newIDTokenVerifier(cfg Config, client HTTPClient, tokenBaseURL string) *idTokenVerifier {
return &idTokenVerifier{
client: client,
tokenBaseURL: strings.TrimRight(tokenBaseURL, "/"),
clientID: cfg.ClientID,
issuerOverride: cfg.Issuer,
jwksOverride: cfg.JWKSURL,
}
}
// Verify returns the ID token's claims once its signature, issuer, audience and
// validity window have all been checked. Every failure path denies the login:
// there is no fallback to unverified claims.
func (v *idTokenVerifier) Verify(ctx context.Context, idToken string) (map[string]interface{}, error) {
keys, issuer, err := v.material(ctx, false)
if err != nil {
return nil, err
}
// An unknown key id most likely means the provider rotated its keys, so
// refetch once before rejecting. Any other failure is not retried.
if kid, kidErr := jose.KeyID(idToken); kidErr == nil {
if _, known := keys[kid]; !known {
if refreshed, refreshedIssuer, refreshErr := v.material(ctx, true); refreshErr == nil {
keys, issuer = refreshed, refreshedIssuer
}
}
}
claims, err := jose.Verify(idToken, keys)
if err != nil {
return nil, fmt.Errorf("authelia: id_token signature: %w", err)
}
if err := v.checkClaims(claims, issuer); err != nil {
return nil, err
}
return claims, nil
}
// checkClaims applies the issuer, audience and validity-window policy. The
// signature only proves who minted the token; these decide whether it was minted
// for us, by the provider we expect, and now.
func (v *idTokenVerifier) checkClaims(claims map[string]interface{}, issuer string) error {
if got, _ := claims["iss"].(string); got != issuer {
return fmt.Errorf("authelia: id_token issuer mismatch")
}
if !audienceContains(claims["aud"], v.clientID) {
return fmt.Errorf("authelia: id_token audience does not include this client")
}
exp, hasExp := numericClaim(claims, "exp")
iat, hasIat := numericClaim(claims, "iat")
if !hasExp || !hasIat {
return fmt.Errorf("authelia: id_token missing exp or iat")
}
now := time.Now()
if now.After(time.Unix(int64(exp), 0).Add(leeway)) {
return fmt.Errorf("authelia: id_token expired")
}
if time.Unix(int64(iat), 0).After(now.Add(leeway)) || iat >= exp {
return fmt.Errorf("authelia: id_token validity window is not sane")
}
if nbf, hasNbf := numericClaim(claims, "nbf"); hasNbf {
if time.Unix(int64(nbf), 0).After(now.Add(leeway)) {
return fmt.Errorf("authelia: id_token is not yet valid")
}
}
return nil
}
// material returns the cached key set and expected issuer, fetching them if
// absent or if refresh is set.
func (v *idTokenVerifier) material(ctx context.Context, refresh bool) (jose.KeySet, string, error) {
v.mu.Lock()
defer v.mu.Unlock()
if !refresh && v.keys != nil {
return v.keys, v.issuer, nil
}
if v.issuer == "" || v.jwksURL == "" || refresh {
if err := v.resolveMetadataLocked(ctx); err != nil {
return nil, "", err
}
}
raw, err := v.get(ctx, v.jwksURL)
if err != nil {
return nil, "", fmt.Errorf("authelia: fetch jwks: %w", err)
}
keys, err := jose.ParseJWKS(raw)
if err != nil {
return nil, "", fmt.Errorf("authelia: parse jwks: %w", err)
}
v.keys = keys
return v.keys, v.issuer, nil
}
// resolveMetadataLocked determines the expected issuer and the JWKS URL.
//
// The advertised jwks_uri is rebased onto the server-side token base URL. In a
// split-horizon deployment the provider advertises its public URL, which KeyCape
// may not be able to reach; the path is the part worth keeping. The issuer is
// taken as advertised, because that is the exact string that appears in the
// token and a rebased one would never match.
func (v *idTokenVerifier) resolveMetadataLocked(ctx context.Context) error {
if v.issuerOverride != "" && v.jwksOverride != "" {
v.issuer, v.jwksURL = v.issuerOverride, v.jwksOverride
return nil
}
raw, err := v.get(ctx, v.tokenBaseURL+"/.well-known/openid-configuration")
if err != nil {
return fmt.Errorf("authelia: fetch provider metadata: %w", err)
}
var metadata struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
}
if err := json.Unmarshal(raw, &metadata); err != nil {
return fmt.Errorf("authelia: parse provider metadata: %w", err)
}
if metadata.Issuer == "" || metadata.JWKSURI == "" {
return fmt.Errorf("authelia: provider metadata is incomplete")
}
v.issuer = metadata.Issuer
if v.issuerOverride != "" {
v.issuer = v.issuerOverride
}
v.jwksURL = v.jwksOverride
if v.jwksURL == "" {
advertised, err := url.Parse(metadata.JWKSURI)
if err != nil {
return fmt.Errorf("authelia: parse advertised jwks_uri: %w", err)
}
v.jwksURL = v.tokenBaseURL + advertised.EscapedPath()
}
return nil
}
func (v *idTokenVerifier) get(ctx context.Context, target string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := v.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
// Bounded read: an upstream that streams indefinitely must not exhaust us.
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}
// audienceContains reports whether the aud claim, which may be a string or an
// array, includes want.
func audienceContains(aud interface{}, want string) bool {
switch value := aud.(type) {
case string:
return value == want
case []interface{}:
for _, entry := range value {
if s, ok := entry.(string); ok && s == want {
return true
}
}
}
return false
}
func numericClaim(claims map[string]interface{}, key string) (float64, bool) {
value, ok := claims[key].(float64)
return value, ok
}

View file

@ -0,0 +1,212 @@
package authelia_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"testing"
"time"
"keycape/internal/adapters/authelia"
"keycape/internal/domain"
)
// KEY-WP-0019-T03 — upstream ID tokens are verified independently of transport.
// Every case here is a token KeyCape must refuse to build an identity from, plus
// the positive and rotation cases that keep the refusals meaningful.
// provider serves discovery, JWKS and the token endpoint. jwks and idToken let a
// case vary exactly one thing.
type provider struct {
jwks string
idToken string
jwksErr error
jwksCalls int
}
func (p *provider) Do(req *http.Request) (*http.Response, error) {
body := ""
switch {
case strings.HasSuffix(req.URL.Path, "/.well-known/openid-configuration"):
body = fmt.Sprintf(`{"issuer":%q,"jwks_uri":%q}`, testIssuer, testIssuer+"/jwks.json")
case strings.HasSuffix(req.URL.Path, "/jwks.json"):
p.jwksCalls++
if p.jwksErr != nil {
return nil, p.jwksErr
}
body = p.jwks
default:
body = fmt.Sprintf(`{"access_token":"at","token_type":"Bearer","id_token":%q}`, p.idToken)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}
func callback(t *testing.T, p *provider) (*domain.AuthResult, error) {
t.Helper()
adapter := authelia.New(testConfig(), p)
return adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code", State: "state"})
}
func TestUpstreamTokenIsAcceptedWhenGenuine(t *testing.T) {
result, err := callback(t, &provider{
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice"}, testKeyID, nil),
})
if err != nil {
t.Fatalf("genuine token rejected: %v", err)
}
if result.Username != "alice" {
t.Fatalf("username %q", result.Username)
}
}
func TestUpstreamTokenRejections(t *testing.T) {
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
valid := map[string]interface{}{"preferred_username": "alice"}
cases := map[string]*provider{
// Signed by a key that is not the provider's.
"forged signature": {
jwks: testJWKS(nil),
idToken: signIDToken(valid, testKeyID, otherKey),
},
// A key id the published set does not contain.
"unknown key id": {
jwks: testJWKS(nil),
idToken: signIDToken(valid, "not-a-published-key", nil),
},
"wrong issuer": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "iss": "https://attacker.example"}, testKeyID, nil),
},
"audience is another client": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "aud": "some-other-client"}, testKeyID, nil),
},
"audience array without this client": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "aud": []string{"a", "b"}}, testKeyID, nil),
},
"expired": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{
"preferred_username": "alice",
"iat": time.Now().Add(-2 * time.Hour).Unix(),
"exp": time.Now().Add(-time.Hour).Unix(),
}, testKeyID, nil),
},
"issued in the future": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "iat": time.Now().Add(time.Hour).Unix()}, testKeyID, nil),
},
"not yet valid": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "nbf": time.Now().Add(time.Hour).Unix()}, testKeyID, nil),
},
"missing expiry": {
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "exp": nil}, testKeyID, nil),
},
"undersized signing key": {
jwks: smallKeyJWKS(t),
idToken: signIDToken(valid, testKeyID, nil),
},
"malformed key set": {
jwks: `{"keys":[{"kty":"RSA","kid":"authelia-key-1","n":"!!not-base64!!","e":"AQAB"}]}`,
idToken: signIDToken(valid, testKeyID, nil),
},
"empty key set": {
jwks: `{"keys":[]}`,
idToken: signIDToken(valid, testKeyID, nil),
},
"unreachable key set": {
jwksErr: errors.New("dial tcp: connection refused"),
idToken: signIDToken(valid, testKeyID, nil),
},
}
for name, p := range cases {
t.Run(name, func(t *testing.T) {
result, err := callback(t, p)
if !errors.Is(err, domain.ErrAuthFailed) {
t.Fatalf("expected ErrAuthFailed, got result=%v err=%v", result, err)
}
})
}
}
// alg must be checked before any key is selected, so "none" and symmetric
// algorithms cannot reach the RSA path.
func TestUpstreamTokenRejectsNonRS256Algorithms(t *testing.T) {
for _, alg := range []string{"none", "HS256", "RS512", ""} {
genuine := signIDToken(map[string]interface{}{"preferred_username": "alice"}, testKeyID, nil)
parts := strings.Split(genuine, ".")
restated := encodeSegment(fmt.Sprintf(`{"alg":%q,"typ":"JWT","kid":%q}`, alg, testKeyID))
if _, err := callback(t, &provider{
jwks: testJWKS(nil),
idToken: restated + "." + parts[1] + "." + parts[2],
}); !errors.Is(err, domain.ErrAuthFailed) {
t.Fatalf("alg %q accepted", alg)
}
}
}
// Provider key rotation must not require a KeyCape restart: an unknown key id
// triggers one refresh, and the new key then verifies.
func TestUpstreamKeyRotationIsPickedUpWithoutRestart(t *testing.T) {
p := &provider{
jwks: testJWKS(nil),
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice"}, testKeyID, nil),
}
adapter := authelia.New(testConfig(), p)
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "c", State: "s"}); err != nil {
t.Fatalf("first login: %v", err)
}
firstCalls := p.jwksCalls
// The provider rotates: same key id, new key material.
rotated, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
p.jwks = testJWKSKid("rotated-key", rotated)
p.idToken = signIDToken(map[string]interface{}{"preferred_username": "alice"}, "rotated-key", rotated)
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "c", State: "s"}); err != nil {
t.Fatalf("after rotation: %v", err)
}
if p.jwksCalls <= firstCalls {
t.Fatal("key set was not refetched after an unknown key id")
}
}
func encodeSegment(raw string) string {
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
// smallKeyJWKS publishes a key below the accepted modulus size.
func smallKeyJWKS(t *testing.T) string {
t.Helper()
small, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatal(err)
}
return fmt.Sprintf(`{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":%q,"n":%q,"e":%q}]}`,
testKeyID,
base64.RawURLEncoding.EncodeToString(small.PublicKey.N.Bytes()),
base64.RawURLEncoding.EncodeToString(big.NewInt(int64(small.PublicKey.E)).Bytes()))
}

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