Add OAuth2 API scope registration and enforcement
Register v1 API scopes in coredata, advertise them in OIDC discovery and protected-resource metadata, show them on the consent screen, and enforce scope-to-action mapping in the IAM Authorizer before policy evaluation. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
103
e2e/console/oauth2_scope_test.go
Normal file
103
e2e/console/oauth2_scope_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestOAuth2_ScopeEnforcementOnConsoleGraphQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
factory.CreateThirdParty(owner, factory.Attrs{"name": "Scoped OAuth Vendor"})
|
||||
|
||||
const redirectURI = "http://localhost:9999/callback"
|
||||
|
||||
client := factory.CreateOAuth2ClientWithAPIScopes(
|
||||
owner,
|
||||
"openid v1:org:read",
|
||||
nil,
|
||||
)
|
||||
|
||||
tokenResp := testutil.OAuth2PerformAuthorizationCodeFlowWithScopes(
|
||||
t,
|
||||
owner,
|
||||
client.ClientID,
|
||||
client.ClientSecret,
|
||||
redirectURI,
|
||||
"openid v1:org:read",
|
||||
)
|
||||
require.NotEmpty(t, tokenResp.AccessToken)
|
||||
|
||||
const getOrganizationQuery = `
|
||||
query GetOrganization($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
allowedResp, err := testutil.ConsoleGraphQLWithAccessToken(
|
||||
t,
|
||||
tokenResp.AccessToken,
|
||||
getOrganizationQuery,
|
||||
map[string]any{
|
||||
"id": owner.GetOrganizationID().String(),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, allowedResp)
|
||||
|
||||
const listThirdPartiesQuery = `
|
||||
query ListThirdParties($orgId: ID!) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
thirdParties(first: 10) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
deniedResp, err := testutil.ConsoleGraphQLWithAccessToken(
|
||||
t,
|
||||
tokenResp.AccessToken,
|
||||
listThirdPartiesQuery,
|
||||
map[string]any{
|
||||
"orgId": owner.GetOrganizationID().String(),
|
||||
},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, deniedResp)
|
||||
require.NotEmpty(t, deniedResp.Errors)
|
||||
|
||||
code := deniedResp.Errors[0].Code()
|
||||
msg := deniedResp.Errors[0].Message
|
||||
isForbidden := code == "FORBIDDEN" ||
|
||||
(code == "" && (strings.Contains(msg, "does not have sufficient permissions") || strings.Contains(msg, "insufficient permissions")))
|
||||
require.True(t, isForbidden, "expected FORBIDDEN error, got code=%q message=%q", code, msg)
|
||||
assert.Empty(t, deniedResp.DataString(), "expected no data on denied request")
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func TestOAuth2_Discovery(t *testing.T) {
|
||||
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")
|
||||
@@ -97,6 +98,46 @@ func TestOAuth2_Discovery(t *testing.T) {
|
||||
assert.Contains(t, discovery.ClaimsSupported, "name")
|
||||
}
|
||||
|
||||
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: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()
|
||||
|
||||
|
||||
@@ -1230,6 +1230,33 @@ func CreateOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
||||
}
|
||||
}
|
||||
|
||||
func CreateOAuth2ClientWithAPIScopes(c *testutil.Client, scopes string, attrs Attrs) OAuth2ClientResult {
|
||||
input := map[string]any{
|
||||
"organization_id": c.GetOrganizationID().String(),
|
||||
"client_name": SafeName("OAuth2 API Client"),
|
||||
"visibility": "private",
|
||||
"redirect_uris": []string{"http://localhost:9999/callback"},
|
||||
"grant_types": []string{
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
},
|
||||
"response_types": []string{"code"},
|
||||
"token_endpoint_auth_method": "client_secret_basic",
|
||||
"scopes": scopes,
|
||||
}
|
||||
|
||||
maps.Copy(input, attrs)
|
||||
|
||||
resp, raw, err := testutil.OAuth2RegisterClient(c, input)
|
||||
require.NoError(c.T, err, "OAuth2 API client registration failed")
|
||||
require.NotNil(c.T, resp, "OAuth2 API client registration returned nil (status=%d body=%s)", raw.StatusCode, string(raw.Body))
|
||||
|
||||
return OAuth2ClientResult{
|
||||
ClientID: resp.ClientID,
|
||||
ClientSecret: resp.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func CreatePublicOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
||||
input := map[string]any{
|
||||
"organization_id": c.GetOrganizationID().String(),
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -36,6 +38,16 @@ type GraphQLResponse struct {
|
||||
Errors []GraphQLError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// DataString returns the GraphQL data payload as JSON text. Absent and JSON null
|
||||
// responses both normalize to an empty string for assert.Empty checks.
|
||||
func (r *GraphQLResponse) DataString() string {
|
||||
if len(r.Data) == 0 || string(r.Data) == "null" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(r.Data)
|
||||
}
|
||||
|
||||
type GraphQLError struct {
|
||||
Message string `json:"message"`
|
||||
Path []any `json:"path,omitempty"`
|
||||
@@ -126,6 +138,68 @@ func (c *Client) DoConnect(query string, variables map[string]any) (*GraphQLResp
|
||||
return c.doWithEndpoint("/api/connect/v1/graphql", query, variables)
|
||||
}
|
||||
|
||||
// ConsoleGraphQLWithAccessToken posts to the console GraphQL endpoint using a
|
||||
// bearer access token and no session cookies.
|
||||
func ConsoleGraphQLWithAccessToken(
|
||||
t testing.TB,
|
||||
accessToken string,
|
||||
query string,
|
||||
variables map[string]any,
|
||||
) (*GraphQLResponse, error) {
|
||||
t.Helper()
|
||||
|
||||
reqBody := GraphQLRequest{
|
||||
Query: query,
|
||||
Variables: variables,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
"POST",
|
||||
GetBaseURL()+"/api/console/v1/graphql",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var gqlResp GraphQLResponse
|
||||
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(gqlResp.Errors) > 0 {
|
||||
return &gqlResp, GraphQLErrors(gqlResp.Errors)
|
||||
}
|
||||
|
||||
return &gqlResp, nil
|
||||
}
|
||||
|
||||
func (c *Client) DoTrust(trustCenterID string, query string, variables map[string]any) (*GraphQLResponse, error) {
|
||||
return c.doWithEndpoint(fmt.Sprintf("/trust/%s/api/trust/v1/graphql", trustCenterID), query, variables)
|
||||
}
|
||||
@@ -136,7 +210,7 @@ func (c *Client) Execute(query string, variables map[string]any, result any) err
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && resp.Data != nil {
|
||||
if result != nil && resp.DataString() != "" {
|
||||
if err := json.Unmarshal(resp.Data, result); err != nil {
|
||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
||||
}
|
||||
@@ -151,7 +225,7 @@ func (c *Client) ExecuteConnect(query string, variables map[string]any, result a
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && resp.Data != nil {
|
||||
if result != nil && resp.DataString() != "" {
|
||||
if err := json.Unmarshal(resp.Data, result); err != nil {
|
||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
||||
}
|
||||
@@ -166,7 +240,7 @@ func (c *Client) ExecuteTrust(trustCenterID string, query string, variables map[
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && resp.Data != nil {
|
||||
if result != nil && resp.DataString() != "" {
|
||||
if err := json.Unmarshal(resp.Data, result); err != nil {
|
||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
||||
}
|
||||
@@ -318,7 +392,7 @@ func (c *Client) executeMultipart(endpoint string, query string, variables map[s
|
||||
return GraphQLErrors(gqlResp.Errors)
|
||||
}
|
||||
|
||||
if result != nil && gqlResp.Data != nil {
|
||||
if result != nil && gqlResp.DataString() != "" {
|
||||
if err := json.Unmarshal(gqlResp.Data, result); err != nil {
|
||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ type (
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
ProtectedResources []string `json:"protected_resources,omitempty"`
|
||||
}
|
||||
|
||||
OAuth2ProtectedResourceMetadataResponse struct {
|
||||
Resource string `json:"resource"`
|
||||
AuthorizationServers []string `json:"authorization_servers"`
|
||||
BearerMethodsSupported []string `json:"bearer_methods_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
}
|
||||
|
||||
OAuth2JWKSResponse struct {
|
||||
@@ -265,6 +273,28 @@ func OAuth2JWKS(c *Client) (*OAuth2JWKSResponse, *OAuth2HTTPResponse, error) {
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2ProtectedResourceMetadata fetches the RFC 9728 protected resource
|
||||
// metadata document.
|
||||
func OAuth2ProtectedResourceMetadata(
|
||||
c *Client,
|
||||
) (*OAuth2ProtectedResourceMetadataResponse, *OAuth2HTTPResponse, error) {
|
||||
raw, err := getJSON(c.HTTPClient(), c.BaseURL()+"/.well-known/oauth-protected-resource", nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if raw.StatusCode != http.StatusOK {
|
||||
return nil, raw, nil
|
||||
}
|
||||
|
||||
var result OAuth2ProtectedResourceMetadataResponse
|
||||
if err := json.Unmarshal(raw.Body, &result); err != nil {
|
||||
return nil, raw, fmt.Errorf("cannot decode protected resource metadata: %w", err)
|
||||
}
|
||||
|
||||
return &result, raw, nil
|
||||
}
|
||||
|
||||
// OAuth2RegisterClient registers a new OAuth2 client via dynamic registration.
|
||||
func OAuth2RegisterClient(
|
||||
c *Client,
|
||||
@@ -890,13 +920,32 @@ func OAuth2PerformAuthorizationCodeFlow(
|
||||
) *OAuth2TokenResponse {
|
||||
t.Helper()
|
||||
|
||||
return OAuth2PerformAuthorizationCodeFlowWithScopes(
|
||||
t,
|
||||
c,
|
||||
clientID,
|
||||
clientSecret,
|
||||
redirectURI,
|
||||
"openid email profile offline_access",
|
||||
)
|
||||
}
|
||||
|
||||
// OAuth2PerformAuthorizationCodeFlowWithScopes performs the authorization code
|
||||
// flow with the requested OAuth2 scopes.
|
||||
func OAuth2PerformAuthorizationCodeFlowWithScopes(
|
||||
t testing.TB,
|
||||
c *Client,
|
||||
clientID, clientSecret, redirectURI, scopes string,
|
||||
) *OAuth2TokenResponse {
|
||||
t.Helper()
|
||||
|
||||
verifier, challenge := GeneratePKCE()
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"response_type": {"code"},
|
||||
"scope": {"openid email profile offline_access"},
|
||||
"scope": {scopes},
|
||||
"state": {"test-state"},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
|
||||
Reference in New Issue
Block a user