diff --git a/apps/console/src/pages/iam/auth/ConsentPage.tsx b/apps/console/src/pages/iam/auth/ConsentPage.tsx index c22265b1b..cb6c314e8 100644 --- a/apps/console/src/pages/iam/auth/ConsentPage.tsx +++ b/apps/console/src/pages/iam/auth/ConsentPage.tsx @@ -19,6 +19,7 @@ import { Button, IconArrowsClockwise, IconEnvelope, + IconKey, IconLockOpen, IconUser, IconUserCircle, @@ -68,6 +69,14 @@ const scopeIcons: Record = { offline_access: , }; +function scopeIcon(name: string): React.ReactNode { + return scopeIcons[name] ?? ; +} + +function scopeLabel(name: string): string { + return scopeLabels[name] ?? name; +} + export default function ConsentPage(props: { queryRef: PreloadedQuery; }) { @@ -192,16 +201,17 @@ export default function ConsentPage(props: {
    - {consent.scopes.map((scope: string) => { - const label = scopeLabels[scope]; - if (!label) return null; + {consent.scopes.map((scope) => { + const label = scopeLabel(scope); + const translated = scopeLabels[scope] ? __(label) : label; + return (
  • - {scopeIcons[scope]} - {__(label)} + {scopeIcon(scope)} + {translated}
  • ); })} diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md index 2e9039980..fec0438cf 100644 --- a/contrib/claude/authorization.md +++ b/contrib/claude/authorization.md @@ -255,6 +255,9 @@ and `r.AuthorizeBatch` (MCP) — keep the returned scope and pass it down. | IAM role policies (`IAMPolicySet`) | `pkg/iam/iam_policies.go` | | Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` | | PolicySet registration | `pkg/iam/policy_set.go` | +| OAuth2 scope mappings (`ScopeSet`) | `pkg/iam/scope_set.go` | +| OAuth2 scope constants (per domain) | `pkg//oauth2_scopes.go` | +| OAuth2 discovery + request context | `pkg/iam/oauth2/` | | GraphQL authz helper | `pkg/server/api/authz/authorization.go` | | MCP authz + recovery | `pkg/server/api/mcp/v1/resolver.go`, `mcputils/recovery.go` | @@ -272,6 +275,14 @@ const ( ) ``` +## OAuth2 API scopes + +OAuth2 scopes for API access are defined as `coredata.OAuth2Scope` constants in each owning package (for example `pkg/probo/oauth2_scopes.go`, `pkg/iam/oauth2_scopes.go`). `pkg/coredata/oauth2_scope.go` defines the persistence type. Standard OIDC scopes live in `pkg/iam/oauth2/scope.go`. Register scope sets with `Authorizer.RegisterScopes`. + +**Enforcement:** OAuth2 bearer-token requests carry the validated access token on the request context (`pkg/iam/oauth2/request_context.go`). Before IAM policy evaluation, `iam.Authorizer` checks registered `iam.ScopeSet` mappings via `ScopeSet.Allows` (`RegisterScopes`, same composition model as `RegisterPolicySet`). Each domain package exports an `OAuth2ScopeSet()` (or `IAMOAuth2ScopeSet()` in `pkg/iam`) and registers it at service startup. The check uses explicit scope→action lists — no `:read` / `:get` heuristics at enforcement time. Session, personal API key, and SCIM auth skip the check (no access token on context). Unmapped IAM actions **deny** OAuth requests (fail closed). Enforcement reads scopes from the access token directly. + +To add a new OAuth surface for OAuth clients: add namespace-level scope constants in the owning package's `oauth2_scopes.go`, map IAM actions in that package's `OAuth2ScopeSet()`, and register the set on the authorizer at service startup. Write scopes are registered only when their mutating IAM actions are mapped. + ## Built-in role policies | Role | Access level | @@ -282,6 +293,26 @@ const ( | `AUDITOR` | Read-only, excludes internal/employee content | | `EMPLOYEE` | Can sign documents and view internal content | +## OAuth2 API scopes + +OAuth2 scopes for API access are defined as `coredata.OAuth2Scope` constants in each owning package (for example [`pkg/probo/oauth2_scopes.go`](../../pkg/probo/oauth2_scopes.go), [`pkg/iam/oauth2_scopes.go`](../../pkg/iam/oauth2_scopes.go)). [`pkg/coredata/oauth2_scope.go`](../../pkg/coredata/oauth2_scope.go) defines the persistence type. Standard OIDC scopes live in [`pkg/iam/oauth2/scope.go`](../../pkg/iam/oauth2/scope.go). Register scope sets with `Authorizer.RegisterScopes`; discovery scopes are derived from each `ScopeSet` automatically. + +**Format:** + +- Read: `v1::read` (e.g. `v1:privacy:read`, `v1:document:read`, `v1:org:read`) +- Write / full: `v1:` without the `:read` suffix (e.g. `v1:org`, `v1:connector`, `v1:agent`) + +Scopes are namespace- or product-level only — no resource segments (e.g. `v1:privacy:dpia` is not supported). + +**Discovery:** + +- Authorization server (RFC 8414): `scopes_supported` on `/.well-known/oauth-authorization-server` lists OIDC + all API scopes; `protected_resources` links to the resource metadata document +- Protected resource (RFC 9728): `scopes_supported` on `/.well-known/oauth-protected-resource` lists `openid` plus API scopes + +**Enforcement:** OAuth2 bearer-token requests carry the validated access token on the request context (`pkg/iam/oauth2/request_context.go`). Before IAM policy evaluation, `iam.Authorizer` runs an OAuth2 scope gate built from registered `iam.ScopeSet` mappings (`RegisterScopes`, same composition model as `RegisterPolicySet`). Each domain package exports an `OAuth2ScopeSet()` (or `IAMOAuth2ScopeSet()` in `pkg/iam`) and registers it at service startup. The gate uses explicit scope→action lists — no `:read` / `:get` heuristics at enforcement time. Session, personal API key, and SCIM auth skip the gate (no access token on context). Unmapped IAM actions **deny** OAuth requests (fail closed). Enforcement reads scopes from the access token directly. + +Add new namespace-level scope constants in the owning package's `oauth2_scopes.go`, map their IAM actions in that package's `OAuth2ScopeSet()`, and register that set on the authorizer when the surface is ready for OAuth clients. Write scopes are registered only when their mutating IAM actions are mapped. + ## New entity IAM wiring When adding a new entity that needs authorization: diff --git a/e2e/console/oauth2_scope_test.go b/e2e/console/oauth2_scope_test.go new file mode 100644 index 000000000..127451e45 --- /dev/null +++ b/e2e/console/oauth2_scope_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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") +} diff --git a/e2e/console/oauth2_test.go b/e2e/console/oauth2_test.go index 657038e2a..6453b1eeb 100644 --- a/e2e/console/oauth2_test.go +++ b/e2e/console/oauth2_test.go @@ -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() diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index fbabefc98..56508b8f5 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -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(), diff --git a/e2e/internal/testutil/graphql.go b/e2e/internal/testutil/graphql.go index 2de0eec55..2503d89d6 100644 --- a/e2e/internal/testutil/graphql.go +++ b/e2e/internal/testutil/graphql.go @@ -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) } diff --git a/e2e/internal/testutil/oauth2.go b/e2e/internal/testutil/oauth2.go index ab0f84058..9dd041153 100644 --- a/e2e/internal/testutil/oauth2.go +++ b/e2e/internal/testutil/oauth2.go @@ -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"}, diff --git a/pkg/accessreview/actions.go b/pkg/accessreview/actions.go index 838a69e81..a8768050b 100644 --- a/pkg/accessreview/actions.go +++ b/pkg/accessreview/actions.go @@ -42,4 +42,7 @@ const ( ActionSourceUpdate = "access-review:source:update" ActionSourceDelete = "access-review:source:delete" ActionSourceSync = "access-review:source:sync" + + // Driver catalog actions (global deployment-scoped catalog). + ActionDriverCatalogList = "access-review:driver-catalog:list" ) diff --git a/pkg/accessreview/oauth2_scopes.go b/pkg/accessreview/oauth2_scopes.go new file mode 100644 index 000000000..de872ea11 --- /dev/null +++ b/pkg/accessreview/oauth2_scopes.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 accessreview + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" +) + +const ( + ScopeV1AccessReviewRead coredata.OAuth2Scope = "v1:access-review:read" + ScopeV1AccessReview coredata.OAuth2Scope = "v1:access-review" +) + +// OAuth2ScopeSet returns OAuth2 scope mappings for access-review actions. +func OAuth2ScopeSet() *iam.ScopeSet { + return iam.CreateScopeSet( + map[coredata.OAuth2Scope][]iam.Action{ + ScopeV1AccessReviewRead: { + ActionCampaignGet, + ActionCampaignList, + ActionEntryGet, + ActionEntryList, + ActionSourceGet, + ActionSourceList, + ActionDriverCatalogList, + }, + ScopeV1AccessReview: { + ActionCampaignCreate, + ActionCampaignUpdate, + ActionCampaignDelete, + ActionCampaignStart, + ActionCampaignClose, + ActionCampaignCancel, + ActionCampaignAddSource, + ActionCampaignRemoveSource, + ActionEntryDecide, + ActionEntryFlag, + ActionSourceCreate, + ActionSourceUpdate, + ActionSourceDelete, + ActionSourceSync, + }, + }, + ) +} diff --git a/pkg/accessreview/policies.go b/pkg/accessreview/policies.go index 27fc8e9b0..cfa6f797f 100644 --- a/pkg/accessreview/policies.go +++ b/pkg/accessreview/policies.go @@ -48,6 +48,17 @@ var ReadAccessPolicy = policy.NewPolicy( ).WithSID("access-review-read-access").When(organizationCondition), ).WithDescription("Read-only access-review access") +// DriverCatalogPolicy grants every authenticated identity read access to the +// global access-review driver catalog. The catalog is deployment-scoped and has +// no organization scoping, so the allow has no condition. +var DriverCatalogPolicy = policy.NewPolicy( + "access-review:driver-catalog", + "Access Review Driver Catalog", + policy.Allow( + ActionDriverCatalogList, + ).WithSID("read-access-review-driver-catalog"), +).WithDescription("Allows every authenticated user to read the global access-review driver catalog") + // PolicySet returns the PolicySet for the access-review service. It is owned by // this package and registered into the authorizer at composition time so the // access-review authorization rules live alongside the access-review domain @@ -56,5 +67,6 @@ func PolicySet() *iam.PolicySet { return iam.NewPolicySet(). AddRolePolicy("OWNER", FullAccessPolicy). AddRolePolicy("ADMIN", FullAccessPolicy). - AddRolePolicy("VIEWER", ReadAccessPolicy) + AddRolePolicy("VIEWER", ReadAccessPolicy). + AddIdentityScopedPolicy(DriverCatalogPolicy) } diff --git a/pkg/agentrun/oauth2_scopes.go b/pkg/agentrun/oauth2_scopes.go new file mode 100644 index 000000000..78c89fff7 --- /dev/null +++ b/pkg/agentrun/oauth2_scopes.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 agentrun + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" +) + +const ( + ScopeV1AgentRead coredata.OAuth2Scope = "v1:agent:read" + ScopeV1Agent coredata.OAuth2Scope = "v1:agent" +) + +// OAuth2ScopeSet returns OAuth2 scope mappings for agent-run actions. +func OAuth2ScopeSet() *iam.ScopeSet { + return iam.CreateScopeSet( + map[coredata.OAuth2Scope][]iam.Action{ + ScopeV1AgentRead: { + ActionAgentRunGet, + ActionAgentRunList, + }, + ScopeV1Agent: { + ActionAgentRunApprove, + }, + }, + ) +} diff --git a/pkg/coredata/oauth2_scope.go b/pkg/coredata/oauth2_scope.go index 29955232c..be6e08471 100644 --- a/pkg/coredata/oauth2_scope.go +++ b/pkg/coredata/oauth2_scope.go @@ -27,32 +27,12 @@ type ( OAuth2Scopes []OAuth2Scope ) -const ( - OAuth2ScopeOpenID OAuth2Scope = "openid" - OAuth2ScopeProfile OAuth2Scope = "profile" - OAuth2ScopeEmail OAuth2Scope = "email" - OAuth2ScopeOfflineAccess OAuth2Scope = "offline_access" -) - var ( _ fmt.Stringer = OAuth2Scope("") _ encoding.TextMarshaler = OAuth2Scope("") _ encoding.TextUnmarshaler = (*OAuth2Scope)(nil) ) -func (v OAuth2Scope) IsValid() bool { - switch v { - case - OAuth2ScopeOpenID, - OAuth2ScopeProfile, - OAuth2ScopeEmail, - OAuth2ScopeOfflineAccess: - return true - } - - return false -} - func (v OAuth2Scope) String() string { return string(v) } @@ -62,12 +42,7 @@ func (v OAuth2Scope) MarshalText() ([]byte, error) { } func (v *OAuth2Scope) UnmarshalText(text []byte) error { - val := OAuth2Scope(text) - if !val.IsValid() { - return fmt.Errorf("invalid OAuth2Scope value: %q", string(text)) - } - - *v = val + *v = OAuth2Scope(text) return nil } diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index f5aa47431..1c408178f 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -27,6 +27,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/iam/policy" ) @@ -82,19 +83,21 @@ type AuthorizeMultiParams struct { // Authorizer evaluates authorization requests against registered policies. type Authorizer struct { - pg *pg.Client - evaluator *policy.Evaluator - policySet *PolicySet - logger *log.Logger + pg *pg.Client + evaluator *policy.Evaluator + policySet *PolicySet + oauth2ScopeSet *ScopeSet + logger *log.Logger } // NewAuthorizer creates a new Authorizer instance. func NewAuthorizer(pgClient *pg.Client, logger *log.Logger) *Authorizer { return &Authorizer{ - pg: pgClient, - evaluator: policy.NewEvaluator(), - policySet: NewPolicySet(), - logger: logger, + pg: pgClient, + evaluator: policy.NewEvaluator(), + policySet: NewPolicySet(), + oauth2ScopeSet: NewScopeSet(), + logger: logger, } } @@ -103,6 +106,37 @@ func (a *Authorizer) RegisterPolicySet(ps *PolicySet) { a.policySet.Merge(ps) } +// RegisterScopes merges OAuth2 scope-to-action mappings into the authorizer. +func (a *Authorizer) RegisterScopes(ss *ScopeSet) { + a.oauth2ScopeSet.Merge(ss) +} + +// APIScopes returns OAuth2 API scopes advertised in discovery metadata. +func (a *Authorizer) APIScopes() []coredata.OAuth2Scope { + if a.oauth2ScopeSet == nil { + return []coredata.OAuth2Scope{} + } + + return a.oauth2ScopeSet.APIScopes() +} + +func (a *Authorizer) checkOAuth2Scope( + ctx context.Context, + principal gid.GID, + action Action, +) error { + accessToken, ok := oauth2.AccessTokenFromContext(ctx) + if !ok { + return nil + } + + if a.oauth2ScopeSet == nil || !a.oauth2ScopeSet.Allows(accessToken.Scopes, action) { + return NewInsufficientOAuth2ScopeError(principal, action) + } + + return nil +} + // Authorize checks if the principal is allowed to perform the action on the resource. func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) (*coredata.Scope, error) { return a.AuthorizeBatch( @@ -459,6 +493,22 @@ func (a *Authorizer) evaluateMultiInTx( continue } + if err := a.checkOAuth2Scope(ctx, params.Principal, item.Action); err != nil { + decisions[i] = err + a.logDecision( + ctx, + DecisionRecord{ + Effect: effectError, + Action: item.Action, + ResourceID: item.Resource, + Principal: params.Principal, + Reason: err.Error(), + }, + ) + + continue + } + req := policy.AuthorizationRequest{ Principal: params.Principal, Resource: item.Resource, diff --git a/pkg/iam/authorizer_oauth2_scope_test.go b/pkg/iam/authorizer_oauth2_scope_test.go new file mode 100644 index 000000000..f3d8d4565 --- /dev/null +++ b/pkg/iam/authorizer_oauth2_scope_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 iam + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/oauth2" +) + +func TestAuthorizer_checkOAuth2Scope(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + principal := gid.New(gid.NilTenant, coredata.IdentityEntityType) + action := Action("core:organization:get") + + t.Run("skips when access token is absent", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + + err := a.checkOAuth2Scope(context.Background(), principal, action) + require.NoError(t, err) + }) + + t.Run("denies before IAM when scopes are not registered", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + + ctx := oauth2.ContextWithAccessToken( + context.Background(), + &coredata.OAuth2AccessToken{Scopes: coredata.OAuth2Scopes{scopeV1OrgRead}}, + ) + + err := a.checkOAuth2Scope(ctx, principal, action) + require.Error(t, err) + + scopeErr, ok := errors.AsType[*ErrInsufficientOAuth2Scope](err) + require.True(t, ok) + assert.Equal(t, principal, scopeErr.IdentityID) + assert.Equal(t, action, scopeErr.Action) + }) + + t.Run("allows when registered scopes authorize the action", func(t *testing.T) { + t.Parallel() + + a := NewAuthorizer(nil, nil) + a.RegisterScopes( + CreateScopeSet( + map[coredata.OAuth2Scope][]Action{ + scopeV1OrgRead: {action}, + }, + ), + ) + + ctx := oauth2.ContextWithAccessToken( + context.Background(), + &coredata.OAuth2AccessToken{Scopes: coredata.OAuth2Scopes{scopeV1OrgRead}}, + ) + + err := a.checkOAuth2Scope(ctx, principal, action) + require.NoError(t, err) + }) +} diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 98bfa1745..f160a5d1f 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -203,6 +203,23 @@ func (e ErrInsufficientPermissions) Error() string { return fmt.Sprintf("identity %q does not have sufficient permissions to perform action %s on entity %q", e.IdentityID, e.Action, e.EntityID) } +type ErrInsufficientOAuth2Scope struct { + IdentityID gid.GID + Action Action +} + +func NewInsufficientOAuth2ScopeError(identityID gid.GID, action Action) error { + return &ErrInsufficientOAuth2Scope{IdentityID: identityID, Action: action} +} + +func (e ErrInsufficientOAuth2Scope) Error() string { + return fmt.Sprintf( + "identity %q does not have an OAuth2 scope granting action %s", + e.IdentityID, + e.Action, + ) +} + type ErrMixedOrganizationBatch struct { Action Action OrganizationIDs []string diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index d0cba78c0..be93db965 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -94,9 +94,6 @@ const ( ActionOAuth2ConsentGet = "iam:oauth2-consent:get" ActionOAuth2ConsentApprove = "iam:oauth2-consent:approve" - // Connector actions - ActionConnectorGet = "iam:connector:get" - // Audit log entry actions ActionAuditLogEntryGet = "iam:audit-log-entry:get" ActionAuditLogEntryList = "iam:audit-log-entry:list" diff --git a/pkg/iam/oauth2server/errors.go b/pkg/iam/oauth2/errors.go similarity index 99% rename from pkg/iam/oauth2server/errors.go rename to pkg/iam/oauth2/errors.go index 393c3a52b..47923afd6 100644 --- a/pkg/iam/oauth2server/errors.go +++ b/pkg/iam/oauth2/errors.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server +package oauth2 import ( "errors" diff --git a/pkg/iam/oauth2server/gc.go b/pkg/iam/oauth2/gc.go similarity index 96% rename from pkg/iam/oauth2server/gc.go rename to pkg/iam/oauth2/gc.go index a7a6413c3..638a49730 100644 --- a/pkg/iam/oauth2server/gc.go +++ b/pkg/iam/oauth2/gc.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server +package oauth2 import ( "context" @@ -45,11 +45,11 @@ func NewGarbageCollector( ) *GarbageCollector { h := &gcHandler{ pg: pgClient, - logger: logger.Named("oauth2server.garbage_collector"), + logger: logger.Named("oauth.garbage_collector"), } return worker.New( - "oauth2server.garbage_collector", + "oauth.garbage_collector", h, logger, append( diff --git a/pkg/iam/oauth2server/id_token.go b/pkg/iam/oauth2/id_token.go similarity index 97% rename from pkg/iam/oauth2server/id_token.go rename to pkg/iam/oauth2/id_token.go index 1ba4aa738..fe330a82e 100644 --- a/pkg/iam/oauth2server/id_token.go +++ b/pkg/iam/oauth2/id_token.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server +package oauth2 import ( "crypto/rsa" @@ -96,10 +96,10 @@ func NewIDTokenClaims( for _, scope := range scopes { switch scope { - case coredata.OAuth2ScopeEmail: + case ScopeEmail: claims.Email = email claims.EmailVerified = &emailVerified - case coredata.OAuth2ScopeProfile: + case ScopeProfile: claims.Name = fullName } } diff --git a/pkg/iam/oauth2server/id_token_test.go b/pkg/iam/oauth2/id_token_test.go similarity index 79% rename from pkg/iam/oauth2server/id_token_test.go rename to pkg/iam/oauth2/id_token_test.go index c8adf871c..8ce5ce895 100644 --- a/pkg/iam/oauth2server/id_token_test.go +++ b/pkg/iam/oauth2/id_token_test.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server_test +package oauth2_test import ( "crypto/sha256" @@ -24,7 +24,7 @@ import ( "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/uri" ) @@ -42,7 +42,7 @@ func TestComputeAtHash(t *testing.T) { h := sha256.Sum256([]byte(accessToken)) expected := base64.RawURLEncoding.EncodeToString(h[:16]) - result := oauth2server.ComputeAtHash(accessToken) + result := oauth2.ComputeAtHash(accessToken) assert.Equal(t, expected, result) }, ) @@ -52,8 +52,8 @@ func TestComputeAtHash(t *testing.T) { func(t *testing.T) { t.Parallel() - hash1 := oauth2server.ComputeAtHash("token-a") - hash2 := oauth2server.ComputeAtHash("token-b") + hash1 := oauth2.ComputeAtHash("token-a") + hash2 := oauth2.ComputeAtHash("token-b") assert.NotEqual(t, hash1, hash2) }, ) @@ -63,7 +63,7 @@ func TestComputeAtHash(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ComputeAtHash("") + result := oauth2.ComputeAtHash("") assert.NotEmpty(t, result) }, ) @@ -73,8 +73,8 @@ func TestComputeAtHash(t *testing.T) { func(t *testing.T) { t.Parallel() - hash1 := oauth2server.ComputeAtHash("same-token") - hash2 := oauth2server.ComputeAtHash("same-token") + hash1 := oauth2.ComputeAtHash("same-token") + hash2 := oauth2.ComputeAtHash("same-token") assert.Equal(t, hash1, hash2) }, ) @@ -92,12 +92,12 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID}, "", "", "user@example.com", @@ -123,12 +123,12 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID}, "test-nonce", "", "", @@ -146,12 +146,12 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID}, "", "access-token-123", "", @@ -160,7 +160,7 @@ func TestNewIDTokenClaims(t *testing.T) { 1*time.Hour, ) - expected := oauth2server.ComputeAtHash("access-token-123") + expected := oauth2.ComputeAtHash("access-token-123") assert.Equal(t, expected, claims.AtHash) }, ) @@ -170,12 +170,12 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail}, "", "", "user@example.com", @@ -195,12 +195,12 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeProfile}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeProfile}, "", "", "", @@ -218,15 +218,15 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, coredata.OAuth2Scopes{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeEmail, - coredata.OAuth2ScopeProfile, + oauth2.ScopeOpenID, + oauth2.ScopeEmail, + oauth2.ScopeProfile, }, "nonce-val", "access-token", @@ -252,12 +252,12 @@ func TestNewIDTokenClaims(t *testing.T) { ttl := 2 * time.Hour before := time.Now() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID}, "", "", "", @@ -279,12 +279,12 @@ func TestNewIDTokenClaims(t *testing.T) { func(t *testing.T) { t.Parallel() - claims := oauth2server.NewIDTokenClaims( + claims := oauth2.NewIDTokenClaims( testIssuer, identityID, clientID, authTime, - coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail}, + coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail}, "", "", "user@example.com", diff --git a/pkg/iam/oauth2server/metadata.go b/pkg/iam/oauth2/metadata.go similarity index 92% rename from pkg/iam/oauth2server/metadata.go rename to pkg/iam/oauth2/metadata.go index 42a3bf599..be262bdd1 100644 --- a/pkg/iam/oauth2server/metadata.go +++ b/pkg/iam/oauth2/metadata.go @@ -12,9 +12,11 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server +package oauth2 import ( + "slices" + "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/uri" ) @@ -33,6 +35,7 @@ type ( RevocationEndpoint uri.URI `json:"revocation_endpoint"` DeviceAuthorizationEndpoint uri.URI `json:"device_authorization_endpoint"` ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"` + ProtectedResources []uri.URI `json:"protected_resources,omitempty"` ResponseTypesSupported []coredata.OAuth2ResponseType `json:"response_types_supported"` GrantTypesSupported []coredata.OAuth2GrantType `json:"grant_types_supported"` TokenEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_methods_supported"` @@ -57,7 +60,7 @@ type ( } ) -func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata { +func NewMetadata(issuer uri.URI, endpoints Endpoints, apiScopes []coredata.OAuth2Scope) *ServerMetadata { return &ServerMetadata{ Issuer: issuer, AuthorizationEndpoint: endpoints.Authorization, @@ -68,12 +71,16 @@ func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata { IntrospectionEndpoint: endpoints.Introspection, RevocationEndpoint: endpoints.Revocation, DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization, - ScopesSupported: []coredata.OAuth2Scope{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeProfile, - coredata.OAuth2ScopeEmail, - coredata.OAuth2ScopeOfflineAccess, - }, + ScopesSupported: slices.Concat( + []coredata.OAuth2Scope{ + ScopeOpenID, + ScopeProfile, + ScopeEmail, + ScopeOfflineAccess, + }, + apiScopes, + ), + ProtectedResources: []uri.URI{issuer}, ResponseTypesSupported: []coredata.OAuth2ResponseType{ coredata.OAuth2ResponseTypeCode, }, diff --git a/pkg/iam/oauth2server/metadata_test.go b/pkg/iam/oauth2/metadata_test.go similarity index 87% rename from pkg/iam/oauth2server/metadata_test.go rename to pkg/iam/oauth2/metadata_test.go index a588ef23f..fee9c09b9 100644 --- a/pkg/iam/oauth2server/metadata_test.go +++ b/pkg/iam/oauth2/metadata_test.go @@ -12,23 +12,27 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server_test +package oauth2_test import ( + "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/uri" ) func TestNewMetadata(t *testing.T) { t.Parallel() + apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead} + issuer := uri.URI("https://auth.example.com") - endpoints := oauth2server.Endpoints{ + endpoints := oauth2.Endpoints{ Authorization: "https://auth.example.com/authorize", Token: "https://auth.example.com/token", Userinfo: "https://auth.example.com/userinfo", @@ -39,7 +43,7 @@ func TestNewMetadata(t *testing.T) { DeviceAuthorization: "https://auth.example.com/device", } - metadata := oauth2server.NewMetadata(issuer, endpoints) + metadata := oauth2.NewMetadata(issuer, endpoints, apiScopes) require.NotNil(t, metadata) t.Run( @@ -72,16 +76,28 @@ func TestNewMetadata(t *testing.T) { func(t *testing.T) { t.Parallel() - assert.Equal( - t, + expectedScopes := slices.Concat( []coredata.OAuth2Scope{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeProfile, - coredata.OAuth2ScopeEmail, - coredata.OAuth2ScopeOfflineAccess, + oauth2.ScopeOpenID, + oauth2.ScopeProfile, + oauth2.ScopeEmail, + oauth2.ScopeOfflineAccess, }, - metadata.ScopesSupported, + apiScopes, ) + + assert.Equal(t, expectedScopes, metadata.ScopesSupported) + assert.Contains(t, metadata.ScopesSupported, oauth2.ScopeOpenID) + assert.Contains(t, metadata.ScopesSupported, probo.ScopeV1DocumentRead) + }, + ) + + t.Run( + "protected resources", + func(t *testing.T) { + t.Parallel() + + assert.Equal(t, []uri.URI{issuer}, metadata.ProtectedResources) }, ) diff --git a/pkg/iam/oauth2server/pkce.go b/pkg/iam/oauth2/pkce.go similarity index 98% rename from pkg/iam/oauth2server/pkce.go rename to pkg/iam/oauth2/pkce.go index 9655b5fec..d2432a775 100644 --- a/pkg/iam/oauth2server/pkce.go +++ b/pkg/iam/oauth2/pkce.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server +package oauth2 import ( "crypto/sha256" diff --git a/pkg/iam/oauth2server/pkce_test.go b/pkg/iam/oauth2/pkce_test.go similarity index 85% rename from pkg/iam/oauth2server/pkce_test.go rename to pkg/iam/oauth2/pkce_test.go index e69375c9c..362f7774a 100644 --- a/pkg/iam/oauth2server/pkce_test.go +++ b/pkg/iam/oauth2/pkce_test.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server_test +package oauth2_test import ( "crypto/sha256" @@ -22,7 +22,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" ) func computeS256Challenge(verifier string) string { @@ -41,7 +41,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( verifier, challenge, coredata.OAuth2CodeChallengeMethodS256, @@ -56,7 +56,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( "wrong-verifier", challenge, coredata.OAuth2CodeChallengeMethodS256, @@ -71,7 +71,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( verifier, "wrong-challenge", coredata.OAuth2CodeChallengeMethodS256, @@ -86,7 +86,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( verifier, challenge, coredata.OAuth2CodeChallengeMethod("plain"), @@ -101,7 +101,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( verifier, challenge, coredata.OAuth2CodeChallengeMethod(""), @@ -116,7 +116,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( "", challenge, coredata.OAuth2CodeChallengeMethodS256, @@ -131,7 +131,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( verifier, "", coredata.OAuth2CodeChallengeMethodS256, @@ -146,7 +146,7 @@ func TestValidateCodeChallenge(t *testing.T) { func(t *testing.T) { t.Parallel() - result := oauth2server.ValidateCodeChallenge( + result := oauth2.ValidateCodeChallenge( "", "", coredata.OAuth2CodeChallengeMethodS256, diff --git a/pkg/iam/oauth2/protected_resource_metadata.go b/pkg/iam/oauth2/protected_resource_metadata.go new file mode 100644 index 000000000..d897e50f0 --- /dev/null +++ b/pkg/iam/oauth2/protected_resource_metadata.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2 + +import ( + "slices" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/uri" +) + +// ProtectedResourceMetadata represents the RFC 9728 protected resource metadata +// document published at /.well-known/oauth-protected-resource. +type ProtectedResourceMetadata struct { + Resource uri.URI `json:"resource"` + AuthorizationServers []uri.URI `json:"authorization_servers"` + BearerMethodsSupported []string `json:"bearer_methods_supported"` + ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"` +} + +func NewProtectedResourceMetadata( + resource uri.URI, + authorizationServer uri.URI, + apiScopes []coredata.OAuth2Scope, +) *ProtectedResourceMetadata { + return &ProtectedResourceMetadata{ + Resource: resource, + AuthorizationServers: []uri.URI{authorizationServer}, + BearerMethodsSupported: []string{ + "header", + }, + ScopesSupported: slices.Concat( + []coredata.OAuth2Scope{ScopeOpenID}, + apiScopes, + ), + } +} diff --git a/pkg/iam/oauth2/protected_resource_metadata_test.go b/pkg/iam/oauth2/protected_resource_metadata_test.go new file mode 100644 index 000000000..4fb4fff5e --- /dev/null +++ b/pkg/iam/oauth2/protected_resource_metadata_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/uri" +) + +func TestNewProtectedResourceMetadata(t *testing.T) { + t.Parallel() + + apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead} + + resource := uri.URI("https://app.example.com") + authorizationServer := uri.URI("https://app.example.com") + + metadata := oauth2.NewProtectedResourceMetadata(resource, authorizationServer, apiScopes) + require.NotNil(t, metadata) + + assert.Equal(t, resource, metadata.Resource) + assert.Equal(t, []uri.URI{authorizationServer}, metadata.AuthorizationServers) + assert.Equal(t, []string{"header"}, metadata.BearerMethodsSupported) + assert.Contains(t, metadata.ScopesSupported, oauth2.ScopeOpenID) + assert.Contains(t, metadata.ScopesSupported, probo.ScopeV1DocumentRead) + assert.NotContains(t, metadata.ScopesSupported, oauth2.ScopeProfile) +} diff --git a/pkg/iam/oauth2/request_context.go b/pkg/iam/oauth2/request_context.go new file mode 100644 index 000000000..8e2e089cc --- /dev/null +++ b/pkg/iam/oauth2/request_context.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2 + +import ( + "context" + + "go.probo.inc/probo/pkg/coredata" +) + +type contextKey struct{ name string } + +var ( + accessTokenContextKey = &contextKey{name: "oauth2_access_token"} + clientContextKey = &contextKey{name: "oauth2_client"} +) + +func ContextWithAccessToken(ctx context.Context, accessToken *coredata.OAuth2AccessToken) context.Context { + return context.WithValue(ctx, accessTokenContextKey, accessToken) +} + +func AccessTokenFromContext(ctx context.Context) (*coredata.OAuth2AccessToken, bool) { + accessToken, ok := ctx.Value(accessTokenContextKey).(*coredata.OAuth2AccessToken) + + return accessToken, ok +} + +func ContextWithClient(ctx context.Context, client *coredata.OAuth2Client) context.Context { + return context.WithValue(ctx, clientContextKey, client) +} + +func ClientFromContext(ctx context.Context) (*coredata.OAuth2Client, bool) { + client, ok := ctx.Value(clientContextKey).(*coredata.OAuth2Client) + + return client, ok +} diff --git a/pkg/iam/oauth2/scope.go b/pkg/iam/oauth2/scope.go new file mode 100644 index 000000000..42bea9598 --- /dev/null +++ b/pkg/iam/oauth2/scope.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 oauth2 + +import ( + "fmt" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + ScopeOpenID coredata.OAuth2Scope = "openid" + ScopeProfile coredata.OAuth2Scope = "profile" + ScopeEmail coredata.OAuth2Scope = "email" + ScopeOfflineAccess coredata.OAuth2Scope = "offline_access" +) + +func IsStandardScope(scope coredata.OAuth2Scope) bool { + switch scope { + case ScopeOpenID, ScopeProfile, ScopeEmail, ScopeOfflineAccess: + return true + } + + return false +} + +func IsValid(scope coredata.OAuth2Scope) bool { + return IsStandardScope(scope) +} + +func UnmarshalScope(text []byte) (coredata.OAuth2Scope, error) { + scope := coredata.OAuth2Scope(text) + if !IsValid(scope) { + return "", fmt.Errorf("invalid oauth2 scope value: %q", string(text)) + } + + return scope, nil +} + +func UnmarshalScopes(text []byte) (coredata.OAuth2Scopes, error) { + str := string(text) + if str == "" { + return nil, nil + } + + fields := strings.Fields(str) + + scopes := make(coredata.OAuth2Scopes, len(fields)) + for i, f := range fields { + scope, err := UnmarshalScope([]byte(f)) + if err != nil { + return nil, err + } + + scopes[i] = scope + } + + return scopes, nil +} diff --git a/pkg/coredata/oauth2_scope_test.go b/pkg/iam/oauth2/scope_test.go similarity index 70% rename from pkg/coredata/oauth2_scope_test.go rename to pkg/iam/oauth2/scope_test.go index 64f7e1ff3..97de5269b 100644 --- a/pkg/coredata/oauth2_scope_test.go +++ b/pkg/iam/oauth2/scope_test.go @@ -12,16 +12,17 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package coredata_test +package oauth2_test import ( "testing" "github.com/stretchr/testify/assert" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2" ) -func TestOAuth2Scope_IsValid(t *testing.T) { +func TestIsValid(t *testing.T) { t.Parallel() t.Run( @@ -29,7 +30,7 @@ func TestOAuth2Scope_IsValid(t *testing.T) { func(t *testing.T) { t.Parallel() - assert.True(t, coredata.OAuth2ScopeOfflineAccess.IsValid()) + assert.True(t, oauth2.IsValid(oauth2.ScopeOfflineAccess)) }, ) @@ -38,12 +39,12 @@ func TestOAuth2Scope_IsValid(t *testing.T) { func(t *testing.T) { t.Parallel() - assert.False(t, coredata.OAuth2Scope("admin").IsValid()) + assert.False(t, oauth2.IsValid(coredata.OAuth2Scope("admin"))) }, ) } -func TestOAuth2Scope_UnmarshalText(t *testing.T) { +func TestUnmarshalScope(t *testing.T) { t.Parallel() t.Run( @@ -51,11 +52,9 @@ func TestOAuth2Scope_UnmarshalText(t *testing.T) { func(t *testing.T) { t.Parallel() - var scope coredata.OAuth2Scope - - err := scope.UnmarshalText([]byte("offline_access")) + scope, err := oauth2.UnmarshalScope([]byte("offline_access")) assert.NoError(t, err) - assert.Equal(t, coredata.OAuth2ScopeOfflineAccess, scope) + assert.Equal(t, oauth2.ScopeOfflineAccess, scope) }, ) @@ -64,15 +63,13 @@ func TestOAuth2Scope_UnmarshalText(t *testing.T) { func(t *testing.T) { t.Parallel() - var scope coredata.OAuth2Scope - - err := scope.UnmarshalText([]byte("admin")) + _, err := oauth2.UnmarshalScope([]byte("admin")) assert.Error(t, err) }, ) } -func TestOAuth2Scopes_Contains(t *testing.T) { +func TestOAuth2ScopesContains(t *testing.T) { t.Parallel() t.Run( @@ -81,10 +78,10 @@ func TestOAuth2Scopes_Contains(t *testing.T) { t.Parallel() scopes := coredata.OAuth2Scopes{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeOfflineAccess, + oauth2.ScopeOpenID, + oauth2.ScopeOfflineAccess, } - assert.True(t, scopes.Contains(coredata.OAuth2ScopeOfflineAccess)) + assert.True(t, scopes.Contains(oauth2.ScopeOfflineAccess)) }, ) @@ -94,20 +91,20 @@ func TestOAuth2Scopes_Contains(t *testing.T) { t.Parallel() scopes := coredata.OAuth2Scopes{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeProfile, + oauth2.ScopeOpenID, + oauth2.ScopeProfile, } - assert.False(t, scopes.Contains(coredata.OAuth2ScopeOfflineAccess)) + assert.False(t, scopes.Contains(oauth2.ScopeOfflineAccess)) }, ) } -func TestOAuth2Scopes_OrDefault(t *testing.T) { +func TestOAuth2ScopesOrDefault(t *testing.T) { t.Parallel() defaultScopes := coredata.OAuth2Scopes{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeProfile, + oauth2.ScopeOpenID, + oauth2.ScopeProfile, } t.Run( @@ -138,7 +135,7 @@ func TestOAuth2Scopes_OrDefault(t *testing.T) { func(t *testing.T) { t.Parallel() - scopes := coredata.OAuth2Scopes{coredata.OAuth2ScopeEmail} + scopes := coredata.OAuth2Scopes{oauth2.ScopeEmail} result := scopes.OrDefault(defaultScopes) assert.Equal(t, scopes, result) }, diff --git a/pkg/iam/oauth2server/service.go b/pkg/iam/oauth2/service.go similarity index 98% rename from pkg/iam/oauth2server/service.go rename to pkg/iam/oauth2/service.go index 42b3fdc22..2a13586cf 100644 --- a/pkg/iam/oauth2server/service.go +++ b/pkg/iam/oauth2/service.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package oauth2server +package oauth2 import ( "context" @@ -194,11 +194,6 @@ func (s *Service) Run(ctx context.Context) error { return s.gc.Run(ctx) } -// Metadata returns the OIDC discovery document. -func (s *Service) Metadata(endpoints Endpoints) *ServerMetadata { - return NewMetadata(s.baseURL, endpoints) -} - // JWKS returns the public key set. func (s *Service) JWKS() *jose.JWKS { jwks := &jose.JWKS{ @@ -384,7 +379,7 @@ func (s *Service) ExchangeAuthorizationCode( } } - if code.Scopes.Contains(coredata.OAuth2ScopeOpenID) { + if code.Scopes.Contains(ScopeOpenID) { var ( idTokenClaims = NewIDTokenClaims( s.baseURL, @@ -426,7 +421,7 @@ func (s *Service) ExchangeAuthorizationCode( return fmt.Errorf("cannot create access token: %w", err) } - if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && code.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) { + if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && code.Scopes.Contains(ScopeOfflineAccess) { refreshTokenValue = rand.MustHexString(refreshTokenByteLength) refreshToken := &coredata.OAuth2RefreshToken{ @@ -560,7 +555,7 @@ func (s *Service) RefreshToken( ) } - if previousRefreshToken.Scopes.Contains(coredata.OAuth2ScopeOpenID) { + if previousRefreshToken.Scopes.Contains(ScopeOpenID) { var ( claims = NewIDTokenClaims( s.baseURL, @@ -689,7 +684,7 @@ func (s *Service) CreateDeviceCode( ) } - if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) { + if requestedScopes.Contains(ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) { return NewError( ErrInvalidScope, WithDescription("offline_access requires the refresh_token grant type"), @@ -846,7 +841,7 @@ func (s *Service) PollDeviceCode( idToken string ) - if deviceCode.Scopes.Contains(coredata.OAuth2ScopeOpenID) { + if deviceCode.Scopes.Contains(ScopeOpenID) { var ( claims = NewIDTokenClaims( s.baseURL, @@ -887,7 +882,7 @@ func (s *Service) PollDeviceCode( return fmt.Errorf("cannot create access token: %w", err) } - if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && deviceCode.Scopes.Contains(coredata.OAuth2ScopeOfflineAccess) { + if client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) && deviceCode.Scopes.Contains(ScopeOfflineAccess) { refreshTokenValue = rand.MustHexString(refreshTokenByteLength) refreshToken := &coredata.OAuth2RefreshToken{ @@ -1287,10 +1282,10 @@ func (s *Service) UserInfo( for _, scope := range scopes { switch scope { - case coredata.OAuth2ScopeEmail: + case ScopeEmail: claims["email"] = identity.EmailAddress.String() claims["email_verified"] = identity.EmailAddressVerified - case coredata.OAuth2ScopeProfile: + case ScopeProfile: claims["name"] = identity.FullName } } @@ -1445,7 +1440,7 @@ func (s *Service) Authorize( return fmt.Errorf("cannot authorize: requested scope exceeds client registration") } - if requestedScopes.Contains(coredata.OAuth2ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) { + if requestedScopes.Contains(ScopeOfflineAccess) && !client.HasGrantType(coredata.OAuth2GrantTypeRefreshToken) { return NewError( ErrInvalidScope, WithDescription("offline_access requires the refresh_token grant type"), diff --git a/pkg/iam/oauth2_scope_registrations_test.go b/pkg/iam/oauth2_scope_registrations_test.go new file mode 100644 index 000000000..fb07e251d --- /dev/null +++ b/pkg/iam/oauth2_scope_registrations_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/accessreview" + "go.probo.inc/probo/pkg/agentrun" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/probo" +) + +func allRegisteredOAuth2ScopeSets() *iam.ScopeSet { + return iam.NewScopeSet(). + Merge(iam.IAMOAuth2ScopeSet()). + Merge(probo.OAuth2ScopeSet()). + Merge(accessreview.OAuth2ScopeSet()). + Merge(agentrun.OAuth2ScopeSet()) +} + +func registerAllOAuth2ScopeSets(authorizer *iam.Authorizer) { + authorizer.RegisterScopes(iam.IAMOAuth2ScopeSet()) + authorizer.RegisterScopes(probo.OAuth2ScopeSet()) + authorizer.RegisterScopes(accessreview.OAuth2ScopeSet()) + authorizer.RegisterScopes(agentrun.OAuth2ScopeSet()) +} + +func TestRegisteredOAuth2ScopeSets_OrganizationRead(t *testing.T) { + t.Parallel() + + authorizer := iam.NewAuthorizer(nil, nil) + registerAllOAuth2ScopeSets(authorizer) + + scopeSet := allRegisteredOAuth2ScopeSets() + tokenScopes := coredata.OAuth2Scopes{probo.ScopeV1OrgRead} + + assert.True(t, scopeSet.Allows(tokenScopes, probo.ActionOrganizationGet)) + assert.False(t, scopeSet.Allows(tokenScopes, probo.ActionOrganizationUpdate)) + assert.False(t, scopeSet.Allows(tokenScopes, probo.ActionThirdPartyList)) +} + +func TestRegisteredOAuth2ScopeSets_UnmappedActionDenies(t *testing.T) { + t.Parallel() + + authorizer := iam.NewAuthorizer(nil, nil) + registerAllOAuth2ScopeSets(authorizer) + + scopeSet := allRegisteredOAuth2ScopeSets() + tokenScopes := coredata.OAuth2Scopes{ + probo.ScopeV1OrgRead, + probo.ScopeV1ThirdPartyRead, + } + + assert.False(t, scopeSet.Allows(tokenScopes, "core:unmapped:action")) +} diff --git a/pkg/iam/oauth2_scopes.go b/pkg/iam/oauth2_scopes.go new file mode 100644 index 000000000..75aeb018f --- /dev/null +++ b/pkg/iam/oauth2_scopes.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 iam + +import "go.probo.inc/probo/pkg/coredata" + +const ( + ScopeV1IAMRead coredata.OAuth2Scope = "v1:iam:read" + ScopeV1IAM coredata.OAuth2Scope = "v1:iam" +) + +// IAMOAuth2ScopeSet returns OAuth2 scope mappings for IAM actions. +func IAMOAuth2ScopeSet() *ScopeSet { + return CreateScopeSet( + map[coredata.OAuth2Scope][]Action{ + ScopeV1IAMRead: { + ActionOrganizationGet, + ActionOrganizationList, + ActionIdentityGet, + ActionSessionList, + ActionSessionGet, + ActionInvitationList, + ActionInvitationGet, + ActionMembershipGet, + ActionMembershipList, + ActionMembershipProfileGet, + ActionMembershipProfileList, + ActionPersonalAPIKeyGet, + ActionPersonalAPIKeyList, + ActionSAMLConfigurationGet, + ActionSAMLConfigurationList, + ActionSCIMConfigurationGet, + ActionSCIMEventList, + ActionSCIMEventGet, + ActionSCIMBridgeGet, + ActionOAuth2ConsentGet, + ActionAuditLogEntryGet, + ActionAuditLogEntryList, + }, + ScopeV1IAM: { + ActionOrganizationCreate, + ActionOrganizationUpdate, + ActionOrganizationDelete, + ActionIdentityUpdate, + ActionIdentityDelete, + ActionSessionRevoke, + ActionSessionRevokeAll, + ActionInvitationCreate, + ActionInvitationAccept, + ActionInvitationDelete, + ActionMembershipUpdate, + ActionMembershipDelete, + ActionMembershipRoleSetOwner, + ActionMembershipProfileCreate, + ActionMembershipProfileUpdate, + ActionMembershipProfileDelete, + ActionMembershipProfileActivate, + ActionMembershipProfileDeactivate, + ActionPersonalAPIKeyCreate, + ActionPersonalAPIKeyUpdate, + ActionPersonalAPIKeyDelete, + ActionSAMLConfigurationCreate, + ActionSAMLConfigurationUpdate, + ActionSAMLConfigurationDelete, + ActionSCIMConfigurationCreate, + ActionSCIMConfigurationUpdate, + ActionSCIMConfigurationDelete, + ActionSCIMBridgeCreate, + ActionSCIMBridgeUpdate, + ActionSCIMBridgeDelete, + ActionOAuth2ConsentApprove, + }, + }, + ) +} diff --git a/pkg/iam/scope_set.go b/pkg/iam/scope_set.go new file mode 100644 index 000000000..e950ef58b --- /dev/null +++ b/pkg/iam/scope_set.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 iam + +import ( + "cmp" + "maps" + "slices" + + "go.probo.inc/probo/pkg/coredata" +) + +// ScopeSet holds OAuth2 scope to IAM action mappings. Services create their +// own ScopeSet and register it on the Authorizer at composition time. +type ScopeSet struct { + scopeActions map[coredata.OAuth2Scope][]Action + actionScopes map[Action][]coredata.OAuth2Scope +} + +// NewScopeSet creates an empty ScopeSet. +func NewScopeSet() *ScopeSet { + return &ScopeSet{ + scopeActions: make(map[coredata.OAuth2Scope][]Action), + } +} + +// CreateScopeSet creates a ScopeSet from scope-to-action mappings. Entries with +// no actions are skipped. +func CreateScopeSet(mappings map[coredata.OAuth2Scope][]Action) *ScopeSet { + s := NewScopeSet() + + for scope, actions := range mappings { + if len(actions) == 0 { + continue + } + + s.scopeActions[scope] = append(s.scopeActions[scope], actions...) + } + + s.rebuildActionScopes() + + return s +} + +// Merge combines another ScopeSet into this one. +func (s *ScopeSet) Merge(other *ScopeSet) *ScopeSet { + for scope, actions := range other.scopeActions { + s.scopeActions[scope] = append(s.scopeActions[scope], actions...) + } + + s.rebuildActionScopes() + + return s +} + +// APIScopes returns every registered OAuth2 API scope in this set. +func (s *ScopeSet) APIScopes() []coredata.OAuth2Scope { + return sortedScopes(slices.Collect(maps.Keys(s.scopeActions))) +} + +// Allows reports whether tokenScopes authorize action. +func (s *ScopeSet) Allows(tokenScopes coredata.OAuth2Scopes, action Action) bool { + grantingScopes, ok := s.actionScopes[action] + if !ok { + return false + } + + return slices.ContainsFunc(grantingScopes, tokenScopes.Contains) +} + +func (s *ScopeSet) rebuildActionScopes() { + actionScopes := make(map[Action][]coredata.OAuth2Scope, len(s.scopeActions)*4) + + for scope, actions := range s.scopeActions { + for _, action := range actions { + actionScopes[action] = append(actionScopes[action], scope) + } + } + + s.actionScopes = actionScopes +} + +func sortedScopes(scopes []coredata.OAuth2Scope) []coredata.OAuth2Scope { + sorted := slices.Clone(scopes) + slices.SortFunc(sorted, func(a, b coredata.OAuth2Scope) int { + return cmp.Compare(string(a), string(b)) + }) + + return sorted +} diff --git a/pkg/iam/scope_set_test.go b/pkg/iam/scope_set_test.go new file mode 100644 index 000000000..3ea8ead45 --- /dev/null +++ b/pkg/iam/scope_set_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 iam + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestScopeSet_Allows(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + scopeSet := CreateScopeSet( + map[coredata.OAuth2Scope][]Action{ + scopeV1OrgRead: {"core:organization:get"}, + }, + ) + + tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} + + assert.True(t, scopeSet.Allows(tokenScopes, "core:organization:get")) + assert.False(t, scopeSet.Allows(tokenScopes, "core:organization:update")) +} + +func TestScopeSet_Merge(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + scopeSet := NewScopeSet(). + Merge( + CreateScopeSet( + map[coredata.OAuth2Scope][]Action{ + scopeV1OrgRead: {"core:organization:get"}, + }, + ), + ). + Merge( + CreateScopeSet( + map[coredata.OAuth2Scope][]Action{ + scopeV1OrgRead: {"core:organization-context:get"}, + }, + ), + ) + + tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} + + assert.True(t, scopeSet.Allows(tokenScopes, "core:organization:get")) + assert.True(t, scopeSet.Allows(tokenScopes, "core:organization-context:get")) +} + +func TestAuthorizer_RegisterScopes(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + authorizer := NewAuthorizer(nil, nil) + authorizer.RegisterScopes( + CreateScopeSet( + map[coredata.OAuth2Scope][]Action{ + scopeV1OrgRead: {"core:organization:get"}, + }, + ), + ) + + require.NotNil(t, authorizer.oauth2ScopeSet) + + tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} + assert.True(t, authorizer.oauth2ScopeSet.Allows(tokenScopes, "core:organization:get")) + assert.False(t, authorizer.oauth2ScopeSet.Allows(tokenScopes, "core:organization:update")) +} diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 0f90222e4..2ba799469 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -33,7 +33,7 @@ import ( "go.probo.inc/probo/pkg/crypto/passwdhash" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/iam/saml" "go.probo.inc/probo/pkg/iam/scim" @@ -67,7 +67,7 @@ type ( OIDCService *oidc.Service SCIMService *scim.Service APIKeyService *APIKeyService - OAuth2ServerService *oauth2server.Service + OAuth2ServerService *oauth2.Service Authorizer *Authorizer samlDomainVerifier *SAMLDomainVerifier @@ -95,8 +95,8 @@ type ( SCIMBridgePollInterval time.Duration GoogleOIDC oidc.ProviderConfig MicrosoftOIDC oidc.ProviderConfig - OAuth2ServerSigningKeys oauth2server.SigningKeys - OAuth2ServerOptions []oauth2server.Option + OAuth2ServerSigningKeys oauth2.SigningKeys + OAuth2ServerOptions []oauth2.Option } ) @@ -162,6 +162,7 @@ func NewService( cfg.Logger.Named("authorizer"), ) svc.Authorizer.RegisterPolicySet(IAMPolicySet()) + svc.Authorizer.RegisterScopes(IAMOAuth2ScopeSet()) samlService, err := saml.NewService(svc.pg, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger) if err != nil { @@ -194,11 +195,11 @@ func NewService( }, ) - svc.OAuth2ServerService = oauth2server.NewService( + svc.OAuth2ServerService = oauth2.NewService( pgClient, cfg.OAuth2ServerSigningKeys, uri.URI(cfg.BaseURL.String()), - cfg.Logger.Named("oauth2server"), + cfg.Logger.Named("oauth2"), cfg.OAuth2ServerOptions..., ) @@ -213,6 +214,16 @@ func NewService( return svc, nil } +// OAuth2ServerMetadata returns the OIDC discovery document. +func (s *Service) OAuth2ServerMetadata(endpoints oauth2.Endpoints) *oauth2.ServerMetadata { + return oauth2.NewMetadata(uri.URI(s.baseURL), endpoints, s.Authorizer.APIScopes()) +} + +// OAuth2ProtectedResourceMetadata returns the RFC 9728 protected resource metadata document. +func (s *Service) OAuth2ProtectedResourceMetadata(resource uri.URI) *oauth2.ProtectedResourceMetadata { + return oauth2.NewProtectedResourceMetadata(resource, resource, s.Authorizer.APIScopes()) +} + func (s *Service) IsSignUpEnabled() bool { return !s.disableSignup } diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index fb432215b..7079b5953 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -303,6 +303,7 @@ const ( // Connector actions (generic) ActionConnectorCreate = "core:connector:create" + ActionConnectorGet = "core:connector:get" ActionConnectorList = "core:connector:list" ActionConnectorDelete = "core:connector:delete" @@ -458,9 +459,8 @@ const ( ActionCookieConsentRecordList = "core:cookie-consent-record:list" // CommonThirdParty actions (global catalog, no organization scope). - ActionCommonThirdPartyGet = "core:common-third-party:get" - ActionCommonThirdPartyList = "core:common-third-party:list" - ActionAccessReviewDriverCatalogList = "core:access-review-driver-catalog:list" + ActionCommonThirdPartyGet = "core:common-third-party:get" + ActionCommonThirdPartyList = "core:common-third-party:list" // ElectronicSignature actions (tenant-scoped via the related document // version signature / trust center access). diff --git a/pkg/probo/oauth2_scopes.go b/pkg/probo/oauth2_scopes.go new file mode 100644 index 000000000..7805b5b01 --- /dev/null +++ b/pkg/probo/oauth2_scopes.go @@ -0,0 +1,448 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 probo + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" +) + +const ( + ScopeV1AssetRead coredata.OAuth2Scope = "v1:asset:read" + ScopeV1Asset coredata.OAuth2Scope = "v1:asset" + + ScopeV1AuditRead coredata.OAuth2Scope = "v1:audit:read" + ScopeV1Audit coredata.OAuth2Scope = "v1:audit" + + ScopeV1CommonThirdPartyRead coredata.OAuth2Scope = "v1:common-third-party:read" + ScopeV1CommonThirdParty coredata.OAuth2Scope = "v1:common-third-party" + + ScopeV1CompliancePageRead coredata.OAuth2Scope = "v1:compliance-page:read" + ScopeV1CompliancePage coredata.OAuth2Scope = "v1:compliance-page" + + ScopeV1ConnectorRead coredata.OAuth2Scope = "v1:connector:read" + ScopeV1Connector coredata.OAuth2Scope = "v1:connector" + + ScopeV1ControlRead coredata.OAuth2Scope = "v1:control:read" + ScopeV1Control coredata.OAuth2Scope = "v1:control" + + ScopeV1DatumRead coredata.OAuth2Scope = "v1:datum:read" + ScopeV1Datum coredata.OAuth2Scope = "v1:datum" + + ScopeV1DocumentRead coredata.OAuth2Scope = "v1:document:read" + ScopeV1Document coredata.OAuth2Scope = "v1:document" + + ScopeV1OrgRead coredata.OAuth2Scope = "v1:org:read" + ScopeV1Org coredata.OAuth2Scope = "v1:org" + + ScopeV1PrivacyRead coredata.OAuth2Scope = "v1:privacy:read" + ScopeV1Privacy coredata.OAuth2Scope = "v1:privacy" + + ScopeV1RiskRead coredata.OAuth2Scope = "v1:risk:read" + ScopeV1Risk coredata.OAuth2Scope = "v1:risk" + + ScopeV1SlackConnectionRead coredata.OAuth2Scope = "v1:slack-connection:read" + ScopeV1SlackConnection coredata.OAuth2Scope = "v1:slack-connection" + + ScopeV1TaskRead coredata.OAuth2Scope = "v1:task:read" + ScopeV1Task coredata.OAuth2Scope = "v1:task" + + ScopeV1ThirdPartyRead coredata.OAuth2Scope = "v1:third-party:read" + ScopeV1ThirdParty coredata.OAuth2Scope = "v1:third-party" + + ScopeV1WebhookRead coredata.OAuth2Scope = "v1:webhook:read" + ScopeV1Webhook coredata.OAuth2Scope = "v1:webhook" +) + +// OAuth2ScopeSet returns OAuth2 scope mappings for core probo actions. +func OAuth2ScopeSet() *iam.ScopeSet { + return iam.CreateScopeSet( + map[coredata.OAuth2Scope][]iam.Action{ + + ScopeV1AssetRead: { + ActionAssetGet, + ActionAssetList, + }, + ScopeV1Asset: { + ActionAssetCreate, + ActionAssetUpdate, + ActionAssetDelete, + ActionAssetPublish, + }, + ScopeV1AuditRead: { + ActionAuditGet, + ActionAuditList, + ActionFindingGet, + ActionFindingList, + ActionReportGet, + ActionReportGetReportUrl, + ActionReportDownloadUrlGet, + }, + ScopeV1Audit: { + ActionAuditCreate, + ActionAuditUpdate, + ActionAuditDelete, + ActionAuditReportUpload, + ActionAuditReportDelete, + ActionFindingCreate, + ActionFindingUpdate, + ActionFindingDelete, + ActionFindingAuditMappingCreate, + ActionFindingAuditMappingDelete, + ActionFindingPublish, + }, + ScopeV1CommonThirdPartyRead: { + ActionCommonThirdPartyGet, + ActionCommonThirdPartyList, + }, + ScopeV1CompliancePageRead: { + ActionTrustCenterGet, + ActionTrustCenterGetNda, + ActionTrustCenterAccessGet, + ActionTrustCenterAccessList, + ActionTrustCenterFileGet, + ActionTrustCenterFileList, + ActionTrustCenterFileGetFileUrl, + ActionTrustCenterReferenceList, + ActionTrustCenterReferenceGetLogoUrl, + ActionTrustCenterDocumentAccessList, + ActionMailingListUpdateList, + ActionMailingListSubscriberList, + ActionComplianceFrameworkList, + ActionComplianceExternalURLList, + ActionCustomDomainGet, + }, + ScopeV1CompliancePage: { + ActionTrustCenterUpdate, + ActionTrustCenterNonDisclosureAgreementUpload, + ActionTrustCenterNonDisclosureAgreementDelete, + ActionTrustCenterAccessCreate, + ActionTrustCenterAccessUpdate, + ActionTrustCenterAccessDelete, + ActionTrustCenterFileUpdate, + ActionTrustCenterFileDelete, + ActionTrustCenterFileCreate, + ActionTrustCenterReferenceCreate, + ActionTrustCenterReferenceUpdate, + ActionTrustCenterReferenceDelete, + ActionMailingListUpdateCreate, + ActionMailingListUpdateUpdate, + ActionMailingListUpdateSend, + ActionMailingListUpdateDelete, + ActionMailingListUpdate, + ActionMailingListSubscriberCreate, + ActionMailingListSubscriberDelete, + ActionComplianceFrameworkCreate, + ActionComplianceFrameworkDelete, + ActionComplianceFrameworkUpdateRank, + ActionComplianceExternalURLCreate, + ActionComplianceExternalURLUpdate, + ActionComplianceExternalURLDelete, + ActionCustomDomainCreate, + ActionCustomDomainDelete, + }, + ScopeV1ConnectorRead: { + ActionConnectorList, + ActionConnectorGet, + }, + ScopeV1Connector: { + ActionConnectorCreate, + ActionConnectorDelete, + ActionConnectorInitiate, + }, + ScopeV1ControlRead: { + ActionControlGet, + ActionControlList, + ActionMeasureGet, + ActionMeasureList, + ActionFrameworkGet, + ActionFrameworkList, + ActionFrameworkExport, + ActionObligationGet, + ActionObligationList, + ActionStatementOfApplicabilityList, + ActionStatementOfApplicabilityGet, + ActionApplicabilityStatementGet, + ActionApplicabilityStatementList, + }, + ScopeV1Control: { + ActionControlCreate, + ActionControlUpdate, + ActionControlDelete, + ActionControlMeasureMappingCreate, + ActionControlMeasureMappingDelete, + ActionControlDocumentMappingCreate, + ActionControlDocumentMappingDelete, + ActionControlAuditMappingCreate, + ActionControlAuditMappingDelete, + ActionControlObligationMappingCreate, + ActionControlObligationMappingDelete, + ActionMeasureCreate, + ActionMeasureUpdate, + ActionMeasureDelete, + ActionMeasureEvidenceUpload, + ActionMeasureImport, + ActionMeasureDocumentMappingCreate, + ActionMeasureDocumentMappingDelete, + ActionMeasureThirdPartyMappingCreate, + ActionMeasureThirdPartyMappingDelete, + ActionFrameworkCreate, + ActionFrameworkUpdate, + ActionFrameworkDelete, + ActionFrameworkImport, + ActionObligationCreate, + ActionObligationUpdate, + ActionObligationDelete, + ActionObligationPublish, + ActionStatementOfApplicabilityCreate, + ActionStatementOfApplicabilityUpdate, + ActionStatementOfApplicabilityDelete, + ActionStatementOfApplicabilityPublish, + ActionApplicabilityStatementCreate, + ActionApplicabilityStatementUpdate, + ActionApplicabilityStatementDelete, + }, + ScopeV1DatumRead: { + ActionDatumGet, + ActionDatumList, + }, + ScopeV1Datum: { + ActionDatumCreate, + ActionDatumUpdate, + ActionDatumDelete, + ActionDatumPublish, + }, + ScopeV1DocumentRead: { + ActionDocumentGet, + ActionDocumentList, + ActionDocumentVersionGet, + ActionDocumentVersionList, + ActionDocumentVersionExportPDF, + ActionDocumentVersionApprovalList, + ActionDocumentVersionExport, + ActionEmployeeDocumentGet, + ActionEmployeeDocumentList, + ActionEmployeeDocumentVersionExportPDF, + ActionDocumentVersionSignatureGet, + ActionDocumentVersionSignatureList, + ActionElectronicSignatureGet, + ActionFileGet, + }, + ScopeV1Document: { + ActionDocumentCreate, + ActionDocumentUpdate, + ActionDocumentDelete, + ActionDocumentChangelogGenerate, + ActionDocumentArchive, + ActionDocumentUnarchive, + ActionDocumentDeleteDraft, + ActionDocumentVersionSign, + ActionDocumentVersionRequestApproval, + ActionDocumentVersionVoidApproval, + ActionDocumentVersionApprove, + ActionDocumentVersionReject, + ActionDocumentVersionPublish, + ActionDocumentVersionSignatureRequest, + ActionDocumentVersionCancelSignature, + }, + ScopeV1OrgRead: { + ActionOrganizationGet, + ActionOrganizationGetLogoUrl, + ActionOrganizationGetHorizontalLogoUrl, + ActionOrganizationContextGet, + }, + ScopeV1Org: { + ActionOrganizationUpdate, + ActionOrganizationContextUpdate, + }, + ScopeV1PrivacyRead: { + ActionProcessingActivityList, + ActionProcessingActivityGet, + ActionDataProtectionImpactAssessmentList, + ActionDataProtectionImpactAssessmentGet, + ActionTransferImpactAssessmentList, + ActionTransferImpactAssessmentGet, + ActionRightsRequestList, + ActionRightsRequestGet, + ActionCookieBannerGet, + ActionCookieBannerList, + ActionCookieBannerVersionGet, + ActionCookieBannerVersionList, + ActionCookieCategoryGet, + ActionCookieCategoryList, + ActionCookieGet, + ActionCookieList, + ActionCookieConsentRecordList, + ActionTrackerPatternGet, + ActionTrackerPatternList, + ActionTrackerResourceGet, + ActionTrackerResourceList, + }, + ScopeV1Privacy: { + ActionProcessingActivityCreate, + ActionProcessingActivityUpdate, + ActionProcessingActivityDelete, + ActionProcessingActivityPublish, + ActionDataProtectionImpactAssessmentCreate, + ActionDataProtectionImpactAssessmentUpdate, + ActionDataProtectionImpactAssessmentDelete, + ActionDataProtectionImpactAssessmentPublish, + ActionTransferImpactAssessmentCreate, + ActionTransferImpactAssessmentUpdate, + ActionTransferImpactAssessmentDelete, + ActionTransferImpactAssessmentPublish, + ActionRightsRequestCreate, + ActionRightsRequestUpdate, + ActionRightsRequestDelete, + ActionCookieBannerCreate, + ActionCookieBannerUpdate, + ActionCookieBannerDelete, + ActionCookieBannerActivate, + ActionCookieBannerDeactivate, + ActionCookieBannerRegeneratePolicy, + ActionCookieBannerVersionPublish, + ActionCookieCategoryCreate, + ActionCookieCategoryUpdate, + ActionCookieCategoryDelete, + ActionCookieCreate, + ActionCookieUpdate, + ActionCookieDelete, + ActionTrackerPatternCreate, + ActionTrackerPatternUpdate, + ActionTrackerPatternDelete, + ActionTrackerResourceCreate, + ActionTrackerResourceUpdate, + ActionTrackerResourceDelete, + }, + ScopeV1RiskRead: { + ActionRiskGet, + ActionRiskList, + ActionRiskAssessmentGet, + ActionRiskAssessmentList, + ActionRiskAssessmentScopeGet, + ActionRiskAssessmentScopeList, + ActionRiskAssessmentNodeGet, + ActionRiskAssessmentNodeList, + ActionRiskAssessmentBoundaryGet, + ActionRiskAssessmentBoundaryList, + ActionRiskAssessmentProcessGet, + ActionRiskAssessmentProcessList, + ActionRiskAssessmentThreatGet, + ActionRiskAssessmentThreatList, + ActionRiskAssessmentScenarioGet, + ActionRiskAssessmentScenarioList, + }, + ScopeV1Risk: { + ActionRiskCreate, + ActionRiskUpdate, + ActionRiskDelete, + ActionRiskMeasureMappingCreate, + ActionRiskMeasureMappingDelete, + ActionRiskDocumentMappingCreate, + ActionRiskDocumentMappingDelete, + ActionRiskObligationMappingCreate, + ActionRiskObligationMappingDelete, + ActionRiskPublish, + ActionRiskAssessmentCreate, + ActionRiskAssessmentUpdate, + ActionRiskAssessmentDelete, + ActionRiskAssessmentScopeCreate, + ActionRiskAssessmentScopeUpdate, + ActionRiskAssessmentScopeDelete, + ActionRiskAssessmentNodeCreate, + ActionRiskAssessmentNodeUpdate, + ActionRiskAssessmentNodeDelete, + ActionRiskAssessmentBoundaryCreate, + ActionRiskAssessmentBoundaryUpdate, + ActionRiskAssessmentBoundaryDelete, + ActionRiskAssessmentProcessCreate, + ActionRiskAssessmentProcessUpdate, + ActionRiskAssessmentProcessDelete, + ActionRiskAssessmentThreatCreate, + ActionRiskAssessmentThreatUpdate, + ActionRiskAssessmentThreatDelete, + ActionRiskAssessmentScenarioCreate, + ActionRiskAssessmentScenarioUpdate, + ActionRiskAssessmentScenarioDelete, + ActionRiskAssessmentScenarioThreatLink, + ActionRiskAssessmentScenarioThreatUnlink, + ActionRiskAssessmentScenarioRiskLink, + ActionRiskAssessmentScenarioRiskUnlink, + }, + ScopeV1SlackConnectionRead: { + ActionSlackConnectionList, + }, + ScopeV1TaskRead: { + ActionTaskGet, + ActionTaskList, + ActionEvidenceList, + }, + ScopeV1Task: { + ActionTaskCreate, + ActionTaskUpdate, + ActionTaskDelete, + ActionTaskAssign, + ActionTaskUnassign, + ActionEvidenceDelete, + }, + ScopeV1ThirdPartyRead: { + ActionThirdPartyList, + ActionThirdPartyGet, + ActionThirdPartyRelationList, + ActionThirdPartyContactGet, + ActionThirdPartyContactList, + ActionThirdPartyServiceGet, + ActionThirdPartyServiceList, + ActionThirdPartyComplianceReportGet, + ActionThirdPartyComplianceReportList, + ActionThirdPartyBusinessAssociateAgreementGet, + ActionThirdPartyDataPrivacyAgreementGet, + ActionThirdPartyRiskAssessmentList, + }, + ScopeV1ThirdParty: { + ActionThirdPartyCreate, + ActionThirdPartyUpdate, + ActionThirdPartyDelete, + ActionThirdPartyVet, + ActionThirdPartyPublish, + ActionThirdPartyRelationCreate, + ActionThirdPartyContactCreate, + ActionThirdPartyContactUpdate, + ActionThirdPartyContactDelete, + ActionThirdPartyServiceCreate, + ActionThirdPartyServiceUpdate, + ActionThirdPartyServiceDelete, + ActionThirdPartyComplianceReportUpload, + ActionThirdPartyComplianceReportDelete, + ActionThirdPartyBusinessAssociateAgreementUpload, + ActionThirdPartyBusinessAssociateAgreementUpdate, + ActionThirdPartyBusinessAssociateAgreementDelete, + ActionThirdPartyDataPrivacyAgreementUpload, + ActionThirdPartyDataPrivacyAgreementUpdate, + ActionThirdPartyDataPrivacyAgreementDelete, + ActionThirdPartyRiskAssessmentCreate, + }, + ScopeV1WebhookRead: { + ActionWebhookSubscriptionList, + ActionWebhookSubscriptionGet, + }, + ScopeV1Webhook: { + ActionWebhookSubscriptionCreate, + ActionWebhookSubscriptionUpdate, + ActionWebhookSubscriptionDelete, + }, + }, + ) +} diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index c1c7509ea..31f04a494 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -196,18 +196,6 @@ var CommonThirdPartyCatalogPolicy = policy.NewPolicy( ).WithSID("read-common-third-party-catalog"), ).WithDescription("Allows every authenticated user to read the global common third-party catalog") -// AccessReviewDriverCatalogPolicy grants every authenticated identity -// read access to the global access-review driver catalog. The catalog -// is deployment-scoped and has no organization scoping, so the allow -// has no condition. -var AccessReviewDriverCatalogPolicy = policy.NewPolicy( - "probo:access-review-driver-catalog", - "Probo Access Review Driver Catalog", - policy.Allow( - ActionAccessReviewDriverCatalogList, - ).WithSID("read-access-review-driver-catalog"), -).WithDescription("Allows every authenticated user to read the global access-review driver catalog") - // EmployeePolicy defines permissions for employee role. var EmployeePolicy = policy.NewPolicy( "probo:employee", @@ -241,6 +229,5 @@ func ProboPolicySet() *iam.PolicySet { AddRolePolicy("VIEWER", ViewerPolicy). AddRolePolicy("AUDITOR", AuditorPolicy). AddRolePolicy("EMPLOYEE", EmployeePolicy). - AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy). - AddIdentityScopedPolicy(AccessReviewDriverCatalogPolicy) + AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy) } diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 8a82f3a7d..dcb5f0e1c 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -149,6 +149,7 @@ func NewService( } iamService.Authorizer.RegisterPolicySet(ProboPolicySet()) + iamService.Authorizer.RegisterScopes(OAuth2ScopeSet()) svc := &Service{ pg: pgClient, diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 36814f65b..5da19a483 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -60,7 +60,7 @@ import ( "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/mailer" "go.probo.inc/probo/pkg/mailman" @@ -385,7 +385,7 @@ func (impl *Implm) Run( } var ( - oauth2SigningKeys oauth2server.SigningKeys + oauth2SigningKeys oauth2.SigningKeys hasActive bool activeSigningKeyPEM string ) @@ -413,7 +413,7 @@ func (impl *Implm) Run( oauth2SigningKeys = append( oauth2SigningKeys, - oauth2server.SigningKey{ + oauth2.SigningKey{ PrivateKey: rsaKey, KID: kid, Active: keyCfg.Active, @@ -601,6 +601,8 @@ func (impl *Implm) Run( iamService.Authorizer.RegisterPolicySet(agentrun.PolicySet()) iamService.Authorizer.RegisterPolicySet(accessreview.PolicySet()) + iamService.Authorizer.RegisterScopes(agentrun.OAuth2ScopeSet()) + iamService.Authorizer.RegisterScopes(accessreview.OAuth2ScopeSet()) thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter) riskManagementService := riskmanagement.NewService(pgClient) @@ -1401,23 +1403,23 @@ func (impl *Implm) runTrustCenterServer( return ctx.Err() } -func oauth2ServerOptions(cfg OAuth2ServerConfig) []oauth2server.Option { - var opts []oauth2server.Option +func oauth2ServerOptions(cfg OAuth2ServerConfig) []oauth2.Option { + var opts []oauth2.Option if cfg.AccessTokenDuration > 0 { - opts = append(opts, oauth2server.WithAccessTokenDuration(time.Duration(cfg.AccessTokenDuration)*time.Second)) + opts = append(opts, oauth2.WithAccessTokenDuration(time.Duration(cfg.AccessTokenDuration)*time.Second)) } if cfg.RefreshTokenDuration > 0 { - opts = append(opts, oauth2server.WithRefreshTokenDuration(time.Duration(cfg.RefreshTokenDuration)*time.Second)) + opts = append(opts, oauth2.WithRefreshTokenDuration(time.Duration(cfg.RefreshTokenDuration)*time.Second)) } if cfg.AuthorizationCodeDuration > 0 { - opts = append(opts, oauth2server.WithAuthorizationCodeDuration(time.Duration(cfg.AuthorizationCodeDuration)*time.Second)) + opts = append(opts, oauth2.WithAuthorizationCodeDuration(time.Duration(cfg.AuthorizationCodeDuration)*time.Second)) } if cfg.DeviceCodeDuration > 0 { - opts = append(opts, oauth2server.WithDeviceCodeDuration(time.Duration(cfg.DeviceCodeDuration)*time.Second)) + opts = append(opts, oauth2.WithDeviceCodeDuration(time.Duration(cfg.DeviceCodeDuration)*time.Second)) } return opts diff --git a/pkg/server/api/authn/oauth2_access_token_middleware.go b/pkg/server/api/authn/oauth2_access_token_middleware.go index d1b9bda8d..2bd99e909 100644 --- a/pkg/server/api/authn/oauth2_access_token_middleware.go +++ b/pkg/server/api/authn/oauth2_access_token_middleware.go @@ -22,6 +22,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/bearertoken" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2" ) func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) http.Handler { @@ -53,6 +54,7 @@ func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) ht } ctx = ContextWithIdentity(ctx, identity) + ctx = oauth2.ContextWithAccessToken(ctx, accessToken) httpserver.LoggerFromContext(ctx).InfoCtx( ctx, diff --git a/pkg/server/api/authz/authorization.go b/pkg/server/api/authz/authorization.go index 319d0290c..1ca7f1ffc 100644 --- a/pkg/server/api/authz/authorization.go +++ b/pkg/server/api/authz/authorization.go @@ -28,9 +28,9 @@ import ( type ( AuthorizeFuncOption func(*iam.AuthorizeParams) - AuthorizeFunc func(context.Context, gid.GID, string, ...AuthorizeFuncOption) (*coredata.Scope, error) + AuthorizeFunc func(context.Context, gid.GID, iam.Action, ...AuthorizeFuncOption) (*coredata.Scope, error) BatchAuthorizeFuncOption func(*iam.AuthorizeBatchParams) - BatchAuthorizeFunc func(context.Context, string, []gid.GID, ...BatchAuthorizeFuncOption) (*coredata.Scope, error) + BatchAuthorizeFunc func(context.Context, iam.Action, []gid.GID, ...BatchAuthorizeFuncOption) (*coredata.Scope, error) ) func WithAttr(key, value string) AuthorizeFuncOption { @@ -78,7 +78,7 @@ func NewAuthorizeFunc( return func( ctx context.Context, objectID gid.GID, - action string, + action iam.Action, options ...AuthorizeFuncOption, ) (*coredata.Scope, error) { identity := authn.IdentityFromContext(ctx) @@ -108,6 +108,10 @@ func NewAuthorizeFunc( return nil, gqlutils.Forbidden(ctx, err) } + if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + return nil, gqlutils.Forbidden(ctx, err) + } + if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFoundf(ctx, "resource not found") } @@ -127,7 +131,7 @@ func NewBatchAuthorizeFunc( ) BatchAuthorizeFunc { return func( ctx context.Context, - action string, + action iam.Action, objectIDs []gid.GID, options ...BatchAuthorizeFuncOption, ) (*coredata.Scope, error) { @@ -158,6 +162,10 @@ func NewBatchAuthorizeFunc( return nil, gqlutils.Forbidden(ctx, err) } + if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + return nil, gqlutils.Forbidden(ctx, err) + } + if _, ok := errors.AsType[*iam.ErrMixedOrganizationBatch](err); ok { return nil, gqlutils.Invalid(ctx, err) } diff --git a/pkg/server/api/connect/v1/base_resolvers.go b/pkg/server/api/connect/v1/base_resolvers.go index 0e467f7ea..b0cbf9f3c 100644 --- a/pkg/server/api/connect/v1/base_resolvers.go +++ b/pkg/server/api/connect/v1/base_resolvers.go @@ -16,7 +16,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" @@ -176,7 +176,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return nil, gqlutils.NotFound(ctx, err) } - if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok { + if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok { return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description()) } diff --git a/pkg/server/api/connect/v1/oauth2_error.go b/pkg/server/api/connect/v1/oauth2_error.go index 6fc66a025..1bb0f5321 100644 --- a/pkg/server/api/connect/v1/oauth2_error.go +++ b/pkg/server/api/connect/v1/oauth2_error.go @@ -21,7 +21,7 @@ import ( "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/server/api/connect/v1/types" ) @@ -35,13 +35,13 @@ func (h *OAuth2Handler) handleAuthorizeError(w http.ResponseWriter, r *http.Requ } func (h *OAuth2Handler) renderOAuth2ErrorResponse(w http.ResponseWriter, r *http.Request, err error) { - oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err) + oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err) if !ok { httpserver.RenderError(w, http.StatusInternalServerError, err) return } - if errors.Is(err, oauth2server.ErrServerError) { + if errors.Is(err, oauth2.ErrServerError) { h.logger.ErrorCtx(r.Context(), "oauth2 server error", log.Error(err)) } @@ -54,15 +54,15 @@ func (h *OAuth2Handler) renderOAuth2ErrorResponse(w http.ResponseWriter, r *http } func isRedirectableError(err error) bool { - return errors.Is(err, oauth2server.ErrAccessDenied) || - errors.Is(err, oauth2server.ErrInvalidRequest) || - errors.Is(err, oauth2server.ErrInvalidScope) || - errors.Is(err, oauth2server.ErrUnauthorizedClient) || - errors.Is(err, oauth2server.ErrInvalidGrant) || - errors.Is(err, oauth2server.ErrUnsupportedGrantType) + return errors.Is(err, oauth2.ErrAccessDenied) || + errors.Is(err, oauth2.ErrInvalidRequest) || + errors.Is(err, oauth2.ErrInvalidScope) || + errors.Is(err, oauth2.ErrUnauthorizedClient) || + errors.Is(err, oauth2.ErrInvalidGrant) || + errors.Is(err, oauth2.ErrUnsupportedGrantType) } -func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int { +func oauth2ErrorStatusCode(err *oauth2.OAuth2Error) int { switch err.ErrorCode() { case "access_denied": return http.StatusForbidden @@ -75,22 +75,22 @@ func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int { } } -func toOAuth2Error(err error) *oauth2server.OAuth2Error { +func toOAuth2Error(err error) *oauth2.OAuth2Error { switch { - case errors.Is(err, oauth2server.ErrClientNotFound): - return oauth2server.NewError(oauth2server.ErrInvalidClient, oauth2server.WithDescription("client not found")) - case errors.Is(err, oauth2server.ErrInvalidRedirectURI): - return oauth2server.ErrInvalidRedirectURI - case errors.Is(err, oauth2server.ErrUnauthorizedMember): - return oauth2server.NewError(oauth2server.ErrUnauthorizedClient, oauth2server.WithDescription("client is private and user is not a member of the organization")) - case errors.Is(err, oauth2server.ErrDeviceCodeNotPending): - return oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("device code is not pending")) + case errors.Is(err, oauth2.ErrClientNotFound): + return oauth2.NewError(oauth2.ErrInvalidClient, oauth2.WithDescription("client not found")) + case errors.Is(err, oauth2.ErrInvalidRedirectURI): + return oauth2.ErrInvalidRedirectURI + case errors.Is(err, oauth2.ErrUnauthorizedMember): + return oauth2.NewError(oauth2.ErrUnauthorizedClient, oauth2.WithDescription("client is private and user is not a member of the organization")) + case errors.Is(err, oauth2.ErrDeviceCodeNotPending): + return oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("device code is not pending")) default: - if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok { + if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok { return oauthErr } - return oauth2server.NewError(oauth2server.ErrServerError, oauth2server.WithDescription("internal error")) + return oauth2.NewError(oauth2.ErrServerError, oauth2.WithDescription("internal error")) } } @@ -101,7 +101,7 @@ func redirectWithError(w http.ResponseWriter, r *http.Request, redirectURI, stat return } - oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err) + oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err) if !ok { httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) return diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go index 4f9f915a6..50900c787 100644 --- a/pkg/server/api/connect/v1/oauth2_handler.go +++ b/pkg/server/api/connect/v1/oauth2_handler.go @@ -15,7 +15,6 @@ package connect_v1 import ( - "context" "encoding/json" "errors" "fmt" @@ -30,18 +29,13 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/connect/v1/types" "go.probo.inc/probo/pkg/uri" ) -var ( - oauth2ClientContextKey = &ctxKey{name: "oauth2_client"} - oauth2AccessTokenContextKey = &ctxKey{name: "oauth2_access_token"} -) - type OAuth2Handler struct { iam *iam.Service sessionCookie *authn.Cookie @@ -63,29 +57,17 @@ func NewOAuth2Handler( } } -// oauth2ClientFromContext returns the authenticated OAuth2 client from context. -func oauth2ClientFromContext(r *http.Request) *coredata.OAuth2Client { - client, _ := r.Context().Value(oauth2ClientContextKey).(*coredata.OAuth2Client) - return client -} - -// oauth2AccessTokenFromContext returns the validated OAuth2 access token from context. -func oauth2AccessTokenFromContext(r *http.Request) *coredata.OAuth2AccessToken { - token, _ := r.Context().Value(oauth2AccessTokenContextKey).(*coredata.OAuth2AccessToken) - return token -} - // ClientAuthMiddleware authenticates the OAuth2 client from HTTP Basic auth // or POST body credentials and stores it in the request context. func (h *OAuth2Handler) ClientAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { client, err := h.authenticateClient(r) if err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient) + h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient) return } - ctx := context.WithValue(r.Context(), oauth2ClientContextKey, client) + ctx := oauth2.ContextWithClient(r.Context(), client) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -110,15 +92,15 @@ func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler { return } - ctx := context.WithValue(r.Context(), oauth2AccessTokenContextKey, accessToken) + ctx := oauth2.ContextWithAccessToken(r.Context(), accessToken) next.ServeHTTP(w, r.WithContext(ctx)) }) } -func (h *OAuth2Handler) endpoints() oauth2server.Endpoints { +func (h *OAuth2Handler) endpoints() oauth2.Endpoints { api := h.baseURL.String() + "/api/connect/v1" - return oauth2server.Endpoints{ + return oauth2.Endpoints{ Authorization: uri.URI(api + "/oauth2/authorize"), Token: uri.URI(api + "/oauth2/token"), Userinfo: uri.URI(api + "/oauth2/userinfo"), @@ -135,7 +117,7 @@ func (h *OAuth2Handler) endpoints() oauth2server.Endpoints { // DiscoveryHandler serves the OpenID Connect Discovery document. // GET /.well-known/openid-configuration func (h *OAuth2Handler) DiscoveryHandler(w http.ResponseWriter, r *http.Request) { - metadata := h.iam.OAuth2ServerService.Metadata(h.endpoints()) + metadata := h.iam.OAuth2ServerMetadata(h.endpoints()) PublicCache(w, 1*time.Hour) httpserver.RenderJSON(w, http.StatusOK, metadata) @@ -168,7 +150,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) var in types.OAuth2AuthorizeInput if err := in.DecodeQuery(r.URL.Query()); err != nil { - h.handleAuthorizeError(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err)), "", "") + h.handleAuthorizeError(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)), "", "") return } @@ -181,7 +163,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) code, err := h.iam.OAuth2ServerService.Authorize( r.Context(), - &oauth2server.AuthorizeRequest{ + &oauth2.AuthorizeRequest{ IdentityID: identity.ID, SessionID: session.ID, ResponseType: in.ResponseType, @@ -196,7 +178,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) }, ) - if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok { + if consentErr, ok := errors.AsType[*oauth2.ConsentRequiredError](err); ok { consentURL := h.baseURL.WithPath("/auth/consent"). WithQuery("consent_id", consentErr.ConsentID.String()). MustString() @@ -217,7 +199,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid form data"))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid form data"))) return } @@ -227,7 +209,7 @@ func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) { ) if err := grantType.UnmarshalText([]byte(value)); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrUnsupportedGrantType) + h.renderOAuth2ErrorResponse(w, r, oauth2.ErrUnsupportedGrantType) return } @@ -244,13 +226,15 @@ func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) { } func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request) { - var ( - client = oauth2ClientFromContext(r) - in = types.OAuth2IntrospectInput{} - ) + client, ok := oauth2.ClientFromContext(r.Context()) + if !ok { + h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient) + return + } + in := types.OAuth2IntrospectInput{} if err := in.DecodeForm(r); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err))) return } @@ -270,12 +254,12 @@ func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) { var ( - client = oauth2ClientFromContext(r) - in = types.OAuth2RevokeInput{} + client, _ = oauth2.ClientFromContext(r.Context()) + in = types.OAuth2RevokeInput{} ) if err := in.DecodeForm(r); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err))) return } @@ -300,7 +284,7 @@ func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) { func (h *OAuth2Handler) DeviceAuthHandler(w http.ResponseWriter, r *http.Request) { in := types.OAuth2DeviceAuthInput{} if err := in.DecodeForm(r); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err))) return } @@ -345,7 +329,7 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) h.renderOAuth2ErrorResponse( w, r, - oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid JSON body")), + oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid JSON body")), ) return @@ -369,15 +353,15 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) if len(in.Scopes) == 0 { in.Scopes = coredata.OAuth2Scopes{ - coredata.OAuth2ScopeOpenID, - coredata.OAuth2ScopeProfile, - coredata.OAuth2ScopeEmail, + oauth2.ScopeOpenID, + oauth2.ScopeProfile, + oauth2.ScopeEmail, } } clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient( r.Context(), - &oauth2server.RegisterClientRequest{ + &oauth2.RegisterClientRequest{ IdentityID: identity.ID, OrganizationID: in.OrganizationID, ClientName: in.ClientName, @@ -417,7 +401,13 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) // UserInfoHandler serves the OIDC UserInfo endpoint. // GET /oauth2/userinfo func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) { - accessToken := oauth2AccessTokenFromContext(r) + accessToken, ok := oauth2.AccessTokenFromContext(r.Context()) + if !ok { + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + + return + } claims, err := h.iam.OAuth2ServerService.UserInfo( r.Context(), @@ -425,7 +415,7 @@ func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) accessToken.Scopes, ) if err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrServerError) + h.renderOAuth2ErrorResponse(w, r, oauth2.ErrServerError) return } @@ -452,12 +442,12 @@ func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Cli } if clientIDStr == "" { - return nil, oauth2server.ErrInvalidClient + return nil, oauth2.ErrInvalidClient } clientID, err := gid.ParseGID(clientIDStr) if err != nil { - return nil, oauth2server.ErrInvalidClient + return nil, oauth2.ErrInvalidClient } return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientID, clientSecret) @@ -466,13 +456,13 @@ func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Cli func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { client, err := h.authenticateClient(r) if err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient) + h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient) return } var in types.OAuth2AuthorizationCodeGrantInput if err := in.DecodeForm(r); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithError(err))) return } @@ -484,7 +474,7 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h in.CodeVerifier, ) if err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired code"))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("invalid or expired code"))) return } @@ -495,19 +485,19 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { client, err := h.authenticateClient(r) if err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient) + h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient) return } var in types.OAuth2RefreshTokenGrantInput if err := in.DecodeForm(r); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithError(err))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithError(err))) return } result, err := h.iam.OAuth2ServerService.RefreshToken(r.Context(), client, in.RefreshToken) if err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("invalid or expired refresh token"))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("invalid or expired refresh token"))) return } @@ -518,7 +508,7 @@ func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.R func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request) { var in types.OAuth2DeviceCodeGrantInput if err := in.DecodeForm(r); err != nil { - h.renderOAuth2ErrorResponse(w, r, oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithError(err))) + h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err))) return } @@ -536,7 +526,7 @@ func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Req httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result)) } -func tokenResultToResponse(r *oauth2server.TokenResult) *types.OAuth2TokenResponse { +func tokenResultToResponse(r *oauth2.TokenResult) *types.OAuth2TokenResponse { return &types.OAuth2TokenResponse{ AccessToken: r.AccessToken, TokenType: r.TokenType, diff --git a/pkg/server/api/connect/v1/oauth2_resolvers.go b/pkg/server/api/connect/v1/oauth2_resolvers.go index dafd17a33..a4bcb6a34 100644 --- a/pkg/server/api/connect/v1/oauth2_resolvers.go +++ b/pkg/server/api/connect/v1/oauth2_resolvers.go @@ -12,7 +12,7 @@ import ( "strings" "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" "go.probo.inc/probo/pkg/server/api/connect/v1/types" @@ -39,13 +39,13 @@ func (r *mutationResolver) AuthorizeDevice(ctx context.Context, input types.Auth err := r.iam.OAuth2ServerService.AuthorizeDevice(ctx, identity.ID, session.ID, userCode) if err != nil { - if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok { + if consentErr, ok := errors.AsType[*oauth2.ConsentRequiredError](err); ok { return &types.AuthorizeDevicePayload{ ConsentID: &consentErr.ConsentID, }, nil } - if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok { + if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok { return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description()) } @@ -66,7 +66,7 @@ func (r *mutationResolver) ApproveConsent(ctx context.Context, input types.Appro result, err := r.iam.OAuth2ServerService.ApproveConsent( ctx, - &oauth2server.ConsentApprovalRequest{ + &oauth2.ConsentApprovalRequest{ ConsentID: input.ConsentID, IdentityID: identity.ID, SessionID: session.ID, diff --git a/pkg/server/api/connect/v1/types/oauth2.go b/pkg/server/api/connect/v1/types/oauth2.go index 1097e0953..d4204e9fe 100644 --- a/pkg/server/api/connect/v1/types/oauth2.go +++ b/pkg/server/api/connect/v1/types/oauth2.go @@ -21,7 +21,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/uri" ) @@ -328,7 +328,7 @@ func InactiveIntrospectResponse() *OAuth2IntrospectResponse { return &OAuth2IntrospectResponse{Active: false} } -func ActiveIntrospectResponse(result *oauth2server.IntrospectResult) *OAuth2IntrospectResponse { +func ActiveIntrospectResponse(result *oauth2.IntrospectResult) *OAuth2IntrospectResponse { return &OAuth2IntrospectResponse{ Active: true, Scope: result.Scopes, diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 34d1de8fc..350cc8dfd 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -16,7 +16,6 @@ import ( "go.probo.inc/probo/pkg/agentrun" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/console/v1/schema" @@ -33,7 +32,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error switch id.EntityType() { case coredata.OrganizationEntityType: - action = iam.ActionOrganizationGet + action = probo.ActionOrganizationGet loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { organization, err := r.probo.Organizations.Get(ctx, scope, id) if err != nil { @@ -547,7 +546,7 @@ func (r *queryResolver) CommonThirdParties(ctx context.Context, name string) ([] func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.ConnectorProviderInfo, error) { identity := authn.IdentityFromContext(ctx) - if _, err := r.authorize(ctx, identity.ID, probo.ActionAccessReviewDriverCatalogList); err != nil { + if _, err := r.authorize(ctx, identity.ID, accessreview.ActionDriverCatalogList); err != nil { return nil, err } diff --git a/pkg/server/api/console/v1/connector_oauth_client_metadata.go b/pkg/server/api/console/v1/connector_oauth2_client_metadata.go similarity index 90% rename from pkg/server/api/console/v1/connector_oauth_client_metadata.go rename to pkg/server/api/console/v1/connector_oauth2_client_metadata.go index 7867e82bf..3c82a6e39 100644 --- a/pkg/server/api/console/v1/connector_oauth_client_metadata.go +++ b/pkg/server/api/console/v1/connector_oauth2_client_metadata.go @@ -22,7 +22,7 @@ import ( "go.probo.inc/probo/pkg/connector" ) -// oauthClientMetadata is the OAuth Client ID Metadata Document (CIMD) +// oauth2ClientMetadata is the OAuth2 Client ID Metadata Document (CIMD) // published for public-client connectors. The deployment's // (baseURL + CIMDMetadataPath) URL is the OAuth client_id; providers such as // PostHog fetch this document server-to-server during authorization to learn @@ -37,7 +37,7 @@ const ( proboLogoURI = "https://www.probo.com/probo-logo-only.svg" ) -type oauthClientMetadata struct { +type oauth2ClientMetadata struct { ClientID string `json:"client_id"` ClientName string `json:"client_name"` ClientURI string `json:"client_uri"` @@ -48,11 +48,11 @@ type oauthClientMetadata struct { ResponseTypes []string `json:"response_types"` } -// handleConnectorOAuthClientMetadata serves the public, unauthenticated CIMD -// document. It is intentionally outside the auth middleware group: the OAuth +// handleConnectorOAuth2ClientMetadata serves the public, unauthenticated CIMD +// document. It is intentionally outside the auth middleware group: the OAuth2 // provider fetches it without any Probo credentials. -func handleConnectorOAuthClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc { - doc := oauthClientMetadata{ +func handleConnectorOAuth2ClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc { + doc := oauth2ClientMetadata{ ClientID: baseURL.WithPath(connector.CIMDMetadataPath).MustString(), ClientName: "Probo", ClientURI: proboBrandURI, diff --git a/pkg/server/api/console/v1/connector_oauth_client_metadata_test.go b/pkg/server/api/console/v1/connector_oauth2_client_metadata_test.go similarity index 94% rename from pkg/server/api/console/v1/connector_oauth_client_metadata_test.go rename to pkg/server/api/console/v1/connector_oauth2_client_metadata_test.go index 404dc4838..6c8f32311 100644 --- a/pkg/server/api/console/v1/connector_oauth_client_metadata_test.go +++ b/pkg/server/api/console/v1/connector_oauth2_client_metadata_test.go @@ -25,18 +25,18 @@ import ( "go.probo.inc/probo/pkg/baseurl" ) -// TestHandleConnectorOAuthClientMetadata verifies the public CIMD document: +// TestHandleConnectorOAuth2ClientMetadata verifies the public CIMD document: // PostHog fetches it server-to-server during authorization, so client_id, // redirect_uris (derived from the deployment base URL) and the public-client // token_endpoint_auth_method must be exactly right or the OAuth flow breaks. -func TestHandleConnectorOAuthClientMetadata(t *testing.T) { +func TestHandleConnectorOAuth2ClientMetadata(t *testing.T) { t.Parallel() base, err := baseurl.Parse("https://probo.example.com") require.NoError(t, err) rec := httptest.NewRecorder() - handleConnectorOAuthClientMetadata(base)( + handleConnectorOAuth2ClientMetadata(base)( rec, httptest.NewRequest(http.MethodGet, "/api/console/v1/connectors/oauth-client-metadata", nil), ) diff --git a/pkg/server/api/console/v1/dataloader/authorize.go b/pkg/server/api/console/v1/dataloader/authorize.go index 2e836e9ca..b65fb1c89 100644 --- a/pkg/server/api/console/v1/dataloader/authorize.go +++ b/pkg/server/api/console/v1/dataloader/authorize.go @@ -35,7 +35,7 @@ func NewAuthorizeFunc(logger *log.Logger) authz.AuthorizeFunc { return func( ctx context.Context, objectID gid.GID, - action string, + action iam.Action, options ...authz.AuthorizeFuncOption, ) (*coredata.Scope, error) { loaders := FromContext(ctx) @@ -66,6 +66,10 @@ func NewAuthorizeFunc(logger *log.Logger) authz.AuthorizeFunc { return nil, gqlutils.Forbidden(ctx, err) } + if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + return nil, gqlutils.Forbidden(ctx, err) + } + if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFoundf(ctx, "resource not found") } diff --git a/pkg/server/api/console/v1/dataloader/dataloader.go b/pkg/server/api/console/v1/dataloader/dataloader.go index 21eb670c3..0b1cb5200 100644 --- a/pkg/server/api/console/v1/dataloader/dataloader.go +++ b/pkg/server/api/console/v1/dataloader/dataloader.go @@ -41,7 +41,7 @@ type ( // batch together. AuthorizeKey struct { ResourceID gid.GID - Action string + Action iam.Action ResourceAttributes string DryRun bool SkipAssumptionCheck bool diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 6d4204b8f..238760e44 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -142,7 +142,7 @@ func NewMux( // is fetched server-to-server by public-client providers (PostHog) // during authorization, with no Probo credentials. Mounted outside the // auth group above. - r.Get("/connectors/oauth-client-metadata", handleConnectorOAuthClientMetadata(baseURL)) + r.Get("/connectors/oauth-client-metadata", handleConnectorOAuth2ClientMetadata(baseURL)) return r } diff --git a/pkg/server/api/files/v1/handler.go b/pkg/server/api/files/v1/handler.go index 0a70292be..b76f6dcc5 100644 --- a/pkg/server/api/files/v1/handler.go +++ b/pkg/server/api/files/v1/handler.go @@ -157,7 +157,18 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { scope, err := h.iamSvc.Authorizer.Authorize(ctx, params) if err != nil { + if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + jsonx.RenderForbidden(w) + return + } + + if _, ok := errors.AsType[*iam.ErrInsufficientPermissions](err); ok { + jsonx.RenderForbidden(w) + return + } + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) + return } diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index a643b2169..333ccd22e 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -82,6 +82,10 @@ func (r *Resolver) Authorize(ctx context.Context, entityID gid.GID, action iam.A return nil, fmt.Errorf("permission denied") } + if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + return nil, fmt.Errorf("insufficient scope") + } + if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok { return nil, fmt.Errorf("assumption required") } @@ -114,6 +118,10 @@ func (r *Resolver) AuthorizeBatch(ctx context.Context, entityIDs []gid.GID, acti return nil, fmt.Errorf("permission denied") } + if _, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + return nil, fmt.Errorf("insufficient scope") + } + if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok { return nil, fmt.Errorf("assumption required") } diff --git a/pkg/server/server.go b/pkg/server/server.go index 9e150b7ad..1cf2f25bd 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -34,7 +34,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/iam/oauth2server" + "go.probo.inc/probo/pkg/iam/oauth2" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/riskmanagement" @@ -155,6 +155,7 @@ func (s *Server) setupRoutes(baseURL string) { // document at the issuer root under well-known paths. s.router.Get("/.well-known/openid-configuration", s.oidcDiscoveryHandler) s.router.Get("/.well-known/oauth-authorization-server", s.oidcDiscoveryHandler) + s.router.Get("/.well-known/oauth-protected-resource", s.protectedResourceMetadataHandler) s.router.Mount("/api", http.StripPrefix("/api", s.apiServer)) s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler)) @@ -182,7 +183,7 @@ func (s *Server) setExtraHeaders(w http.ResponseWriter) { func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) { api := s.baseURL + "/api/connect/v1" - endpoints := oauth2server.Endpoints{ + endpoints := oauth2.Endpoints{ Authorization: uri.URI(api + "/oauth2/authorize"), Token: uri.URI(api + "/oauth2/token"), Userinfo: uri.URI(api + "/oauth2/userinfo"), @@ -193,7 +194,15 @@ func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) { DeviceAuthorization: uri.URI(api + "/oauth2/device"), } - metadata := s.iamService.OAuth2ServerService.Metadata(endpoints) + metadata := s.iamService.OAuth2ServerMetadata(endpoints) + + w.Header().Set("Cache-Control", "public, max-age=3600") + httpserver.RenderJSON(w, http.StatusOK, metadata) +} + +func (s *Server) protectedResourceMetadataHandler(w http.ResponseWriter, r *http.Request) { + resource := uri.URI(s.baseURL) + metadata := s.iamService.OAuth2ProtectedResourceMetadata(resource) w.Header().Set("Cache-Control", "public, max-age=3600") httpserver.RenderJSON(w, http.StatusOK, metadata)