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