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:
Ludovic Vielle
2026-06-15 17:33:15 +02:00
parent 25151fa089
commit 3ebb221a9b
56 changed files with 1918 additions and 290 deletions

View File

@@ -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(),

View File

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

View File

@@ -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"},