- 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
174 lines
8.1 KiB
Go
174 lines
8.1 KiB
Go
package wristband
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestRefreshOptIn pins D-17: the refresh_token grant exists only on a
|
|
// server with RefreshEnabled, only for an OfflineAccess client granted
|
|
// offline_access; each use rotates, a replay revokes the lineage and its
|
|
// access tokens (T-19.4-12), a narrower scope is allowed and a wider one is
|
|
// not, and the refreshed id_token carries no nonce.
|
|
func TestRefreshOptIn(t *testing.T) {
|
|
const unsupported = `{"error":"unsupported_grant_type","error_description":"Only the authorization_code grant is supported."}`
|
|
|
|
// Disabled server: the PHP answer, whatever the client.
|
|
disabled := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
for _, form := range []url.Values{
|
|
{"grant_type": {"refresh_token"}, "refresh_token": {"oas_rt_x"}},
|
|
{"grant_type": {"client_credentials"}},
|
|
{},
|
|
} {
|
|
rec := disabled.tokenCall(form, disabled.client.ClientID, disabled.clientSecret)
|
|
if rec.Code != http.StatusBadRequest || rec.Body.String() != unsupported {
|
|
t.Fatalf("disabled %v = %d %s", form, rec.Code, rec.Body.String())
|
|
}
|
|
assertNoStore(t, rec)
|
|
}
|
|
// offline_access is not even a supported scope there.
|
|
if loc := disabled.get("/oauth/authorize?" + oidcAuthorizeParams(disabled, func(q url.Values) { q.Set("scope", "openid offline_access") }).Encode()).Header().Get("Location"); !strings.Contains(loc, "not+supported") {
|
|
t.Fatalf("offline_access on a disabled server = %q", loc)
|
|
}
|
|
|
|
f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) {
|
|
o.RefreshEnabled = true
|
|
o.RefreshTokenTTL = time.Hour
|
|
})
|
|
rec := f.tokenCall(url.Values{"grant_type": {"password"}}, f.client.ClientID, f.clientSecret)
|
|
if rec.Body.String() != `{"error":"unsupported_grant_type","error_description":"Only the authorization_code and refresh_token grants are supported."}` {
|
|
t.Fatalf("enabled, unsupported grant = %s", rec.Body.String())
|
|
}
|
|
offline := f.backend.addClient(OIDCClientRecord{
|
|
ClientID: "oas_offline", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect},
|
|
Scopes: []string{"openid", "profile", "email", "offline_access"}, IsActive: true, OfflineAccess: true,
|
|
}, testOIDCSecret)
|
|
// offline_access in the scopes of a client without the flag is ignored.
|
|
flagless := f.backend.addClient(OIDCClientRecord{
|
|
ClientID: "oas_flagless", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect},
|
|
Scopes: []string{"openid", "offline_access"}, IsActive: true,
|
|
}, testOIDCSecret)
|
|
refresh := func(c *OIDCClientRecord, token, scope string) *httptest.ResponseRecorder {
|
|
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}}
|
|
if scope != "" {
|
|
form.Set("scope", scope)
|
|
}
|
|
return f.tokenCall(form, c.ClientID, testOIDCSecret)
|
|
}
|
|
|
|
if loc := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) {
|
|
q.Set("client_id", flagless.ClientID)
|
|
q.Set("scope", "openid offline_access")
|
|
}).Encode()).Header().Get("Location"); !strings.Contains(loc, "not+allowed+for+this+client") {
|
|
t.Fatalf("offline_access without OfflineAccess = %q", loc)
|
|
}
|
|
rec = refresh(flagless, "oas_rt_x", "")
|
|
if rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"unauthorized_client","error_description":"The client is not allowed to use the refresh_token grant."}` {
|
|
t.Fatalf("refresh without OfflineAccess = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// An OfflineAccess client without offline_access in the grant gets no
|
|
// refresh token.
|
|
if tok := decodeToken(t, f.exchange(offline, testOIDCSecret, f.obtainCode(t, offline, 7, "openid profile"), oidcTestVerifier)); tok.RefreshToken != "" {
|
|
t.Fatal("a refresh token without offline_access")
|
|
}
|
|
|
|
first := decodeToken(t, f.exchange(offline, testOIDCSecret, f.obtainCode(t, offline, 7, "openid profile email offline_access"), oidcTestVerifier))
|
|
if !strings.HasPrefix(first.RefreshToken, "oas_rt_") || first.Scope != "openid profile email offline_access" {
|
|
t.Fatalf("first = %+v", first)
|
|
}
|
|
row := func(raw string) *OIDCRefreshTokenRecord {
|
|
for _, r := range f.backend.refresh {
|
|
if r.TokenHash == sha256Hex(raw) {
|
|
return r
|
|
}
|
|
}
|
|
t.Fatal("no refresh row")
|
|
return nil
|
|
}
|
|
if r := row(first.RefreshToken); r.AccessTokenID == nil || r.ExpiresAt.Sub(time.Now()) > time.Hour || r.ClientID != offline.ClientID {
|
|
t.Fatalf("refresh row = %+v", r)
|
|
}
|
|
assertNoPlaintext(t, row(first.RefreshToken), first.RefreshToken)
|
|
|
|
// Errors that leave the token alone.
|
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
|
"missing token": f.tokenCall(url.Values{"grant_type": {"refresh_token"}}, offline.ClientID, testOIDCSecret),
|
|
"unprefixed token": refresh(offline, strings.TrimPrefix(first.RefreshToken, "oas_rt_"), ""),
|
|
"unknown token": refresh(offline, "oas_rt_unknown", ""),
|
|
"another client": refresh(f.client, first.RefreshToken, ""),
|
|
"wider scope": refresh(offline, first.RefreshToken, "openid phone"),
|
|
} {
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("%s = 200", name)
|
|
}
|
|
assertNoStore(t, rec)
|
|
}
|
|
if rec := refresh(offline, first.RefreshToken, "openid phone"); !strings.Contains(rec.Body.String(), `"error":"invalid_scope"`) {
|
|
t.Fatalf("wider scope = %s", rec.Body.String())
|
|
}
|
|
if rec := f.tokenCall(url.Values{"grant_type": {"refresh_token"}}, offline.ClientID, testOIDCSecret); !strings.Contains(rec.Body.String(), `"error":"invalid_request","error_description":"refresh_token is required."`) {
|
|
t.Fatalf("missing token = %s", rec.Body.String())
|
|
}
|
|
if row(first.RefreshToken).RotatedToID != nil || row(first.RefreshToken).RevokedAt != nil {
|
|
t.Fatal("a refused refresh changed the token")
|
|
}
|
|
|
|
// Rotation with a narrower scope.
|
|
second := decodeToken(t, refresh(offline, first.RefreshToken, "openid profile"))
|
|
if second.RefreshToken == first.RefreshToken || second.Scope != "openid profile" || second.AccessToken == first.AccessToken {
|
|
t.Fatalf("second = %+v", second)
|
|
}
|
|
if r := row(first.RefreshToken); r.RotatedToID == nil || *r.RotatedToID != row(second.RefreshToken).ID {
|
|
t.Fatal("the predecessor does not point at its successor")
|
|
}
|
|
c := f.parseIDToken(t, second.IDToken)
|
|
if _, ok := c["nonce"]; ok || c["at_hash"] != atHash(second.AccessToken) || c["aud"] != offline.ClientID || c["email"] != nil {
|
|
t.Fatalf("refreshed id_token claims = %v", c)
|
|
}
|
|
// The successor keeps the original grant: the full scope is still
|
|
// available on the next rotation.
|
|
third := decodeToken(t, refresh(offline, second.RefreshToken, ""))
|
|
if third.Scope != "openid profile email offline_access" {
|
|
t.Fatalf("third scope = %q", third.Scope)
|
|
}
|
|
if rec := f.userinfo(third.AccessToken); rec.Code != http.StatusOK {
|
|
t.Fatalf("userinfo with a refreshed token = %d", rec.Code)
|
|
}
|
|
|
|
// Replaying a rotated token revokes the whole lineage and its access
|
|
// tokens.
|
|
rec = refresh(offline, first.RefreshToken, "")
|
|
if rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"invalid_grant","error_description":"The refresh token is invalid, expired, revoked, or does not belong to this client."}` {
|
|
t.Fatalf("replay = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
for _, tok := range []string{first.RefreshToken, second.RefreshToken, third.RefreshToken} {
|
|
if row(tok).RevokedAt == nil {
|
|
t.Fatal("a token of the lineage survived the replay")
|
|
}
|
|
}
|
|
assertUserInfo401(t, f.userinfo(second.AccessToken))
|
|
assertUserInfo401(t, f.userinfo(third.AccessToken))
|
|
if rec := refresh(offline, third.RefreshToken, ""); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("the newest token after a replay = %d", rec.Code)
|
|
}
|
|
|
|
// Expired refresh tokens are refused.
|
|
fresh := decodeToken(t, f.exchange(offline, testOIDCSecret, f.obtainCode(t, offline, 7, "openid offline_access"), oidcTestVerifier))
|
|
later := time.Now().Add(2 * time.Hour)
|
|
f.srv.now = func() time.Time { return later }
|
|
if rec := refresh(offline, fresh.RefreshToken, ""); !strings.Contains(rec.Body.String(), `"invalid_grant"`) {
|
|
t.Fatalf("expired refresh = %s", rec.Body.String())
|
|
}
|
|
f.srv.now = time.Now
|
|
// A gone user cannot refresh.
|
|
delete(f.backend.identities, 7)
|
|
if rec := refresh(offline, fresh.RefreshToken, ""); !strings.Contains(rec.Body.String(), `"invalid_grant"`) {
|
|
t.Fatalf("refresh for a gone user = %s", rec.Body.String())
|
|
}
|
|
}
|