- PHP protocol cases, one subtest per PHP method: TestPKCEVerifier, TestRedirectURIMatch, TestTokenIssuer, TestAuthCodeLifecycle, TestPKCEAndReplay, TestConsentCSRF (cases 1-5) - Go-only behaviour: discovery bytes, JWKS shapes, PEM parsing, ES256 and mixed rings, validation order, repeated parameters, PHP encoding, resume, switch account, JSON consent, client authentication, uniform userinfo 401, pairwise subjects, opt-in refresh, kind isolation, flavour dispatch, SetOIDCBackend checks, MCP refuses openid - every name required by the gate's go stage
260 lines
10 KiB
Go
260 lines
10 KiB
Go
package wristband
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// memberOrder returns the member names of a JSON object in wire order.
|
|
func memberOrder(t *testing.T, raw []byte) []string {
|
|
t.Helper()
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
|
|
t.Fatalf("not an object: %s", raw)
|
|
}
|
|
var names []string
|
|
for dec.More() {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names = append(names, tok.(string))
|
|
var skip json.RawMessage
|
|
if err := dec.Decode(&skip); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// TestJWKSShapes pins the JWKS members and their order for RSA and EC keys,
|
|
// the cache header, and the fixed 500 for a key-store failure or a key whose
|
|
// algorithm does not match its type (T-19.4-08, T-19.4-09).
|
|
func TestJWKSShapes(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
f.keys.rotate(t, AlgRS256, time.Now())
|
|
f.keys.rotate(t, AlgES256, time.Now())
|
|
rec := f.get("/oauth/jwks")
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "max-age=3600, public" {
|
|
t.Fatalf("jwks = %d %v", rec.Code, rec.Header())
|
|
}
|
|
var doc struct {
|
|
Keys []json.RawMessage `json:"keys"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(doc.Keys) != 2 {
|
|
t.Fatalf("%d keys", len(doc.Keys))
|
|
}
|
|
if got := strings.Join(memberOrder(t, doc.Keys[0]), ","); got != "kty,use,alg,kid,n,e" {
|
|
t.Fatalf("RSA members = %s", got)
|
|
}
|
|
if got := strings.Join(memberOrder(t, doc.Keys[1]), ","); got != "kty,use,alg,kid,crv,x,y" {
|
|
t.Fatalf("EC members = %s", got)
|
|
}
|
|
var rsaKey, ecKey map[string]string
|
|
_ = json.Unmarshal(doc.Keys[0], &rsaKey)
|
|
_ = json.Unmarshal(doc.Keys[1], &ecKey)
|
|
if rsaKey["kty"] != "RSA" || rsaKey["use"] != "sig" || rsaKey["alg"] != "RS256" || rsaKey["e"] != "AQAB" || !regexp.MustCompile(`^[0-9a-f]{16}$`).MatchString(rsaKey["kid"]) {
|
|
t.Fatalf("RSA JWK = %v", rsaKey)
|
|
}
|
|
if ecKey["kty"] != "EC" || ecKey["crv"] != "P-256" || ecKey["alg"] != "ES256" || len(ecKey["x"]) != 43 || len(ecKey["y"]) != 43 {
|
|
t.Fatalf("EC JWK = %v", ecKey)
|
|
}
|
|
if strings.ContainsAny(rec.Body.String(), "=+/") {
|
|
t.Fatalf("JWK members are not unpadded base64url: %s", rec.Body.String())
|
|
}
|
|
|
|
// Key-store failures answer the fixed 500 without the error text.
|
|
for _, failActive := range []bool{true, false} {
|
|
srv := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions())
|
|
if err := srv.SetOIDCBackend(newMemoryOIDCBackend(), failingKeyStore{failActive: failActive, inner: newMemoryKeyStore()}, &fakeOIDCHost{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := serve(srv.JWKS, "/oauth/jwks")
|
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` {
|
|
t.Fatalf("failing key store (active=%v) = %d %s", failActive, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
bare := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions())
|
|
if rec := serve(bare.JWKS, "/oauth/jwks"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` {
|
|
t.Fatalf("unconfigured JWKS = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A published key whose Alg does not match its type is refused.
|
|
f.keys.mu.Lock()
|
|
f.keys.keys[len(f.keys.keys)-1].alg = AlgRS256
|
|
f.keys.mu.Unlock()
|
|
if rec := f.get("/oauth/jwks"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` {
|
|
t.Fatalf("mislabelled key = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rsaPriv, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
|
for name, k := range map[string]PublicKey{
|
|
"RSA labelled ES256": {Kid: "a", Alg: AlgES256, Key: &rsaPriv.PublicKey},
|
|
"P-384 key": {Kid: "b", Alg: AlgES256, Key: &p384.PublicKey},
|
|
"unknown key type": {Kid: "c", Alg: AlgRS256, Key: "not a key"},
|
|
} {
|
|
if _, err := jwkFor(k); err == nil {
|
|
t.Fatalf("%s: jwkFor accepted it", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func pemOf(typ string, der []byte) []byte {
|
|
return pem.EncodeToMemory(&pem.Block{Type: typ, Bytes: der})
|
|
}
|
|
|
|
// TestKeyPEMParsing pins the key parsers: PKCS#8 RSA and EC, PKCS#1 RSA,
|
|
// SEC1 EC, PKIX public keys, and fixed errors for garbage, other curves and
|
|
// a key of the wrong kind.
|
|
func TestKeyPEMParsing(t *testing.T) {
|
|
for _, alg := range []string{AlgRS256, AlgES256} {
|
|
kid, priv, pub, err := GenerateSigningKey(alg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !regexp.MustCompile(`^[0-9a-f]{16}$`).MatchString(kid) || !strings.Contains(string(priv), "BEGIN PRIVATE KEY") || !strings.Contains(string(pub), "BEGIN PUBLIC KEY") {
|
|
t.Fatalf("%s: kid %q, PEM types %q / %q", alg, kid, priv[:30], pub[:30])
|
|
}
|
|
signer, err := ParsePrivateKeyPEM(priv)
|
|
if err != nil {
|
|
t.Fatalf("%s PKCS#8: %v", alg, err)
|
|
}
|
|
public, err := ParsePublicKeyPEM(pub)
|
|
if err != nil {
|
|
t.Fatalf("%s PKIX: %v", alg, err)
|
|
}
|
|
if eq, ok := signer.Public().(interface{ Equal(crypto.PublicKey) bool }); !ok || !eq.Equal(public) {
|
|
t.Fatalf("%s: the public half does not match the private key", alg)
|
|
}
|
|
}
|
|
|
|
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
if k, err := ParsePrivateKeyPEM(pemOf("RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(rsaKey))); err != nil || !rsaKey.Equal(k) {
|
|
t.Fatalf("PKCS#1: %v", err)
|
|
}
|
|
ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
sec1, _ := x509.MarshalECPrivateKey(ecKey)
|
|
if k, err := ParsePrivateKeyPEM(pemOf("EC PRIVATE KEY", sec1)); err != nil || !ecKey.Equal(k) {
|
|
t.Fatalf("SEC1: %v", err)
|
|
}
|
|
|
|
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
|
p384der, _ := x509.MarshalPKCS8PrivateKey(p384)
|
|
p384pub, _ := x509.MarshalPKIXPublicKey(&p384.PublicKey)
|
|
_, edKey, _ := ed25519.GenerateKey(rand.Reader)
|
|
edPriv, _ := x509.MarshalPKCS8PrivateKey(edKey)
|
|
rsaPub, _ := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
|
|
rsaPKCS8, _ := x509.MarshalPKCS8PrivateKey(rsaKey)
|
|
|
|
for name, data := range map[string][]byte{
|
|
"empty": nil,
|
|
"no PEM block": []byte("not a key"),
|
|
"garbage DER": pemOf("PRIVATE KEY", []byte("garbage")),
|
|
"P-384 private key": pemOf("PRIVATE KEY", p384der),
|
|
"Ed25519 private key": pemOf("PRIVATE KEY", edPriv),
|
|
"a public key as private": pemOf("PUBLIC KEY", rsaPub),
|
|
} {
|
|
if _, err := ParsePrivateKeyPEM(data); err == nil || !strings.HasPrefix(err.Error(), "wristband: ") {
|
|
t.Fatalf("private %s: %v", name, err)
|
|
}
|
|
}
|
|
for name, data := range map[string][]byte{
|
|
"empty": nil,
|
|
"no PEM block": []byte("not a key"),
|
|
"garbage DER": pemOf("PUBLIC KEY", []byte("garbage")),
|
|
"P-384 public key": pemOf("PUBLIC KEY", p384pub),
|
|
"a private key as public": pemOf("PRIVATE KEY", rsaPKCS8),
|
|
} {
|
|
if _, err := ParsePublicKeyPEM(data); err == nil || !strings.HasPrefix(err.Error(), "wristband: ") {
|
|
t.Fatalf("public %s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestES256Ring runs the flow on a ring whose only key is ES256: the
|
|
// id_token is ES256 with the active kid, the JWKS has one EC key and
|
|
// discovery lists ES256 only.
|
|
func TestES256Ring(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
f.keys.alg = AlgES256
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile"), oidcTestVerifier))
|
|
key, _ := f.keys.ActiveSigningKey(context.Background())
|
|
if h := jwtHeader(t, tok.IDToken); h["alg"] != "ES256" || h["kid"] != key.Kid {
|
|
t.Fatalf("header = %v", h)
|
|
}
|
|
c := f.parseIDToken(t, tok.IDToken)
|
|
if c["aud"] != f.client.ClientID || c["name"] != "Ada Example" {
|
|
t.Fatalf("claims = %v", c)
|
|
}
|
|
var jwks struct {
|
|
Keys []map[string]any `json:"keys"`
|
|
}
|
|
_ = json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks)
|
|
if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != "EC" || jwks.Keys[0]["kid"] != key.Kid {
|
|
t.Fatalf("jwks = %v", jwks.Keys)
|
|
}
|
|
if body := f.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"id_token_signing_alg_values_supported":["ES256"]`) {
|
|
t.Fatalf("discovery = %s", body)
|
|
}
|
|
// The ES256 signature is a JOSE R||S pair of 64 bytes.
|
|
if sig := strings.Split(tok.IDToken, ".")[2]; len(sig) != 86 {
|
|
t.Fatalf("ES256 signature has %d base64url characters, want 86", len(sig))
|
|
}
|
|
}
|
|
|
|
// TestMixedRingJWKS rotates between RS256 and ES256 in both directions: the
|
|
// JWKS serves both kinds in ring order inside the grace window and discovery
|
|
// always lists RS256 before ES256.
|
|
func TestMixedRingJWKS(t *testing.T) {
|
|
for _, order := range [][2]string{{AlgES256, AlgRS256}, {AlgRS256, AlgES256}} {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
f.keys.alg = order[0]
|
|
before := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
|
|
f.keys.rotate(t, order[1], time.Now())
|
|
|
|
var jwks struct {
|
|
Keys []map[string]any `json:"keys"`
|
|
}
|
|
_ = json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks)
|
|
kty := map[string]string{AlgRS256: "RSA", AlgES256: "EC"}
|
|
if len(jwks.Keys) != 2 || jwks.Keys[0]["kty"] != kty[order[0]] || jwks.Keys[1]["kty"] != kty[order[1]] {
|
|
t.Fatalf("%v: jwks = %v", order, jwks.Keys)
|
|
}
|
|
if body := f.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"id_token_signing_alg_values_supported":["RS256","ES256"]`) {
|
|
t.Fatalf("%v: discovery = %s", order, body)
|
|
}
|
|
f.parseIDToken(t, before.IDToken)
|
|
after := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
|
|
if jwtHeader(t, after.IDToken)["alg"] != order[1] {
|
|
t.Fatalf("%v: a new token is not signed with the new key", order)
|
|
}
|
|
f.parseIDToken(t, after.IDToken)
|
|
|
|
// Past the grace window the retired key and its algorithm leave.
|
|
f.srv.now = func() time.Time { return time.Now().Add(25 * time.Hour) }
|
|
_ = json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks)
|
|
if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != kty[order[1]] {
|
|
t.Fatalf("%v: jwks after grace = %v", order, jwks.Keys)
|
|
}
|
|
if body := f.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"id_token_signing_alg_values_supported":["`+order[1]+`"]`) {
|
|
t.Fatalf("%v: discovery after grace = %s", order, body)
|
|
}
|
|
}
|
|
}
|