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>
3618 lines
101 KiB
Go
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")
|
|
},
|
|
)
|
|
}
|