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