Files
probo/e2e/console/oauth2_test.go
Bryan Frimin 11770b4058 Add OAuth2/OpenID Connect authorization server
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.

Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-04-19 12:00:53 +02:00

3386 lines
95 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS 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.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")
}
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)
},
)
}
// ---------------------------------------------------------------------------
// 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.False(t, *claims.EmailVerified,
"email_verified must be false for unverified 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.False(t, *claims.EmailVerified,
"email_verified must be false for unverified 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")
},
)
}