Files
summercms/modules/wristband/oidc_token_test.go
Jakub Zych dd167466d2 test(19.4-04): unit tests for wristband's OIDC flavour
- 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
2026-10-19 21:05:21 +02:00

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