Files
traefikoidc/jwt_test_helpers_test.go
lukaszraczylo c9844d0ff2 fix(middleware): forward configured headers on SSE/WebSocket bypass (#162)
The SSE and WebSocket bypass forwarded only X-Forwarded-User. It did not
apply the configured headers. Backends that authenticate the proxy with a
shared-secret header returned 401 on WebSocket upgrades. Version 0.8.x sent
WebSocket upgrades through the normal flow, so this is a regression.

The bypass now renders the header templates through applyHeaderTemplates,
which forwardAuthorized also uses. Templates use the ID-token claims from
the session cookie. If the session has no ID token, they use the
access-token claims.

The bypass now also rejects a session that the IdP logged out through
back-channel or front-channel logout. Without this check, a logged-out
cookie can open streams with the proxy secret attached.

Add a yaegi check for the WebSocket case. Move tokenWithGroups to the
shared JWT test helpers, as FIX-41 requires.

Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
2026-09-28 15:24:33 +01:00

101 lines
3.9 KiB
Go

package traefikoidc
// Shared JWT/JWKS construction helpers for round-regression tests.
//
// Moved here for FIX-41: writeJWKS, signRSAJWTForTest and makeJWTForTest
// were each declared inside a single round file (review_r54, review_r109,
// review_r102) but consumed by other round files, so deleting or renaming
// the declaring file silently broke the consumer. Every review_rNN*_test.go
// file must depend only on shared helpers like this one, never on another
// round file.
//
// makeTestJWT joined this file for the same reason (R4 re-review): it was
// declared inside review_r92_test.go but consumed by
// access_token_unexpired_iat_optional_test.go, a FIX-round test file — the
// dependency was not even confined to other round files.
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
)
// writeJWKS encodes a single-key RSA JWKS response for kid/pub to w. Used by
// JWKS-rotation and live-refresh tests that stand up an httptest.Server.
func writeJWKS(t *testing.T, w http.ResponseWriter, kid string, pub *rsa.PublicKey) {
t.Helper()
jwk := JWK{
Kty: "RSA",
Kid: kid,
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}
_ = json.NewEncoder(w).Encode(JWKSet{Keys: []JWK{jwk}})
}
// signRSAJWTForTest builds an RS256-signed JWT with the given kid and claims,
// suitable for exercising real signature verification (unlike makeJWTForTest
// which uses alg=none).
func signRSAJWTForTest(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string {
t.Helper()
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid})
payload, _ := json.Marshal(claims)
signing := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
hasher := sha256.New()
hasher.Write([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hasher.Sum(nil))
if err != nil {
t.Fatalf("sign: %v", err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// makeJWTForTest builds an unsigned (alg=none) JWT carrying claims. Used by
// tests that only need claims to parse, not a verifiable signature.
func makeJWTForTest(claims map[string]any) string {
h, _ := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"})
p, _ := json.Marshal(claims)
return base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p) + ".c2ln"
}
// makeTestJWT builds a syntactically valid 3-part JWT with the given claims.
// The signature segment is not cryptographically validated by callers that
// only parse claims (e.g. accessTokenUnexpired), so a static value is fine.
func makeTestJWT(t *testing.T, claims map[string]interface{}) string {
t.Helper()
hdr, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
if err != nil {
t.Fatalf("marshal header: %v", err)
}
pl, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + ".c2ln"
}
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
// the session chunk manager's format validation (>=10 chars) so the access
// token round-trips through Set/GetAccessToken.
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
pl, _ := json.Marshal(claims)
// Varied (non-repeating) signature bytes so the session chunk manager's
// repeated-character heuristic does not reject it.
sigBytes := make([]byte, 32)
for i := range sigBytes {
sigBytes[i] = byte(i + 1)
}
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
}