Files
probo/e2e/console/oauth2_test.go
Émile Ré d08e4c3314 Expect verified email in OAuth2 ID token e2e
E2E owners confirm email during setup so password re-login works,
so ID token claim assertions must expect email_verified true.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-28 15:11:06 +02:00

3618 lines
101 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package console_test
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
// ---------------------------------------------------------------------------
// 1. Discovery and JWKS
// ---------------------------------------------------------------------------
func TestOAuth2_Discovery(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
discovery, raw, err := testutil.OAuth2Discovery(owner)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, discovery)
assert.NotEmpty(t, discovery.Issuer)
assert.Contains(t, discovery.AuthorizationEndpoint, "/oauth2/authorize")
assert.Contains(t, discovery.TokenEndpoint, "/oauth2/token")
assert.Contains(t, discovery.UserinfoEndpoint, "/oauth2/userinfo")
assert.Contains(t, discovery.JwksURI, "/oauth2/jwks")
assert.Contains(t, discovery.RegistrationEndpoint, "/oauth2/register")
assert.Contains(t, discovery.IntrospectionEndpoint, "/oauth2/introspect")
assert.Contains(t, discovery.RevocationEndpoint, "/oauth2/revoke")
assert.Contains(t, discovery.DeviceAuthorizationEndpoint, "/oauth2/device")
assert.Contains(t, discovery.GrantTypesSupported, "authorization_code")
assert.Contains(t, discovery.GrantTypesSupported, "refresh_token")
assert.Contains(t, discovery.GrantTypesSupported, "urn:ietf:params:oauth:grant-type:device_code")
assert.Contains(t, discovery.ScopesSupported, "openid")
assert.Contains(t, discovery.ScopesSupported, "profile")
assert.Contains(t, discovery.ScopesSupported, "email")
assert.Contains(t, discovery.ScopesSupported, "offline_access")
assert.Contains(t, discovery.ScopesSupported, "v1:document:read")
assert.Contains(t, discovery.ResponseTypesSupported, "code")
assert.Contains(t, discovery.CodeChallengeMethodsSupported, "S256")
assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "client_secret_basic")
assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "client_secret_post")
assert.Contains(t, discovery.TokenEndpointAuthMethodsSupported, "none")
assert.Contains(t, discovery.RevocationEndpointAuthMethodsSupported, "client_secret_basic")
assert.Contains(t, discovery.RevocationEndpointAuthMethodsSupported, "client_secret_post")
assert.Contains(t, discovery.RevocationEndpointAuthMethodsSupported, "none")
assert.Contains(t, discovery.IntrospectionEndpointAuthMethodsSupported, "client_secret_basic")
assert.Contains(t, discovery.IntrospectionEndpointAuthMethodsSupported, "client_secret_post")
assert.Contains(t, discovery.IntrospectionEndpointAuthMethodsSupported, "none")
assert.Contains(t, discovery.SubjectTypesSupported, "public")
assert.Contains(t, discovery.IDTokenSigningAlgValuesSupported, "RS256")
assert.Contains(t, discovery.ClaimsSupported, "iss")
assert.Contains(t, discovery.ClaimsSupported, "sub")
assert.Contains(t, discovery.ClaimsSupported, "aud")
assert.Contains(t, discovery.ClaimsSupported, "exp")
assert.Contains(t, discovery.ClaimsSupported, "iat")
assert.Contains(t, discovery.ClaimsSupported, "auth_time")
assert.Contains(t, discovery.ClaimsSupported, "nonce")
assert.Contains(t, discovery.ClaimsSupported, "at_hash")
assert.Contains(t, discovery.ClaimsSupported, "email")
assert.Contains(t, discovery.ClaimsSupported, "email_verified")
assert.Contains(t, discovery.ClaimsSupported, "name")
assert.True(t, discovery.ClientIDMetadataDocumentSupported)
}
func TestOAuth2_ProtectedResourceMetadata(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
metadata, raw, err := testutil.OAuth2ProtectedResourceMetadata(owner)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, metadata)
expectedResource := owner.BaseURL()
assert.Equal(t, expectedResource, metadata.Resource)
assert.Contains(t, metadata.AuthorizationServers, expectedResource)
assert.Contains(t, metadata.BearerMethodsSupported, "header")
assert.Contains(t, metadata.ScopesSupported, "openid")
assert.Contains(t, metadata.ScopesSupported, "v1:document")
assert.NotContains(t, metadata.ScopesSupported, "v1:document:read")
assert.NotContains(t, metadata.ScopesSupported, "profile")
}
func TestOAuth2_RegisterClientWithAPIScope(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
resp, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{
"organization_id": owner.GetOrganizationID().String(),
"client_name": "API scope client",
"visibility": "private",
"redirect_uris": []string{"http://localhost:9999/callback"},
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "client_secret_basic",
"scopes": "openid v1:document:read",
})
require.NoError(t, err)
require.Equal(t, http.StatusCreated, raw.StatusCode)
require.NotNil(t, resp)
assert.Contains(t, resp.Scopes, "v1:document:read")
}
func TestOAuth2_JWKS(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
jwks, raw, err := testutil.OAuth2JWKS(owner)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, jwks)
require.NotEmpty(t, jwks.Keys, "JWKS must contain at least one key")
key := jwks.Keys[0]
assert.Equal(t, "RSA", key["kty"])
assert.NotEmpty(t, key["kid"])
assert.NotEmpty(t, key["n"])
assert.NotEmpty(t, key["e"])
assert.Equal(t, "sig", key["use"])
assert.Equal(t, "RS256", key["alg"])
}
// ---------------------------------------------------------------------------
// 2. Dynamic Client Registration
// ---------------------------------------------------------------------------
func TestOAuth2_RegisterClient(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"happy path with confidential client",
func(t *testing.T) {
t.Parallel()
result := factory.CreateOAuth2Client(owner, nil)
assert.NotEmpty(t, result.ClientID)
assert.NotEmpty(t, result.ClientSecret)
},
)
t.Run(
"public client has no secret",
func(t *testing.T) {
t.Parallel()
result := factory.CreatePublicOAuth2Client(owner, nil)
assert.NotEmpty(t, result.ClientID)
assert.Empty(t, result.ClientSecret)
},
)
t.Run(
"invalid redirect URI for private client",
func(t *testing.T) {
t.Parallel()
_, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{
"organization_id": owner.GetOrganizationID().String(),
"client_name": factory.SafeName("Bad Redirect"),
"visibility": "private",
"redirect_uris": []string{"http://evil.example.com/callback"},
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
"scopes": "openid",
})
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, raw.StatusCode)
},
)
t.Run(
"non-member cannot register",
func(t *testing.T) {
t.Parallel()
otherOwner := testutil.NewClient(t, testutil.RoleOwner)
_, raw, err := testutil.OAuth2RegisterClient(otherOwner, map[string]any{
"organization_id": owner.GetOrganizationID().String(),
"client_name": factory.SafeName("Foreign Client"),
"visibility": "private",
"redirect_uris": []string{"http://localhost:9999/callback"},
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
"scopes": "openid",
})
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, raw.StatusCode)
},
)
}
// ---------------------------------------------------------------------------
// 3. Authorization Code Flow (with PKCE)
// ---------------------------------------------------------------------------
func TestOAuth2_AuthorizationCodeFlow(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"full happy path with PKCE",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokenResp := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
assert.NotEmpty(t, tokenResp.AccessToken)
assert.NotEmpty(t, tokenResp.RefreshToken)
assert.NotEmpty(t, tokenResp.IDToken)
assert.Equal(t, "Bearer", tokenResp.TokenType)
assert.Greater(t, tokenResp.ExpiresIn, int64(0))
assert.Contains(t, tokenResp.Scope, "openid")
},
)
t.Run(
"consent deny returns access_denied",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
_ = verifier
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"deny-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
require.True(t, testutil.IsConsentRedirect(authResp), "expected consent redirect")
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
denyResp, err := testutil.OAuth2ConsentDeny(owner, consentID)
require.NoError(t, err)
require.Equal(t, http.StatusFound, denyResp.StatusCode)
loc := denyResp.Header.Get("Location")
assert.Contains(t, loc, "error=access_denied")
},
)
t.Run(
"invalid scope",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid super_admin"},
"state": {"scope-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode)
}
},
)
t.Run(
"bad code verifier fails token exchange",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"pkce-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
_, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
"wrong-verifier-that-does-not-match-the-challenge-at-all",
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode)
},
)
t.Run(
"code reuse fails",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"reuse-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, tokenResp)
_, raw2, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw2.StatusCode, "second exchange should fail")
},
)
}
// ---------------------------------------------------------------------------
// 4. Refresh Token Flow
// ---------------------------------------------------------------------------
func TestOAuth2_RefreshToken(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"token rotation",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
firstTokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
refreshResp, raw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.RefreshToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode, "refresh failed: %s", string(raw.Body))
require.NotNil(t, refreshResp)
assert.NotEqual(t, firstTokens.AccessToken, refreshResp.AccessToken)
assert.NotEqual(t, firstTokens.RefreshToken, refreshResp.RefreshToken)
assert.NotEmpty(t, refreshResp.IDToken, "should include id_token for openid scope")
assert.Equal(t, "Bearer", refreshResp.TokenType)
assert.Greater(t, refreshResp.ExpiresIn, int64(0))
},
)
t.Run(
"replay detection revokes all tokens",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
firstTokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
secondTokens, raw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.RefreshToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, secondTokens)
_, replayRaw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, replayRaw.StatusCode, "replayed refresh token should fail")
_, newRaw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
secondTokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(
t,
http.StatusOK,
newRaw.StatusCode,
"new refresh token should also be revoked after replay detection",
)
},
)
t.Run(
"cross-client refresh token theft rejected",
func(t *testing.T) {
t.Parallel()
clientA := factory.CreateOAuth2Client(owner, nil)
clientB := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
clientA.ClientID,
clientA.ClientSecret,
redirectURI,
)
_, raw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
clientB.ClientID,
clientB.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode,
"client B must not be able to use client A's refresh token")
},
)
t.Run(
"invalid refresh token",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
_, raw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
"totally-invalid-refresh-token",
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode)
},
)
}
// ---------------------------------------------------------------------------
// 5. Device Code Flow
// ---------------------------------------------------------------------------
func TestOAuth2_DeviceCodeFlow(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"full happy path",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
deviceResp, raw, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid email profile",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode, "device auth failed: %s", string(raw.Body))
require.NotNil(t, deviceResp)
assert.NotEmpty(t, deviceResp.DeviceCode)
assert.NotEmpty(t, deviceResp.UserCode)
assert.NotEmpty(t, deviceResp.VerificationURI)
assert.Greater(t, deviceResp.ExpiresIn, 0)
assert.Greater(t, deviceResp.Interval, 0)
_, errResp, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp)
assert.Equal(t, "authorization_pending", errResp.Code)
userCode := deviceResp.UserCode
verifyResp, err := testutil.OAuth2DeviceVerify(owner, userCode)
require.NoError(t, err)
require.Equal(t, http.StatusOK, verifyResp.StatusCode, "device verify failed: %s", string(verifyResp.Body))
time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second)
tokenResp, _, pollRaw, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, pollRaw.StatusCode, "device token poll failed: %s", string(pollRaw.Body))
require.NotNil(t, tokenResp)
assert.NotEmpty(t, tokenResp.AccessToken)
assert.Equal(t, "Bearer", tokenResp.TokenType)
assert.Greater(t, tokenResp.ExpiresIn, int64(0))
},
)
t.Run(
"invalid client",
func(t *testing.T) {
t.Parallel()
_, raw, err := testutil.OAuth2DeviceAuth(
owner,
"nonexistent-client-id",
"openid",
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode)
},
)
t.Run(
"slow down polling",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
deviceResp, _, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid",
)
require.NoError(t, err)
require.NotNil(t, deviceResp)
_, errResp1, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp1)
assert.Equal(t, "authorization_pending", errResp1.Code)
_, errResp2, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp2)
assert.Equal(t, "slow_down", errResp2.Code)
},
)
}
// ---------------------------------------------------------------------------
// 6. Token Introspection
// ---------------------------------------------------------------------------
func TestOAuth2_Introspect(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"active token",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
introspect, raw, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
assert.True(t, introspect.Active)
assert.Equal(t, "Bearer", introspect.TokenType)
assert.NotEmpty(t, introspect.Sub)
assert.Greater(t, introspect.Exp, int64(0))
assert.NotEmpty(t, introspect.Scope, "introspection should return scope")
assert.Equal(t, client.ClientID, introspect.ClientID, "introspection should return client_id")
},
)
t.Run(
"revoked token is inactive",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
revokeRaw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, revokeRaw.StatusCode)
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active)
},
)
t.Run(
"wrong client gets inactive",
func(t *testing.T) {
t.Parallel()
clientA := factory.CreateOAuth2Client(owner, nil)
clientB := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
clientA.ClientID,
clientA.ClientSecret,
redirectURI,
)
introspect, _, err := testutil.OAuth2Introspect(
owner,
clientB.ClientID,
clientB.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active)
},
)
t.Run(
"bad client auth",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
_, raw, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
"wrong-secret",
"some-token",
)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
},
)
t.Run(
"active refresh token",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
require.NotEmpty(t, tokens.RefreshToken,
"authorization code flow must mint a refresh token")
introspect, raw, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
assert.True(t, introspect.Active,
"valid refresh token must introspect as active")
assert.Empty(t, introspect.TokenType,
"refresh tokens have no OAuth2 token_type per RFC 6749 §5.1")
assert.Equal(t, client.ClientID, introspect.ClientID)
assert.NotEmpty(t, introspect.Sub)
assert.Greater(t, introspect.Exp, int64(0))
assert.NotEmpty(t, introspect.Scope)
},
)
t.Run(
"refresh token with matching hint",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
introspect, raw, err := testutil.OAuth2IntrospectWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
"refresh_token",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
assert.True(t, introspect.Active)
},
)
t.Run(
"access token still resolves with refresh_token hint",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
introspect, raw, err := testutil.OAuth2IntrospectWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
"refresh_token",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
assert.True(t, introspect.Active,
"hint is advisory, server must fall back to other token types")
assert.Equal(t, "Bearer", introspect.TokenType)
},
)
t.Run(
"revoked refresh token is inactive",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
revokeRaw, err := testutil.OAuth2RevokeWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
"refresh_token",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, revokeRaw.StatusCode)
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active,
"revoked refresh token must introspect as inactive")
},
)
t.Run(
"refresh token from different client is inactive",
func(t *testing.T) {
t.Parallel()
clientA := factory.CreateOAuth2Client(owner, nil)
clientB := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
clientA.ClientID,
clientA.ClientSecret,
redirectURI,
)
introspect, _, err := testutil.OAuth2Introspect(
owner,
clientB.ClientID,
clientB.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active,
"refresh token must only be introspectable by its issuing client")
},
)
}
// ---------------------------------------------------------------------------
// 7. Token Revocation
// ---------------------------------------------------------------------------
func TestOAuth2_Revoke(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"revoke access token then introspect is inactive",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
raw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active)
},
)
t.Run(
"revoke refresh token then refresh fails",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
raw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
_, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, refreshRaw.StatusCode)
},
)
t.Run(
"unknown token returns 200 per RFC 7009",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
raw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
client.ClientSecret,
"unknown-token-that-does-not-exist",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
},
)
t.Run(
"bad client auth",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
raw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
"wrong-secret",
"some-token",
)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
},
)
t.Run(
"token_type_hint=access_token revokes access token",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
raw, err := testutil.OAuth2RevokeWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
"access_token",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active)
},
)
t.Run(
"token_type_hint=refresh_token revokes refresh token",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
raw, err := testutil.OAuth2RevokeWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
"refresh_token",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
_, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, refreshRaw.StatusCode)
},
)
t.Run(
"wrong token_type_hint still finds and revokes the token",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
// Send access token with refresh_token hint — server must
// extend search across all types per RFC 7009 §2.1.
raw, err := testutil.OAuth2RevokeWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
"refresh_token",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active)
},
)
t.Run(
"invalid token_type_hint is ignored per RFC 7009",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
raw, err := testutil.OAuth2RevokeWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
"bogus_hint",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active)
},
)
t.Run(
"revoking refresh token cascades to linked access token per RFC 7009",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
// Revoke the refresh token.
raw, err := testutil.OAuth2RevokeWithHint(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
"refresh_token",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
// The linked access token should also be invalidated.
introspect, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect.Active, "access token should be revoked when refresh token is revoked")
},
)
t.Run(
"empty token returns 200",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
raw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
client.ClientSecret,
"",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, raw.StatusCode)
},
)
}
// ---------------------------------------------------------------------------
// 8. UserInfo
// ---------------------------------------------------------------------------
func TestOAuth2_UserInfo(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"returns claims for openid email profile scopes",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
userInfo, raw, err := testutil.OAuth2UserInfo(owner, tokens.AccessToken)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode, "userinfo failed: %s", string(raw.Body))
require.NotNil(t, userInfo)
assert.NotEmpty(t, userInfo.Sub)
assert.NotEmpty(t, userInfo.Email)
assert.NotEmpty(t, userInfo.Name)
},
)
t.Run(
"revoked access token returns 401",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
revokeRaw, err := testutil.OAuth2Revoke(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, revokeRaw.StatusCode)
_, raw, err := testutil.OAuth2UserInfo(owner, tokens.AccessToken)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode,
"revoked access token must be rejected by userinfo")
},
)
t.Run(
"no bearer token returns 401",
func(t *testing.T) {
t.Parallel()
_, raw, err := testutil.OAuth2UserInfo(owner, "")
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
},
)
t.Run(
"invalid bearer token returns 401",
func(t *testing.T) {
t.Parallel()
_, raw, err := testutil.OAuth2UserInfo(owner, "invalid-access-token")
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
},
)
}
// ---------------------------------------------------------------------------
// 9. Token Endpoint Errors
// ---------------------------------------------------------------------------
func TestOAuth2_Token_Errors(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"unsupported grant type",
func(t *testing.T) {
t.Parallel()
raw, err := testutil.OAuth2TokenRaw(owner, url.Values{
"grant_type": {"password"},
"username": {"user"},
"password": {"pass"},
})
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, raw.StatusCode)
var errResp testutil.OAuth2ErrorResponse
require.NoError(t, json.Unmarshal(raw.Body, &errResp))
assert.Equal(t, "unsupported_grant_type", errResp.Code)
},
)
t.Run(
"missing grant type",
func(t *testing.T) {
t.Parallel()
raw, err := testutil.OAuth2TokenRaw(owner, url.Values{})
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, raw.StatusCode)
},
)
t.Run(
"bad client auth on authorization code grant",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
raw, err := testutil.OAuth2TokenRawWithBasicAuth(
owner,
url.Values{
"grant_type": {"authorization_code"},
"code": {"fake-code"},
},
client.ClientID,
"wrong-secret",
)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
},
)
t.Run(
"slow down device code polling",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
deviceResp, _, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid",
)
require.NoError(t, err)
require.NotNil(t, deviceResp)
_, errResp1, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp1)
_, errResp2, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp2)
assert.Equal(t, "slow_down", errResp2.Code)
time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second)
_, errResp3, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp3)
assert.Equal(t, "authorization_pending", errResp3.Code, "after waiting, should not get slow_down")
},
)
}
// ---------------------------------------------------------------------------
// 10. Security
// ---------------------------------------------------------------------------
func TestOAuth2_Security(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"public client requires PKCE",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"no-pkce"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=", "public client without code_challenge should be rejected")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode)
}
},
)
t.Run(
"only S256 code challenge method accepted",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"plain-method"},
"code_challenge": {"some-challenge-value"},
"code_challenge_method": {"plain"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=", "plain code_challenge_method should be rejected")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode)
}
},
)
t.Run(
"redirect URI mismatch at token exchange",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"redirect-mismatch"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
_, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
"http://localhost:9999/WRONG-callback",
verifier,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode, "mismatched redirect_uri must fail")
},
)
t.Run(
"state parameter roundtrip",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
state := "csrf-protection-nonce-abc123"
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var redirectLoc string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
require.Equal(t, http.StatusFound, consentResp.StatusCode)
redirectLoc = consentResp.Header.Get("Location")
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
redirectLoc = authResp.Header.Get("Location")
}
u, err := url.Parse(redirectLoc)
require.NoError(t, err)
assert.Equal(t, state, u.Query().Get("state"), "state must be returned unchanged")
assert.NotEmpty(t, u.Query().Get("code"), "code must be present")
},
)
t.Run(
"scope escalation rejected",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, factory.Attrs{
"scopes": "openid",
})
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"scope-esc"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=", "requesting scopes beyond registration must fail")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode,
"should not show consent page for disallowed scopes")
}
},
)
t.Run(
"cross-client code exchange rejected",
func(t *testing.T) {
t.Parallel()
clientA := factory.CreateOAuth2Client(owner, nil)
clientB := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {clientA.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"cross-client"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
_, raw, err := testutil.OAuth2TokenWithCode(
owner,
clientB.ClientID,
clientB.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode,
"code issued to client A must not be exchangeable by client B")
},
)
t.Run(
"unregistered redirect URI rejected at authorize",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {"http://localhost:9999/evil"},
"response_type": {"code"},
"scope": {"openid"},
"state": {"open-redirect"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, authResp.StatusCode,
"unregistered redirect_uri must not show consent page")
assert.NotEqual(t, http.StatusFound, authResp.StatusCode,
"invalid redirect_uri must not redirect (must return JSON error)")
var errResp testutil.OAuth2ErrorResponse
if json.Unmarshal(authResp.Body, &errResp) == nil {
assert.Equal(t, "invalid_redirect_uri", errResp.Code,
"should return invalid_redirect_uri error code")
}
},
)
t.Run(
"private client rejects non-member at authorize",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
_, challenge := testutil.GeneratePKCE()
otherOwner := testutil.NewClient(t, testutil.RoleOwner)
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {"http://localhost:9999/callback"},
"response_type": {"code"},
"scope": {"openid"},
"state": {"non-member"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(otherOwner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=",
"non-member must be rejected when authorizing against a private client")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode,
"non-member must not see the consent page for a private client")
}
},
)
t.Run(
"consent skipped on re-authorization with same scopes",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge1 := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"consent-first"},
"code_challenge": {challenge1},
"code_challenge_method": {"S256"},
}
firstResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
require.True(t, testutil.IsConsentRedirect(firstResp), "first request should require consent")
consentID, err := testutil.ExtractConsentIDFromResponse(firstResp)
require.NoError(t, err)
_, err = testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
_, challenge2 := testutil.GeneratePKCE()
params.Set("state", "consent-second")
params.Set("code_challenge", challenge2)
secondResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
assert.Equal(t, http.StatusFound, secondResp.StatusCode,
"second authorize with same scopes should skip consent and redirect with code")
code, err := testutil.OAuth2AuthorizeCodeFromRedirect(secondResp)
require.NoError(t, err)
assert.NotEmpty(t, code)
},
)
t.Run(
"ID token contains nonce from authorize request",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
nonce := "test-nonce-value-abc123"
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"nonce-test"},
"nonce": {nonce},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotEmpty(t, tokenResp.IDToken)
parts := strings.SplitN(tokenResp.IDToken, ".", 3)
require.Len(t, parts, 3)
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var claims struct {
Nonce string `json:"nonce"`
}
require.NoError(t, json.Unmarshal(claimsJSON, &claims))
assert.Equal(t, nonce, claims.Nonce,
"ID token must contain the nonce from the authorize request")
},
)
t.Run(
"ID token contains valid at_hash claim",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
require.NotEmpty(t, tokens.IDToken)
parts := strings.SplitN(tokens.IDToken, ".", 3)
require.Len(t, parts, 3)
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var claims struct {
AtHash string `json:"at_hash"`
}
require.NoError(t, json.Unmarshal(claimsJSON, &claims))
require.NotEmpty(t, claims.AtHash, "at_hash must be present in ID token")
h := sha256.Sum256([]byte(tokens.AccessToken))
expectedAtHash := base64.RawURLEncoding.EncodeToString(h[:16])
assert.Equal(t, expectedAtHash, claims.AtHash,
"at_hash must be the left half of SHA-256 of the access token, base64url-encoded")
},
)
t.Run(
"ID token signature verifiable with JWKS",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
require.NotEmpty(t, tokens.IDToken, "expected id_token")
// Parse JWT parts.
parts := strings.SplitN(tokens.IDToken, ".", 3)
require.Len(t, parts, 3, "JWT must have 3 parts")
// Decode and verify header.
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
require.NoError(t, err)
var header struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
Kid string `json:"kid"`
}
require.NoError(t, json.Unmarshal(headerJSON, &header))
assert.Equal(t, "RS256", header.Alg)
assert.Equal(t, "JWT", header.Typ)
assert.NotEmpty(t, header.Kid)
// Decode claims and verify standard fields.
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var claims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
AuthTime int64 `json:"auth_time"`
}
require.NoError(t, json.Unmarshal(claimsJSON, &claims))
assert.NotEmpty(t, claims.Iss)
assert.NotEmpty(t, claims.Sub)
assert.Equal(t, client.ClientID, claims.Aud)
assert.Greater(t, claims.Exp, time.Now().Unix(), "token must not be expired")
assert.LessOrEqual(t, claims.Iat, time.Now().Unix())
assert.Greater(t, claims.AuthTime, int64(0), "auth_time must be set")
// Fetch JWKS and find the matching key.
jwks, _, err := testutil.OAuth2JWKS(owner)
require.NoError(t, err)
require.NotNil(t, jwks)
var matchingKey map[string]any
for _, k := range jwks.Keys {
if kid, ok := k["kid"].(string); ok && kid == header.Kid {
matchingKey = k
break
}
}
require.NotNil(t, matchingKey, "JWKS must contain key matching kid=%s", header.Kid)
// Reconstruct the RSA public key from JWK.
nB64, ok := matchingKey["n"].(string)
require.True(t, ok)
eB64, ok := matchingKey["e"].(string)
require.True(t, ok)
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
require.NoError(t, err)
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
require.NoError(t, err)
pubKey := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: int(new(big.Int).SetBytes(eBytes).Int64()),
}
// Verify RS256 signature.
signingInput := parts[0] + "." + parts[1]
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
digest := sha256.Sum256([]byte(signingInput))
err = rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes)
assert.NoError(t, err, "ID token signature must be verifiable with JWKS public key")
},
)
t.Run(
"consent approval with different user rejected",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"csrf-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
require.True(t, testutil.IsConsentRedirect(authResp), "expected consent redirect")
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
otherOwner := testutil.NewClient(t, testutil.RoleOwner)
approveResp, err := testutil.OAuth2ConsentApprove(otherOwner, consentID)
require.NoError(t, err)
assert.NotEqual(t, http.StatusFound, approveResp.StatusCode,
"consent approval by a different user must be rejected")
},
)
t.Run(
"cross-client revocation does not revoke the token",
func(t *testing.T) {
t.Parallel()
clientA := factory.CreateOAuth2Client(owner, nil)
clientB := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
clientA.ClientID,
clientA.ClientSecret,
redirectURI,
)
revokeRaw, err := testutil.OAuth2Revoke(
owner,
clientB.ClientID,
clientB.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, revokeRaw.StatusCode,
"cross-client revoke should return 200 per RFC 7009")
introspect, _, err := testutil.OAuth2Introspect(
owner,
clientA.ClientID,
clientA.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
assert.True(t, introspect.Active,
"token must still be active when revoked by a different client")
},
)
t.Run(
"bearer token in query string rejected by userinfo",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
raw, err := testutil.OAuth2UserInfoRaw(owner, url.Values{
"access_token": {tokens.AccessToken},
})
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode,
"bearer token in query string must not authenticate")
},
)
t.Run(
"confidential client can complete flow without PKCE",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"no-pkce-confidential"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
"",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode,
"confidential client should succeed without PKCE: %s", string(raw.Body))
assert.NotEmpty(t, tokenResp.AccessToken)
},
)
}
// ---------------------------------------------------------------------------
// 11. Client Secret Post Authentication
// ---------------------------------------------------------------------------
func TestOAuth2_ClientSecretPost(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"full auth code flow with client_secret_post",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, factory.Attrs{
"token_endpoint_auth_method": "client_secret_post",
})
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"post-auth"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCodePostAuth(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode,
"client_secret_post exchange failed: %s", string(raw.Body))
require.NotNil(t, tokenResp)
assert.NotEmpty(t, tokenResp.AccessToken)
assert.Equal(t, "Bearer", tokenResp.TokenType)
assert.Greater(t, tokenResp.ExpiresIn, int64(0))
},
)
t.Run(
"wrong secret via client_secret_post returns 401",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, factory.Attrs{
"token_endpoint_auth_method": "client_secret_post",
})
raw, err := testutil.OAuth2TokenRaw(owner, url.Values{
"grant_type": {"authorization_code"},
"code": {"fake-code"},
"client_id": {client.ClientID},
"client_secret": {"wrong-secret"},
})
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
},
)
}
// ---------------------------------------------------------------------------
// 12. Public Client Authorization Code Flow
// ---------------------------------------------------------------------------
func TestOAuth2_PublicClientAuthCodeFlow(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"full happy path with PKCE and no secret",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"public-pkce"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCodePostAuth(
owner,
client.ClientID,
"",
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode,
"public client token exchange failed: %s", string(raw.Body))
require.NotNil(t, tokenResp)
assert.NotEmpty(t, tokenResp.AccessToken)
assert.Equal(t, "Bearer", tokenResp.TokenType)
assert.Greater(t, tokenResp.ExpiresIn, int64(0))
assert.Contains(t, tokenResp.Scope, "openid")
},
)
}
// ---------------------------------------------------------------------------
// 13. Registration Edge Cases
// ---------------------------------------------------------------------------
func TestOAuth2_RegisterClient_EdgeCases(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"public client with http redirect URI rejected",
func(t *testing.T) {
t.Parallel()
_, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{
"organization_id": owner.GetOrganizationID().String(),
"client_name": factory.SafeName("Public HTTP"),
"visibility": "public",
"redirect_uris": []string{"http://example.com/callback"},
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "none",
"scopes": "openid",
})
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, raw.StatusCode,
"public clients must require https redirect URIs")
},
)
t.Run(
"public client with https redirect URI accepted",
func(t *testing.T) {
t.Parallel()
resp, raw, err := testutil.OAuth2RegisterClient(owner, map[string]any{
"organization_id": owner.GetOrganizationID().String(),
"client_name": factory.SafeName("Public HTTPS"),
"visibility": "public",
"redirect_uris": []string{"https://example.com/callback"},
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "none",
"scopes": "openid",
})
require.NoError(t, err)
require.Equal(t, http.StatusCreated, raw.StatusCode, "body: %s", string(raw.Body))
assert.NotEmpty(t, resp.ClientID)
},
)
}
// ---------------------------------------------------------------------------
// 14. ID Token Claims
// ---------------------------------------------------------------------------
func TestOAuth2_IDTokenClaims(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"ID token from auth code flow contains email and name claims",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
require.NotEmpty(t, tokens.IDToken)
parts := strings.SplitN(tokens.IDToken, ".", 3)
require.Len(t, parts, 3)
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var claims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
AuthTime int64 `json:"auth_time"`
Email string `json:"email"`
EmailVerified *bool `json:"email_verified"`
Name string `json:"name"`
}
require.NoError(t, json.Unmarshal(claimsJSON, &claims))
assert.NotEmpty(t, claims.Iss)
assert.NotEmpty(t, claims.Sub)
assert.NotEmpty(t, claims.Aud)
assert.NotEmpty(t, claims.Exp)
assert.NotEmpty(t, claims.Iat)
assert.NotEmpty(t, claims.AuthTime)
assert.NotEmpty(t, claims.Email,
"ID token must contain email when email scope is requested")
require.NotNil(t, claims.EmailVerified,
"ID token must contain email_verified when email scope is requested")
assert.True(t, *claims.EmailVerified,
"email_verified must reflect the verified e2e test identity")
assert.NotEmpty(t, claims.Name,
"ID token must contain name when profile scope is requested")
},
)
t.Run(
"ID token from refresh contains identity claims and omits nonce",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
nonce := "test-refresh-nonce"
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile offline_access"},
"state": {"refresh-nonce"},
"nonce": {nonce},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
firstTokens, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
refreshResp, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.RefreshToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, refreshRaw.StatusCode)
require.NotEmpty(t, refreshResp.IDToken)
parts := strings.SplitN(refreshResp.IDToken, ".", 3)
require.Len(t, parts, 3)
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var claims struct {
Nonce string `json:"nonce"`
AuthTime int64 `json:"auth_time"`
Email string `json:"email"`
EmailVerified *bool `json:"email_verified"`
Name string `json:"name"`
}
require.NoError(t, json.Unmarshal(claimsJSON, &claims))
assert.Empty(t, claims.Nonce,
"ID token from refresh must not contain the original nonce")
assert.NotEmpty(t, claims.AuthTime,
"refresh ID token must contain auth_time")
assert.NotEmpty(t, claims.Email,
"refresh ID token must contain email when email scope is present")
require.NotNil(t, claims.EmailVerified,
"refresh ID token must contain email_verified when email scope is present")
assert.True(t, *claims.EmailVerified,
"email_verified must reflect the verified e2e test identity")
assert.NotEmpty(t, claims.Name,
"refresh ID token must contain name when profile scope is present")
},
)
}
// ---------------------------------------------------------------------------
// 15. Cache-Control Headers
// ---------------------------------------------------------------------------
func TestOAuth2_CacheHeaders(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"token endpoint response has no-store cache header",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"cache-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
_, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
cacheControl := raw.Header.Get("Cache-Control")
assert.Contains(t, cacheControl, "no-store",
"token response must include Cache-Control: no-store per RFC 6749 section 5.1")
},
)
}
// ---------------------------------------------------------------------------
// 16. Device Flow Edge Cases
// ---------------------------------------------------------------------------
func TestOAuth2_DeviceCodeFlow_EdgeCases(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"invalid user code on device verify",
func(t *testing.T) {
t.Parallel()
verifyResp, err := testutil.OAuth2DeviceVerify(owner, "ZZZZ-ZZZZ")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, verifyResp.StatusCode)
assert.Contains(t, strings.ToLower(string(verifyResp.Body)), "error",
"response should indicate verification failure")
},
)
t.Run(
"scope exceeding client registration rejected",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, factory.Attrs{
"scopes": "openid",
})
_, raw, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid email profile",
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode,
"device auth with scope exceeding registration must be rejected")
},
)
}
// ---------------------------------------------------------------------------
// 17. Authorize Endpoint Edge Cases
// ---------------------------------------------------------------------------
func TestOAuth2_Authorize_EdgeCases(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"unsupported response_type rejected",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"token"},
"scope": {"openid"},
"state": {"implicit-attempt"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=",
"response_type=token must be rejected")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode,
"response_type=token must not show consent page")
}
},
)
t.Run(
"already approved consent cannot be resubmitted",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"double-approve"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
require.True(t, testutil.IsConsentRedirect(authResp), "expected consent redirect")
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
firstApproval, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
require.Equal(t, http.StatusFound, firstApproval.StatusCode,
"first consent approval should redirect with code")
secondApproval, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
assert.NotEqual(t, http.StatusFound, secondApproval.StatusCode,
"second consent approval must be rejected")
},
)
}
// ---------------------------------------------------------------------------
// 18. Introspect Edge Cases
// ---------------------------------------------------------------------------
func TestOAuth2_Introspect_EdgeCases(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"unknown token returns inactive with valid client auth",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
introspect, raw, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
"completely-unknown-token-that-was-never-issued",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode,
"introspect of unknown token must return 200 per RFC 7662")
assert.False(t, introspect.Active,
"unknown token must be reported as inactive")
},
)
t.Run(
"empty token returns error",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
_, raw, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
"",
)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, raw.StatusCode,
"empty token should be rejected by input validation")
},
)
}
// ---------------------------------------------------------------------------
// 19. Token Expiry (requires short durations in e2e config)
// ---------------------------------------------------------------------------
func TestOAuth2_Expiry(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"expired authorization code rejected at token exchange",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid"},
"state": {"expiry-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
// e2e config sets authorization-code-duration to 5s
time.Sleep(6 * time.Second)
_, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode,
"expired authorization code must be rejected")
},
)
t.Run(
"expired access token rejected by userinfo",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
// e2e config sets access-token-duration to 10s
time.Sleep(11 * time.Second)
_, raw, err := testutil.OAuth2UserInfo(owner, tokens.AccessToken)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode,
"expired access token must be rejected by userinfo")
},
)
t.Run(
"expired access token introspects as inactive",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
// e2e config sets access-token-duration to 10s
time.Sleep(11 * time.Second)
introspect, raw, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
tokens.AccessToken,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
assert.False(t, introspect.Active,
"expired access token must introspect as inactive")
},
)
t.Run(
"expired refresh token rejected",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
tokens := testutil.OAuth2PerformAuthorizationCodeFlow(
t,
owner,
client.ClientID,
client.ClientSecret,
redirectURI,
)
// e2e config sets refresh-token-duration to 10s
time.Sleep(11 * time.Second)
_, raw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
tokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, raw.StatusCode,
"expired refresh token must be rejected")
},
)
t.Run(
"expired device code rejected at poll",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
deviceResp, raw, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, deviceResp)
// e2e config sets device-code-duration to 15s
time.Sleep(16 * time.Second)
_, errResp, _, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.NotNil(t, errResp)
assert.Equal(t, "expired_token", errResp.Code,
"expired device code must return expired_token error")
},
)
}
// ---------------------------------------------------------------------------
// 20. Offline Access Scope
// ---------------------------------------------------------------------------
func TestOAuth2_OfflineAccessScope(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"refresh token issued with offline_access scope",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid offline_access"},
"state": {"offline-yes"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, tokenResp)
assert.NotEmpty(t, tokenResp.RefreshToken,
"refresh token must be issued when offline_access scope is requested")
},
)
t.Run(
"no refresh token without offline_access scope",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"offline-no"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
tokenResp, raw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, tokenResp)
assert.NotEmpty(t, tokenResp.AccessToken)
assert.Empty(t, tokenResp.RefreshToken,
"refresh token must not be issued without offline_access scope")
},
)
t.Run(
"offline_access rejected without refresh_token grant type",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, factory.Attrs{
"grant_types": []string{"authorization_code"},
"scopes": "openid offline_access",
})
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid offline_access"},
"state": {"no-grant-type"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
if authResp.StatusCode == http.StatusFound {
loc := authResp.Header.Get("Location")
assert.Contains(t, loc, "error=invalid_scope",
"offline_access without refresh_token grant type must return invalid_scope")
} else {
assert.NotEqual(t, http.StatusOK, authResp.StatusCode,
"offline_access without refresh_token grant type must not show consent page")
}
},
)
t.Run(
"device flow with offline_access scope issues refresh token",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
deviceResp, raw, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid offline_access",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, deviceResp)
userCode := deviceResp.UserCode
verifyResp, err := testutil.OAuth2DeviceVerify(owner, userCode)
require.NoError(t, err)
require.Equal(t, http.StatusOK, verifyResp.StatusCode)
time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second)
tokenResp, _, pollRaw, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, pollRaw.StatusCode)
require.NotNil(t, tokenResp)
assert.NotEmpty(t, tokenResp.RefreshToken,
"device flow must issue refresh token when offline_access is requested")
},
)
t.Run(
"device flow without offline_access scope has no refresh token",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
deviceResp, raw, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid email profile",
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw.StatusCode)
require.NotNil(t, deviceResp)
userCode := deviceResp.UserCode
verifyResp, err := testutil.OAuth2DeviceVerify(owner, userCode)
require.NoError(t, err)
require.Equal(t, http.StatusOK, verifyResp.StatusCode)
time.Sleep(time.Duration(deviceResp.Interval+1) * time.Second)
tokenResp, _, pollRaw, err := testutil.OAuth2TokenWithDeviceCode(
owner,
client.ClientID,
deviceResp.DeviceCode,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, pollRaw.StatusCode)
require.NotNil(t, tokenResp)
assert.Empty(t, tokenResp.RefreshToken,
"device flow must not issue refresh token without offline_access scope")
},
)
t.Run(
"device flow offline_access rejected without refresh_token grant type",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, factory.Attrs{
"grant_types": []string{
"authorization_code",
"urn:ietf:params:oauth:grant-type:device_code",
},
"scopes": "openid offline_access",
})
_, raw, err := testutil.OAuth2DeviceAuth(
owner,
client.ClientID,
"openid offline_access",
)
require.NoError(t, err)
require.NotEqual(t, http.StatusOK, raw.StatusCode,
"device flow with offline_access but no refresh_token grant type must be rejected")
var errResp testutil.OAuth2ErrorResponse
require.NoError(t, json.Unmarshal(raw.Body, &errResp))
assert.Equal(t, "invalid_scope", errResp.Code)
assert.Contains(t, errResp.Description, "refresh_token")
},
)
t.Run(
"authorize offline_access error includes description",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, factory.Attrs{
"grant_types": []string{"authorization_code"},
"scopes": "openid offline_access",
})
redirectURI := "http://localhost:9999/callback"
_, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid offline_access"},
"state": {"err-desc"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
require.Equal(t, http.StatusFound, authResp.StatusCode)
loc, err := url.Parse(authResp.Header.Get("Location"))
require.NoError(t, err)
assert.Equal(t, "invalid_scope", loc.Query().Get("error"))
assert.Contains(t, loc.Query().Get("error_description"), "refresh_token",
"error description should mention missing refresh_token grant type")
assert.Equal(t, "err-desc", loc.Query().Get("state"),
"state parameter must be preserved in error redirect")
},
)
}
// ---------------------------------------------------------------------------
// 20. RFC 6819 Compliance: Public Client Consent Skip Prevention
// ---------------------------------------------------------------------------
func TestOAuth2_PublicClientAlwaysRequiresConsent(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"public client must prompt consent on every authorization",
func(t *testing.T) {
t.Parallel()
client := factory.CreatePublicOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
// First flow: authorize and approve consent explicitly.
verifier1, challenge1 := testutil.GeneratePKCE()
params1 := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"first"},
"code_challenge": {challenge1},
"code_challenge_method": {"S256"},
}
authResp1, err := testutil.OAuth2Authorize(owner, params1)
require.NoError(t, err)
// First request must require consent (redirect to consent page).
require.True(t, testutil.IsConsentRedirect(authResp1), "first authorization must require consent for public client")
consentID1, err := testutil.ExtractConsentIDFromResponse(authResp1)
require.NoError(t, err)
consentResp1, err := testutil.OAuth2ConsentApprove(owner, consentID1)
require.NoError(t, err)
require.Equal(t, http.StatusFound, consentResp1.StatusCode)
code1, err := testutil.OAuth2AuthorizeCodeFromRedirect(consentResp1)
require.NoError(t, err)
tokenResp1, raw1, err := testutil.OAuth2TokenWithCodePostAuth(
owner,
client.ClientID,
"",
code1,
redirectURI,
verifier1,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw1.StatusCode, "first token exchange failed: %s", string(raw1.Body))
require.NotEmpty(t, tokenResp1.AccessToken)
// Second flow with same client+scopes: must STILL require consent
// (RFC 6819 §5.2.3.2 — no auto-consent for public clients).
_, challenge2 := testutil.GeneratePKCE()
params2 := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"second"},
"code_challenge": {challenge2},
"code_challenge_method": {"S256"},
}
authResp2, err := testutil.OAuth2Authorize(owner, params2)
require.NoError(t, err)
// The key assertion: the second authorization must also require
// explicit consent, not silently issue a code via redirect.
require.True(t, testutil.IsConsentRedirect(authResp2),
"public client must always require consent, got code redirect (auto-consent)")
_, err = testutil.ExtractConsentIDFromResponse(authResp2)
require.NoError(t, err,
"second authorization must present consent form for public client")
},
)
t.Run(
"confidential client can skip consent on repeat authorization",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
// First flow: approve consent.
verifier1, challenge1 := testutil.GeneratePKCE()
params1 := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"first"},
"code_challenge": {challenge1},
"code_challenge_method": {"S256"},
}
authResp1, err := testutil.OAuth2Authorize(owner, params1)
require.NoError(t, err)
require.True(t, testutil.IsConsentRedirect(authResp1), "first authorization should require consent")
consentID, err := testutil.ExtractConsentIDFromResponse(authResp1)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
require.Equal(t, http.StatusFound, consentResp.StatusCode)
code1, err := testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
_, raw1, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code1,
redirectURI,
verifier1,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw1.StatusCode)
// Second flow with same scopes: confidential client may skip consent.
verifier2, challenge2 := testutil.GeneratePKCE()
params2 := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {"second"},
"code_challenge": {challenge2},
"code_challenge_method": {"S256"},
}
authResp2, err := testutil.OAuth2Authorize(owner, params2)
require.NoError(t, err)
require.Equal(t, http.StatusFound, authResp2.StatusCode,
"confidential client should auto-consent on repeat authorization")
code2, err := testutil.OAuth2AuthorizeCodeFromRedirect(authResp2)
require.NoError(t, err)
require.NotEmpty(t, code2)
_, raw2, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code2,
redirectURI,
verifier2,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, raw2.StatusCode)
},
)
}
// ---------------------------------------------------------------------------
// 21. RFC 6819 Compliance: Authorization Code Replay Detection
// ---------------------------------------------------------------------------
func TestOAuth2_AuthorizationCodeReplayRevokesTokens(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"second code exchange revokes tokens from first exchange",
func(t *testing.T) {
t.Parallel()
client := factory.CreateOAuth2Client(owner, nil)
redirectURI := "http://localhost:9999/callback"
verifier, challenge := testutil.GeneratePKCE()
params := url.Values{
"client_id": {client.ClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid email profile offline_access"},
"state": {"replay-test"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authResp, err := testutil.OAuth2Authorize(owner, params)
require.NoError(t, err)
var code string
if testutil.IsConsentRedirect(authResp) {
consentID, err := testutil.ExtractConsentIDFromResponse(authResp)
require.NoError(t, err)
consentResp, err := testutil.OAuth2ConsentApprove(owner, consentID)
require.NoError(t, err)
require.Equal(t, http.StatusFound, consentResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(consentResp)
require.NoError(t, err)
} else {
require.Equal(t, http.StatusFound, authResp.StatusCode)
code, err = testutil.OAuth2AuthorizeCodeFromRedirect(authResp)
require.NoError(t, err)
}
// First exchange: should succeed.
firstTokens, firstRaw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, firstRaw.StatusCode, "first exchange failed: %s", string(firstRaw.Body))
require.NotNil(t, firstTokens)
require.NotEmpty(t, firstTokens.AccessToken)
require.NotEmpty(t, firstTokens.RefreshToken)
// Verify the access token works before replay.
introspect1, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.AccessToken,
)
require.NoError(t, err)
assert.True(t, introspect1.Active, "access token must be active before replay attempt")
// Second exchange with same code: should fail (replay).
_, replayRaw, err := testutil.OAuth2TokenWithCode(
owner,
client.ClientID,
client.ClientSecret,
code,
redirectURI,
verifier,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, replayRaw.StatusCode,
"replayed authorization code must be rejected")
// The access token from the first exchange must now be revoked.
introspect2, _, err := testutil.OAuth2Introspect(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.AccessToken,
)
require.NoError(t, err)
assert.False(t, introspect2.Active,
"access token must be revoked after authorization code replay")
// The refresh token from the first exchange must also be revoked.
_, refreshRaw, err := testutil.OAuth2TokenWithRefreshToken(
owner,
client.ClientID,
client.ClientSecret,
firstTokens.RefreshToken,
)
require.NoError(t, err)
assert.NotEqual(t, http.StatusOK, refreshRaw.StatusCode,
"refresh token must be revoked after authorization code replay")
},
)
}