Files
summercms/modules/wristband/oidc_refresh_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

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