- 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
657 lines
28 KiB
Go
657 lines
28 KiB
Go
package wristband
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// randomVerifier is a conforming 43-character verifier: base64url of 32
|
|
// random bytes, unpadded.
|
|
func randomVerifier(t *testing.T) string {
|
|
t.Helper()
|
|
v, err := randomBase64URL(32)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// handChallenge restates the S256 transformation by hand, so the tests do
|
|
// not simply echo the implementation.
|
|
func handChallenge(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(sum[:]), "=")
|
|
}
|
|
|
|
// TestPKCEVerifier ports the PHP PkceVerifierTest (RFC 7636, T-19.4-02).
|
|
func TestPKCEVerifier(t *testing.T) {
|
|
t.Run("a_correctly_computed_s256_pair_verifies", func(t *testing.T) {
|
|
v := randomVerifier(t)
|
|
if len(v) != 43 {
|
|
t.Fatalf("fixture verifier has %d characters", len(v))
|
|
}
|
|
if !verifyOIDCPkce(v, handChallenge(v), "S256") {
|
|
t.Fatal("a correct S256 pair must verify")
|
|
}
|
|
if s256Challenge(v) != handChallenge(v) {
|
|
t.Fatal("s256Challenge disagrees with the hand-written transformation")
|
|
}
|
|
})
|
|
t.Run("a_verifier_that_does_not_hash_to_the_challenge_is_rejected", func(t *testing.T) {
|
|
if verifyOIDCPkce(randomVerifier(t), handChallenge(randomVerifier(t)), "S256") {
|
|
t.Fatal("a foreign verifier verified")
|
|
}
|
|
})
|
|
t.Run("the_no_op_transformation_is_rejected_even_when_verifier_equals_challenge", func(t *testing.T) {
|
|
v := randomVerifier(t)
|
|
for _, method := range []string{"plain", "PLAIN", "S256"} {
|
|
if verifyOIDCPkce(v, v, method) {
|
|
t.Fatalf("verifier == challenge verified with method %q", method)
|
|
}
|
|
}
|
|
})
|
|
t.Run("an_empty_method_is_rejected", func(t *testing.T) {
|
|
v := randomVerifier(t)
|
|
for _, method := range []string{"", "s256", "S512"} {
|
|
if verifyOIDCPkce(v, handChallenge(v), method) {
|
|
t.Fatalf("method %q verified", method)
|
|
}
|
|
}
|
|
})
|
|
t.Run("a_42_character_verifier_is_rejected", func(t *testing.T) {
|
|
v := strings.Repeat("a", 42)
|
|
if verifyOIDCPkce(v, handChallenge(v), "S256") {
|
|
t.Fatal("a 42-character verifier verified")
|
|
}
|
|
})
|
|
t.Run("a_129_character_verifier_is_rejected", func(t *testing.T) {
|
|
v := strings.Repeat("a", 129)
|
|
if verifyOIDCPkce(v, handChallenge(v), "S256") {
|
|
t.Fatal("a 129-character verifier verified")
|
|
}
|
|
limit := strings.Repeat("a", 128)
|
|
if !verifyOIDCPkce(limit, handChallenge(limit), "S256") {
|
|
t.Fatal("the 128-character boundary must verify")
|
|
}
|
|
})
|
|
t.Run("a_verifier_outside_the_unreserved_charset_is_rejected", func(t *testing.T) {
|
|
for _, c := range []string{"+", "/", "="} {
|
|
v := strings.Repeat("a", 42) + c
|
|
if verifyOIDCPkce(v, handChallenge(v), "S256") {
|
|
t.Fatalf("a verifier with %q verified", c)
|
|
}
|
|
}
|
|
// The unreserved punctuation is allowed.
|
|
v := strings.Repeat("a", 39) + "-._~"
|
|
if !verifyOIDCPkce(v, handChallenge(v), "S256") {
|
|
t.Fatal("a verifier with - . _ ~ must verify")
|
|
}
|
|
})
|
|
t.Run("a_padded_base64_challenge_does_not_verify", func(t *testing.T) {
|
|
v := randomVerifier(t)
|
|
sum := sha256.Sum256([]byte(v))
|
|
padded := base64.URLEncoding.EncodeToString(sum[:])
|
|
if !strings.HasSuffix(padded, "=") {
|
|
t.Fatalf("padded challenge %q has no padding", padded)
|
|
}
|
|
if verifyOIDCPkce(v, padded, "S256") {
|
|
t.Fatal("a padded challenge verified")
|
|
}
|
|
})
|
|
t.Run("an_empty_challenge_is_rejected", func(t *testing.T) {
|
|
if verifyOIDCPkce(randomVerifier(t), "", "S256") || verifyOIDCPkce("", "", "S256") {
|
|
t.Fatal("an empty challenge verified")
|
|
}
|
|
})
|
|
}
|
|
|
|
// jwtHeader decodes the header of a compact JWT without verifying it.
|
|
func jwtHeader(t *testing.T, raw string) map[string]any {
|
|
t.Helper()
|
|
seg, err := base64.RawURLEncoding.DecodeString(strings.Split(raw, ".")[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var h map[string]any
|
|
if err := json.Unmarshal(seg, &h); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// signWithFixture signs an id_token for in with the fixture's active key.
|
|
func (f *oidcFixture) signWithFixture(t *testing.T, in idTokenInput) string {
|
|
t.Helper()
|
|
key, err := f.keys.ActiveSigningKey(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if in.Issuer == "" {
|
|
in.Issuer = f.srv.oidcIssuer()
|
|
}
|
|
if in.Audience == "" {
|
|
in.Audience = f.client.ClientID
|
|
}
|
|
if in.TTL == 0 {
|
|
in.TTL = f.srv.oidc.IDTokenTTL
|
|
}
|
|
if in.Now.IsZero() {
|
|
in.Now = f.srv.now()
|
|
}
|
|
if in.Allowed == nil {
|
|
in.Allowed = f.srv.oidc.IdentityClaims
|
|
}
|
|
raw, err := signIDToken(key, in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// TestTokenIssuer ports the PHP TokenIssuerTest (D-12, D-14): every id_token
|
|
// is checked against the fixture's own served JWKS, as a relying party does.
|
|
func TestTokenIssuer(t *testing.T) {
|
|
t.Run("id_token_header_carries_rs256_and_the_active_kid", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
key, _ := f.keys.ActiveSigningKey(context.Background())
|
|
h := jwtHeader(t, f.signWithFixture(t, idTokenInput{Subject: "sub-1", AccessToken: "oas_at_x"}))
|
|
if h["typ"] != "JWT" || h["alg"] != "RS256" || h["kid"] != key.Kid {
|
|
t.Fatalf("header = %v, want JWT RS256 %s", h, key.Kid)
|
|
}
|
|
})
|
|
t.Run("id_token_verifies_against_the_served_jwks", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
raw := f.signWithFixture(t, idTokenInput{
|
|
Subject: "sub-1", Nonce: "nonce-abc", AccessToken: "oas_at_plaintext",
|
|
Identity: map[string]any{"sub": "sub-1", "name": "Ada Lovelace", "email": "ada@example.test", "email_verified": true},
|
|
})
|
|
c := f.parseIDToken(t, raw)
|
|
if c["sub"] != "sub-1" || c["name"] != "Ada Lovelace" || c["email"] != "ada@example.test" || c["email_verified"] != true {
|
|
t.Fatalf("claims = %v", c)
|
|
}
|
|
iat, _ := c["iat"].(float64)
|
|
exp, _ := c["exp"].(float64)
|
|
if iat == 0 || exp <= iat || iat != float64(int64(iat)) {
|
|
t.Fatalf("iat = %v, exp = %v", c["iat"], c["exp"])
|
|
}
|
|
})
|
|
t.Run("iss_comes_from_the_issuer_config_not_app_url", func(t *testing.T) {
|
|
f := newOIDCFixture(t, "https://issuer.example/", nil)
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
|
|
c := f.parseIDToken(t, tok.IDToken)
|
|
if c["iss"] != "https://issuer.example" {
|
|
t.Fatalf("iss = %v, want the configured issuer without its trailing slash", c["iss"])
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(f.get("/.well-known/openid-configuration").Body.Bytes(), &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if doc["issuer"] != c["iss"] {
|
|
t.Fatalf("discovery issuer %v differs from the id_token iss %v", doc["issuer"], c["iss"])
|
|
}
|
|
})
|
|
t.Run("aud_is_the_client_id_and_sub_is_the_subject_uuid", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
|
|
c := f.parseIDToken(t, tok.IDToken)
|
|
if c["aud"] != f.client.ClientID {
|
|
t.Fatalf("aud = %v, want %s", c["aud"], f.client.ClientID)
|
|
}
|
|
sub, _ := c["sub"].(string)
|
|
if !uuidV4.MatchString(sub) || sub == "7" || sub != f.backend.subject(7, "") {
|
|
t.Fatalf("sub = %q", sub)
|
|
}
|
|
other := f.backend.addClient(OIDCClientRecord{ClientID: "oas_other_app", Name: "Other App", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, testOIDCSecret)
|
|
tok = decodeToken(t, f.exchange(other, testOIDCSecret, f.obtainCode(t, other, 7, "openid"), oidcTestVerifier))
|
|
if oc := f.parseIDToken(t, tok.IDToken); oc["aud"] == c["aud"] || oc["aud"] != other.ClientID {
|
|
t.Fatalf("another client's aud = %v", oc["aud"])
|
|
}
|
|
})
|
|
t.Run("nonce_is_echoed_when_supplied_and_absent_when_not", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
if c := f.parseIDToken(t, f.signWithFixture(t, idTokenInput{Subject: "s", Nonce: "n-123"})); c["nonce"] != "n-123" {
|
|
t.Fatalf("nonce = %v", c["nonce"])
|
|
}
|
|
if c := f.parseIDToken(t, f.signWithFixture(t, idTokenInput{Subject: "s"})); c["nonce"] != nil {
|
|
t.Fatalf("nonce present without one: %v", c["nonce"])
|
|
}
|
|
// Through the flow: an authorize request without nonce gives an
|
|
// id_token without nonce, an empty nonce counts as none.
|
|
for _, n := range []*string{nil, new(string)} {
|
|
q := oidcAuthorizeParams(f, func(q url.Values) {
|
|
q.Set("scope", "openid")
|
|
if n == nil {
|
|
q.Del("nonce")
|
|
} else {
|
|
q.Set("nonce", *n)
|
|
}
|
|
})
|
|
f.host.signIn(7)
|
|
rec := f.startAndResume(t, q)
|
|
if rec.Code == http.StatusOK {
|
|
view := f.host.lastView(t)
|
|
rec = f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}})
|
|
}
|
|
u, _ := url.Parse(rec.Header().Get("Location"))
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, u.Query().Get("code"), oidcTestVerifier))
|
|
if _, ok := f.parseIDToken(t, tok.IDToken)["nonce"]; ok {
|
|
t.Fatalf("nonce claim present for nonce %v", n)
|
|
}
|
|
}
|
|
})
|
|
t.Run("at_hash_is_the_left_128_bits_of_the_sha256_of_the_plaintext_access_token", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
|
|
c := f.parseIDToken(t, tok.IDToken)
|
|
sum := sha256.Sum256([]byte(tok.AccessToken))
|
|
want := strings.TrimRight(base64.URLEncoding.EncodeToString(sum[:16]), "=")
|
|
if c["at_hash"] != want || len(want) != 22 {
|
|
t.Fatalf("at_hash = %v, want %s", c["at_hash"], want)
|
|
}
|
|
stored := sha256.Sum256([]byte(sha256Hex(tok.AccessToken)))
|
|
if c["at_hash"] == base64.RawURLEncoding.EncodeToString(stored[:16]) {
|
|
t.Fatal("at_hash was computed over the stored hash")
|
|
}
|
|
})
|
|
t.Run("exp_honours_the_configured_ttl", func(t *testing.T) {
|
|
for _, ttl := range []time.Duration{137 * time.Second, 900 * time.Second} {
|
|
f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.IDTokenTTL = ttl })
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
|
|
c := f.parseIDToken(t, tok.IDToken)
|
|
if got := int64(c["exp"].(float64) - c["iat"].(float64)); got != int64(ttl.Seconds()) {
|
|
t.Fatalf("exp - iat = %d, want %d", got, int64(ttl.Seconds()))
|
|
}
|
|
}
|
|
})
|
|
t.Run("access_token_is_stored_hashed_with_the_oas_at_prefix_and_is_usable", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.AccessTokenTTL = 271 * time.Second })
|
|
code := f.obtainCode(t, f.client, 7, "openid profile")
|
|
before := time.Now()
|
|
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier))
|
|
if !strings.HasPrefix(tok.AccessToken, "oas_at_") {
|
|
t.Fatalf("access token = %q", tok.AccessToken)
|
|
}
|
|
rows := f.backend.accessTokens()
|
|
if len(rows) != 1 {
|
|
t.Fatalf("%d access token rows", len(rows))
|
|
}
|
|
row := rows[0]
|
|
codeRow := f.backend.codes[len(f.backend.codes)-1]
|
|
if row.TokenHash != sha256Hex(tok.AccessToken) || row.ClientID != f.client.ClientID || row.UserID != 7 ||
|
|
row.AuthCodeID == nil || *row.AuthCodeID != codeRow.ID || strings.Join(row.Scopes, " ") != "openid profile" {
|
|
t.Fatalf("row = %+v", row)
|
|
}
|
|
if d := row.ExpiresAt.Sub(before); d < 266*time.Second || d > 276*time.Second {
|
|
t.Fatalf("ExpiresAt - now = %s, want about 271s", d)
|
|
}
|
|
assertNoPlaintext(t, row, tok.AccessToken)
|
|
if rec := f.userinfo(tok.AccessToken); rec.Code != http.StatusOK {
|
|
t.Fatalf("userinfo with the access token = %d", rec.Code)
|
|
}
|
|
var body map[string]any
|
|
_ = json.Unmarshal(f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier).Body.Bytes(), &body)
|
|
if body["expires_in"] != float64(271) {
|
|
t.Fatalf("expires_in = %v, want 271", body["expires_in"])
|
|
}
|
|
})
|
|
t.Run("a_token_signed_before_a_rotation_still_verifies_during_the_grace_window", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
key, _ := f.keys.ActiveSigningKey(context.Background())
|
|
raw := f.signWithFixture(t, idTokenInput{Subject: "sub-1", AccessToken: "oas_at_x"})
|
|
if jwtHeader(t, raw)["kid"] != key.Kid {
|
|
t.Fatal("the token is not signed by the active key")
|
|
}
|
|
newKid := f.keys.rotate(t, AlgRS256, time.Now())
|
|
if newKid == key.Kid {
|
|
t.Fatal("rotation kept the kid")
|
|
}
|
|
var jwks struct {
|
|
Keys []map[string]any `json:"keys"`
|
|
}
|
|
if err := json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kids := []any{}
|
|
for _, k := range jwks.Keys {
|
|
kids = append(kids, k["kid"])
|
|
}
|
|
if len(kids) != 2 || kids[0] != key.Kid || kids[1] != newKid {
|
|
t.Fatalf("published kids = %v", kids)
|
|
}
|
|
if c := f.parseIDToken(t, raw); c["sub"] != "sub-1" {
|
|
t.Fatalf("sub = %v", c["sub"])
|
|
}
|
|
if jwtHeader(t, f.signWithFixture(t, idTokenInput{Subject: "sub-1"}))["kid"] != newKid {
|
|
t.Fatal("a token issued after rotation is not signed by the new key")
|
|
}
|
|
})
|
|
t.Run("identity_claims_cannot_overwrite_the_protocol_claims", func(t *testing.T) {
|
|
f := newOIDCFixture(t, "https://issuer.example", nil)
|
|
now := time.Now()
|
|
raw := f.signWithFixture(t, idTokenInput{
|
|
Subject: "the-real-sub",
|
|
Now: now,
|
|
Identity: map[string]any{
|
|
"iss": "https://attacker.example", "aud": "oas_someone_else", "sub": "the-wrong-sub",
|
|
"exp": now.Unix() + 999999, "nonce": "injected", "scope": "admin", "name": "Ada Lovelace",
|
|
"at_hash": "forged", "iat": 1,
|
|
},
|
|
// Even an allowlist that names protocol claims cannot copy them.
|
|
Allowed: append(append([]string{}, DefaultOIDCOptions().IdentityClaims...), "iss", "aud", "exp", "nonce", "at_hash", "iat"),
|
|
})
|
|
c := f.parseIDToken(t, raw)
|
|
if c["iss"] != "https://issuer.example" || c["aud"] != f.client.ClientID || c["sub"] != "the-real-sub" || c["name"] != "Ada Lovelace" {
|
|
t.Fatalf("claims = %v", c)
|
|
}
|
|
for _, k := range []string{"nonce", "scope", "at_hash"} {
|
|
if _, ok := c[k]; ok {
|
|
t.Fatalf("claim %s copied from the identity map: %v", k, c)
|
|
}
|
|
}
|
|
if exp := int64(c["exp"].(float64)); exp >= now.Unix()+10000 || int64(c["iat"].(float64)) != now.Unix() {
|
|
t.Fatalf("exp = %d, iat = %v", exp, c["iat"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// assertNoPlaintext fails when any string field of rec carries secret.
|
|
func assertNoPlaintext(t *testing.T, rec any, secret string) {
|
|
t.Helper()
|
|
v := reflect.Indirect(reflect.ValueOf(rec))
|
|
for i := 0; i < v.NumField(); i++ {
|
|
fv := reflect.Indirect(v.Field(i))
|
|
if fv.IsValid() && fv.Kind() == reflect.String && strings.Contains(fv.String(), secret) {
|
|
t.Fatalf("field %s carries the plaintext", v.Type().Field(i).Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// captureLogs routes slog to a buffer for the test.
|
|
func captureLogs(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
return &buf
|
|
}
|
|
|
|
// codeRecord returns the code row of a raw code.
|
|
func (f *oidcFixture) codeRecord(t *testing.T, raw string) *OIDCCodeRecord {
|
|
t.Helper()
|
|
f.backend.mu.Lock()
|
|
defer f.backend.mu.Unlock()
|
|
for _, c := range f.backend.codes {
|
|
if c.CodeHash == sha256Hex(raw) {
|
|
cp := *c
|
|
return &cp
|
|
}
|
|
}
|
|
t.Fatalf("no code row for %q", raw)
|
|
return nil
|
|
}
|
|
|
|
// TestAuthCodeLifecycle ports the PHP AuthCodeLifecycleTest (T-19.4-02,
|
|
// T-19.4-03) through the engine: a code is never at rest in a usable form
|
|
// and redeems once.
|
|
func TestAuthCodeLifecycle(t *testing.T) {
|
|
t.Run("code_is_stored_hashed_and_never_in_plaintext", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid profile")
|
|
if !strings.HasPrefix(code, "oas_ac_") {
|
|
t.Fatalf("code = %q", code)
|
|
}
|
|
row := f.codeRecord(t, code)
|
|
if row.CodeHash != sha256Hex(code) {
|
|
t.Fatal("the code is not stored as its SHA-256")
|
|
}
|
|
assertNoPlaintext(t, row, code)
|
|
})
|
|
t.Run("code_can_be_consumed_exactly_once", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier))
|
|
if f.codeRecord(t, code).ConsumedAt == nil {
|
|
t.Fatal("a redeemed code is not marked consumed")
|
|
}
|
|
if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("second redemption = %d", rec.Code)
|
|
}
|
|
})
|
|
t.Run("expired_code_cannot_be_consumed", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
later := time.Now().Add(5 * time.Minute)
|
|
f.srv.now = func() time.Time { return later }
|
|
if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expired code = %d", rec.Code)
|
|
}
|
|
if f.codeRecord(t, code).ConsumedAt != nil {
|
|
t.Fatal("an expired code was consumed")
|
|
}
|
|
})
|
|
t.Run("replay_is_detectable_after_consume", func(t *testing.T) {
|
|
logs := captureLogs(t)
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
if f.codeRecord(t, code).ConsumedAt != nil {
|
|
t.Fatal("a fresh code is consumed")
|
|
}
|
|
decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier))
|
|
f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)
|
|
if !strings.Contains(logs.String(), "reason=invalid_grant:replay") {
|
|
t.Fatalf("a replay is not detected:\n%s", logs)
|
|
}
|
|
logs.Reset()
|
|
f.exchange(f.client, f.clientSecret, "oas_ac_never-issued", oidcTestVerifier)
|
|
if strings.Contains(logs.String(), "replay") || !strings.Contains(logs.String(), "reason=invalid_grant:unknown_or_expired") {
|
|
t.Fatalf("an unknown code counts as a replay:\n%s", logs)
|
|
}
|
|
})
|
|
t.Run("default_ttl_comes_from_config", func(t *testing.T) {
|
|
for _, ttl := range []time.Duration{DefaultOIDCOptions().CodeTTL, 137 * time.Second} {
|
|
f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.CodeTTL = ttl })
|
|
before := time.Now()
|
|
row := f.codeRecord(t, f.obtainCode(t, f.client, 7, "openid"))
|
|
if d := row.ExpiresAt.Sub(before) - ttl; d < -2*time.Second || d > 2*time.Second {
|
|
t.Fatalf("ExpiresAt is %s off a TTL of %s", d, ttl)
|
|
}
|
|
}
|
|
if DefaultOIDCOptions().CodeTTL != 60*time.Second {
|
|
t.Fatal("the default code TTL is not 60 seconds")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPKCEAndReplay ports the PHP PkceAndReplayTest: PKCE at the token
|
|
// endpoint and single use with revocation (RFC 6749 §10.5).
|
|
func TestPKCEAndReplay(t *testing.T) {
|
|
t.Run("missing_code_verifier_is_rejected", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
for _, form := range []url.Values{
|
|
{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testOIDCRedirect}},
|
|
{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {""}},
|
|
} {
|
|
rec := f.tokenCall(form, f.client.ClientID, f.clientSecret)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_request"`) {
|
|
t.Fatalf("no verifier = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if n := len(f.backend.accessTokens()); n != 0 {
|
|
t.Fatalf("%d access tokens issued", n)
|
|
}
|
|
decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier))
|
|
})
|
|
t.Run("wrong_code_verifier_returns_invalid_grant", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
rec := f.exchange(f.client, f.clientSecret, code, "oauthserver-test-verifier-9876543210zyxwvutsrq")
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_grant"`) {
|
|
t.Fatalf("wrong verifier = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(f.backend.accessTokens()) != 0 || f.codeRecord(t, code).ConsumedAt == nil {
|
|
t.Fatal("a wrong verifier must issue nothing and burn the code")
|
|
}
|
|
if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("right verifier after a guess = %d", rec.Code)
|
|
}
|
|
})
|
|
t.Run("a_code_bound_to_the_no_op_challenge_method_never_verifies", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
raw := "oas_ac_minted-directly-with-the-plain-method"
|
|
if err := f.backend.WithinOIDCTx(context.Background(), func(tx OIDCTx) error {
|
|
return tx.CreateCode(context.Background(), &OIDCCodeRecord{
|
|
CodeHash: sha256Hex(raw), ClientID: f.client.ClientID, UserID: 7, RedirectURI: testOIDCRedirect,
|
|
Scopes: []string{"openid"}, CodeChallenge: oidcTestVerifier, CodeChallengeMethod: "plain",
|
|
ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := f.exchange(f.client, f.clientSecret, raw, oidcTestVerifier)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_grant"`) || len(f.backend.accessTokens()) != 0 {
|
|
t.Fatalf("plain-method code = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
t.Run("replayed_code_returns_invalid_grant", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier))
|
|
replay := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)
|
|
if replay.Code != http.StatusBadRequest || !strings.Contains(replay.Body.String(), `"error":"invalid_grant"`) {
|
|
t.Fatalf("replay = %d %s", replay.Code, replay.Body.String())
|
|
}
|
|
if len(f.backend.accessTokens()) != 1 {
|
|
t.Fatal("a replay issued a second token")
|
|
}
|
|
unknown := f.exchange(f.client, f.clientSecret, "oas_ac_"+strings.Repeat("z", 43), oidcTestVerifier)
|
|
if unknown.Body.String() != replay.Body.String() {
|
|
t.Fatalf("an unknown code answers %s, a replay %s", unknown.Body.String(), replay.Body.String())
|
|
}
|
|
})
|
|
t.Run("replayed_code_revokes_the_token_previously_issued_from_it", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
first := decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier))
|
|
if rec := f.userinfo(first.AccessToken); rec.Code != http.StatusOK {
|
|
t.Fatalf("userinfo before the replay = %d", rec.Code)
|
|
}
|
|
if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("replay = %d", rec.Code)
|
|
}
|
|
assertUserInfo401(t, f.userinfo(first.AccessToken))
|
|
if f.backend.accessTokens()[0].RevokedAt == nil {
|
|
t.Fatal("the token issued from the replayed code is not revoked")
|
|
}
|
|
})
|
|
t.Run("expired_code_returns_invalid_grant", func(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
code := f.obtainCode(t, f.client, 7, "openid")
|
|
later := time.Now().Add(f.srv.oidc.CodeTTL + 5*time.Second)
|
|
f.srv.now = func() time.Time { return later }
|
|
rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_grant"`) {
|
|
t.Fatalf("expired = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(f.backend.accessTokens()) != 0 || f.codeRecord(t, code).ConsumedAt != nil {
|
|
t.Fatal("an expired code must issue nothing and stay unconsumed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOIDCClientAuthentication pins the PHP client authentication of the
|
|
// token endpoint (T-19.4-06): Basic first with any case of the scheme and
|
|
// percent-decoded halves, then the form; one uniform invalid_client with
|
|
// the lowercase realm.
|
|
func TestOIDCClientAuthentication(t *testing.T) {
|
|
creds := func(header string, form url.Values) (string, string) {
|
|
r := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if header != "" {
|
|
r.Header.Set("Authorization", header)
|
|
}
|
|
_ = r.ParseForm()
|
|
return oidcClientCredentials(r)
|
|
}
|
|
b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
|
form := url.Values{"client_id": {"form-id"}, "client_secret": {"form-secret"}}
|
|
|
|
for _, tc := range []struct {
|
|
name, header string
|
|
wantID, wantSc string
|
|
}{
|
|
{"basic", "Basic " + b64("id:secret"), "id", "secret"},
|
|
{"scheme case-insensitive", "bAsIc " + b64("id:secret"), "id", "secret"},
|
|
{"percent-decoded halves", "Basic " + b64("a%2Bb:c%3Ad+e%7E"), "a+b", "c:d e~"},
|
|
{"split on the first colon", "Basic " + b64("id:sec:ret"), "id", "sec:ret"},
|
|
{"unpadded base64", "Basic " + strings.TrimRight(b64("id:secre"), "="), "id", "secre"},
|
|
{"empty id falls back to the form", "Basic " + b64(":secret"), "form-id", "form-secret"},
|
|
{"no colon falls back to the form", "Basic " + b64("idsecret"), "form-id", "form-secret"},
|
|
{"not base64 falls back to the form", "Basic !!!", "form-id", "form-secret"},
|
|
{"missing scheme prefix falls back to the form", b64("id:secret"), "form-id", "form-secret"},
|
|
{"bearer is not Basic", "Bearer " + b64("id:secret"), "form-id", "form-secret"},
|
|
{"no header", "", "form-id", "form-secret"},
|
|
} {
|
|
id, sc := creds(tc.header, form)
|
|
if id != tc.wantID || sc != tc.wantSc {
|
|
t.Errorf("%s: credentials = %q %q, want %q %q", tc.name, id, sc, tc.wantID, tc.wantSc)
|
|
}
|
|
}
|
|
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
want := `{"error":"invalid_client","error_description":"Client authentication failed."}`
|
|
inactive := f.backend.addClient(OIDCClientRecord{ClientID: "oas_inactive", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}}, testOIDCSecret)
|
|
mcpKind := f.backend.addClient(OIDCClientRecord{ClientID: "oas_mcp", Kind: ClientKindMCP, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, testOIDCSecret)
|
|
noSecret := f.backend.addClient(OIDCClientRecord{ClientID: "oas_nosecret", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, "")
|
|
unprefixed := "plain-secret-of-the-right-length-0123456789abcdef"
|
|
plain := f.backend.addClient(OIDCClientRecord{ClientID: "oas_plain", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, unprefixed)
|
|
for _, tc := range []struct {
|
|
name, id, secret string
|
|
}{
|
|
{"unknown client (dummy comparison)", "oas_unknown", testOIDCSecret},
|
|
{"wrong secret", f.client.ClientID, "oas_cs_wrong"},
|
|
{"empty secret", f.client.ClientID, ""},
|
|
{"inactive client", inactive.ClientID, testOIDCSecret},
|
|
{"MCP-kind client", mcpKind.ClientID, testOIDCSecret},
|
|
{"client without a secret", noSecret.ClientID, testOIDCSecret},
|
|
{"secret without the prefix", plain.ClientID, unprefixed},
|
|
} {
|
|
rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}, "client_id": {tc.id}, "client_secret": {tc.secret}}, "", "")
|
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != want || rec.Header().Get("WWW-Authenticate") != `Basic realm="oauth"` {
|
|
t.Errorf("%s = %d %q %q", tc.name, rec.Code, rec.Body.String(), rec.Header().Get("WWW-Authenticate"))
|
|
}
|
|
}
|
|
// No credentials at all: the same answer.
|
|
if rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}}, "", ""); rec.Code != http.StatusUnauthorized || rec.Body.String() != want {
|
|
t.Fatalf("no credentials = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
// Basic wins over the form: a wrong Basic secret fails although the form
|
|
// carries the right one.
|
|
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(url.Values{
|
|
"grant_type": {"authorization_code"}, "client_id": {f.client.ClientID}, "client_secret": {f.clientSecret},
|
|
}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.SetBasicAuth(f.client.ClientID, "oas_cs_wrong")
|
|
rec := httptest.NewRecorder()
|
|
f.mux().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("Basic over form = %d", rec.Code)
|
|
}
|
|
// The verified client reaches the grant (here: missing parameters).
|
|
if rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}}, f.client.ClientID, f.clientSecret); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("authenticated client = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|