Add OAuth2 API scope registration and enforcement
Register v1 API scopes in coredata, advertise them in OIDC discovery and protected-resource metadata, show them on the consent screen, and enforce scope-to-action mapping in the IAM Authorizer before policy evaluation. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
IconArrowsClockwise,
|
IconArrowsClockwise,
|
||||||
IconEnvelope,
|
IconEnvelope,
|
||||||
|
IconKey,
|
||||||
IconLockOpen,
|
IconLockOpen,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
@@ -68,6 +69,14 @@ const scopeIcons: Record<string, React.ReactNode> = {
|
|||||||
offline_access: <IconArrowsClockwise size={18} className="shrink-0 text-txt-tertiary" />,
|
offline_access: <IconArrowsClockwise size={18} className="shrink-0 text-txt-tertiary" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function scopeIcon(name: string): React.ReactNode {
|
||||||
|
return scopeIcons[name] ?? <IconKey size={18} className="shrink-0 text-txt-tertiary" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(name: string): string {
|
||||||
|
return scopeLabels[name] ?? name;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ConsentPage(props: {
|
export default function ConsentPage(props: {
|
||||||
queryRef: PreloadedQuery<ConsentPageQuery>;
|
queryRef: PreloadedQuery<ConsentPageQuery>;
|
||||||
}) {
|
}) {
|
||||||
@@ -192,16 +201,17 @@ export default function ConsentPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{consent.scopes.map((scope: string) => {
|
{consent.scopes.map((scope) => {
|
||||||
const label = scopeLabels[scope];
|
const label = scopeLabel(scope);
|
||||||
if (!label) return null;
|
const translated = scopeLabels[scope] ? __(label) : label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={scope}
|
key={scope}
|
||||||
className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-txt-secondary border border-border-mid rounded-lg"
|
className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-txt-secondary border border-border-mid rounded-lg"
|
||||||
>
|
>
|
||||||
{scopeIcons[scope]}
|
{scopeIcon(scope)}
|
||||||
{__(label)}
|
{translated}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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` |
|
| IAM role policies (`IAMPolicySet`) | `pkg/iam/iam_policies.go` |
|
||||||
| Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` |
|
| Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` |
|
||||||
| PolicySet registration | `pkg/iam/policy_set.go` |
|
| PolicySet registration | `pkg/iam/policy_set.go` |
|
||||||
|
| OAuth2 scope mappings (`ScopeSet`) | `pkg/iam/scope_set.go` |
|
||||||
|
| OAuth2 scope constants (per domain) | `pkg/<service>/oauth2_scopes.go` |
|
||||||
|
| OAuth2 discovery + request context | `pkg/iam/oauth2/` |
|
||||||
| GraphQL authz helper | `pkg/server/api/authz/authorization.go` |
|
| GraphQL authz helper | `pkg/server/api/authz/authorization.go` |
|
||||||
| MCP authz + recovery | `pkg/server/api/mcp/v1/resolver.go`, `mcputils/recovery.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
|
## Built-in role policies
|
||||||
|
|
||||||
| Role | Access level |
|
| Role | Access level |
|
||||||
@@ -282,6 +293,26 @@ const (
|
|||||||
| `AUDITOR` | Read-only, excludes internal/employee content |
|
| `AUDITOR` | Read-only, excludes internal/employee content |
|
||||||
| `EMPLOYEE` | Can sign documents and view internal 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:<namespace>:read` (e.g. `v1:privacy:read`, `v1:document:read`, `v1:org:read`)
|
||||||
|
- Write / full: `v1:<namespace>` 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
|
## New entity IAM wiring
|
||||||
|
|
||||||
When adding a new entity that needs authorization:
|
When adding a new entity that needs authorization:
|
||||||
|
|||||||
103
e2e/console/oauth2_scope_test.go
Normal file
103
e2e/console/oauth2_scope_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package console_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/e2e/internal/factory"
|
||||||
|
"go.probo.inc/probo/e2e/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOAuth2_ScopeEnforcementOnConsoleGraphQL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
factory.CreateThirdParty(owner, factory.Attrs{"name": "Scoped OAuth Vendor"})
|
||||||
|
|
||||||
|
const redirectURI = "http://localhost:9999/callback"
|
||||||
|
|
||||||
|
client := factory.CreateOAuth2ClientWithAPIScopes(
|
||||||
|
owner,
|
||||||
|
"openid v1:org:read",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
tokenResp := testutil.OAuth2PerformAuthorizationCodeFlowWithScopes(
|
||||||
|
t,
|
||||||
|
owner,
|
||||||
|
client.ClientID,
|
||||||
|
client.ClientSecret,
|
||||||
|
redirectURI,
|
||||||
|
"openid v1:org:read",
|
||||||
|
)
|
||||||
|
require.NotEmpty(t, tokenResp.AccessToken)
|
||||||
|
|
||||||
|
const getOrganizationQuery = `
|
||||||
|
query GetOrganization($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on Organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
allowedResp, err := testutil.ConsoleGraphQLWithAccessToken(
|
||||||
|
t,
|
||||||
|
tokenResp.AccessToken,
|
||||||
|
getOrganizationQuery,
|
||||||
|
map[string]any{
|
||||||
|
"id": owner.GetOrganizationID().String(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, allowedResp)
|
||||||
|
|
||||||
|
const listThirdPartiesQuery = `
|
||||||
|
query ListThirdParties($orgId: ID!) {
|
||||||
|
node(id: $orgId) {
|
||||||
|
... on Organization {
|
||||||
|
thirdParties(first: 10) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
deniedResp, err := testutil.ConsoleGraphQLWithAccessToken(
|
||||||
|
t,
|
||||||
|
tokenResp.AccessToken,
|
||||||
|
listThirdPartiesQuery,
|
||||||
|
map[string]any{
|
||||||
|
"orgId": owner.GetOrganizationID().String(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotNil(t, deniedResp)
|
||||||
|
require.NotEmpty(t, deniedResp.Errors)
|
||||||
|
|
||||||
|
code := deniedResp.Errors[0].Code()
|
||||||
|
msg := deniedResp.Errors[0].Message
|
||||||
|
isForbidden := code == "FORBIDDEN" ||
|
||||||
|
(code == "" && (strings.Contains(msg, "does not have sufficient permissions") || strings.Contains(msg, "insufficient permissions")))
|
||||||
|
require.True(t, isForbidden, "expected FORBIDDEN error, got code=%q message=%q", code, msg)
|
||||||
|
assert.Empty(t, deniedResp.DataString(), "expected no data on denied request")
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ func TestOAuth2_Discovery(t *testing.T) {
|
|||||||
assert.Contains(t, discovery.ScopesSupported, "profile")
|
assert.Contains(t, discovery.ScopesSupported, "profile")
|
||||||
assert.Contains(t, discovery.ScopesSupported, "email")
|
assert.Contains(t, discovery.ScopesSupported, "email")
|
||||||
assert.Contains(t, discovery.ScopesSupported, "offline_access")
|
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.ResponseTypesSupported, "code")
|
||||||
assert.Contains(t, discovery.CodeChallengeMethodsSupported, "S256")
|
assert.Contains(t, discovery.CodeChallengeMethodsSupported, "S256")
|
||||||
@@ -97,6 +98,46 @@ func TestOAuth2_Discovery(t *testing.T) {
|
|||||||
assert.Contains(t, discovery.ClaimsSupported, "name")
|
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) {
|
func TestOAuth2_JWKS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1230,6 +1230,33 @@ func CreateOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateOAuth2ClientWithAPIScopes(c *testutil.Client, scopes string, attrs Attrs) OAuth2ClientResult {
|
||||||
|
input := map[string]any{
|
||||||
|
"organization_id": c.GetOrganizationID().String(),
|
||||||
|
"client_name": SafeName("OAuth2 API Client"),
|
||||||
|
"visibility": "private",
|
||||||
|
"redirect_uris": []string{"http://localhost:9999/callback"},
|
||||||
|
"grant_types": []string{
|
||||||
|
"authorization_code",
|
||||||
|
"refresh_token",
|
||||||
|
},
|
||||||
|
"response_types": []string{"code"},
|
||||||
|
"token_endpoint_auth_method": "client_secret_basic",
|
||||||
|
"scopes": scopes,
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(input, attrs)
|
||||||
|
|
||||||
|
resp, raw, err := testutil.OAuth2RegisterClient(c, input)
|
||||||
|
require.NoError(c.T, err, "OAuth2 API client registration failed")
|
||||||
|
require.NotNil(c.T, resp, "OAuth2 API client registration returned nil (status=%d body=%s)", raw.StatusCode, string(raw.Body))
|
||||||
|
|
||||||
|
return OAuth2ClientResult{
|
||||||
|
ClientID: resp.ClientID,
|
||||||
|
ClientSecret: resp.ClientSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CreatePublicOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
func CreatePublicOAuth2Client(c *testutil.Client, attrs Attrs) OAuth2ClientResult {
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"organization_id": c.GetOrganizationID().String(),
|
"organization_id": c.GetOrganizationID().String(),
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -36,6 +38,16 @@ type GraphQLResponse struct {
|
|||||||
Errors []GraphQLError `json:"errors,omitempty"`
|
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 {
|
type GraphQLError struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Path []any `json:"path,omitempty"`
|
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)
|
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) {
|
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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && resp.Data != nil {
|
if result != nil && resp.DataString() != "" {
|
||||||
if err := json.Unmarshal(resp.Data, result); err != nil {
|
if err := json.Unmarshal(resp.Data, result); err != nil {
|
||||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && resp.Data != nil {
|
if result != nil && resp.DataString() != "" {
|
||||||
if err := json.Unmarshal(resp.Data, result); err != nil {
|
if err := json.Unmarshal(resp.Data, result); err != nil {
|
||||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
return fmt.Errorf("cannot unmarshal data: %w", err)
|
||||||
}
|
}
|
||||||
@@ -166,7 +240,7 @@ func (c *Client) ExecuteTrust(trustCenterID string, query string, variables map[
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && resp.Data != nil {
|
if result != nil && resp.DataString() != "" {
|
||||||
if err := json.Unmarshal(resp.Data, result); err != nil {
|
if err := json.Unmarshal(resp.Data, result); err != nil {
|
||||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
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)
|
return GraphQLErrors(gqlResp.Errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && gqlResp.Data != nil {
|
if result != nil && gqlResp.DataString() != "" {
|
||||||
if err := json.Unmarshal(gqlResp.Data, result); err != nil {
|
if err := json.Unmarshal(gqlResp.Data, result); err != nil {
|
||||||
return fmt.Errorf("cannot unmarshal data: %w", err)
|
return fmt.Errorf("cannot unmarshal data: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ type (
|
|||||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||||
ClaimsSupported []string `json:"claims_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 {
|
OAuth2JWKSResponse struct {
|
||||||
@@ -265,6 +273,28 @@ func OAuth2JWKS(c *Client) (*OAuth2JWKSResponse, *OAuth2HTTPResponse, error) {
|
|||||||
return &result, raw, nil
|
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.
|
// OAuth2RegisterClient registers a new OAuth2 client via dynamic registration.
|
||||||
func OAuth2RegisterClient(
|
func OAuth2RegisterClient(
|
||||||
c *Client,
|
c *Client,
|
||||||
@@ -890,13 +920,32 @@ func OAuth2PerformAuthorizationCodeFlow(
|
|||||||
) *OAuth2TokenResponse {
|
) *OAuth2TokenResponse {
|
||||||
t.Helper()
|
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()
|
verifier, challenge := GeneratePKCE()
|
||||||
|
|
||||||
params := url.Values{
|
params := url.Values{
|
||||||
"client_id": {clientID},
|
"client_id": {clientID},
|
||||||
"redirect_uri": {redirectURI},
|
"redirect_uri": {redirectURI},
|
||||||
"response_type": {"code"},
|
"response_type": {"code"},
|
||||||
"scope": {"openid email profile offline_access"},
|
"scope": {scopes},
|
||||||
"state": {"test-state"},
|
"state": {"test-state"},
|
||||||
"code_challenge": {challenge},
|
"code_challenge": {challenge},
|
||||||
"code_challenge_method": {"S256"},
|
"code_challenge_method": {"S256"},
|
||||||
|
|||||||
@@ -42,4 +42,7 @@ const (
|
|||||||
ActionSourceUpdate = "access-review:source:update"
|
ActionSourceUpdate = "access-review:source:update"
|
||||||
ActionSourceDelete = "access-review:source:delete"
|
ActionSourceDelete = "access-review:source:delete"
|
||||||
ActionSourceSync = "access-review:source:sync"
|
ActionSourceSync = "access-review:source:sync"
|
||||||
|
|
||||||
|
// Driver catalog actions (global deployment-scoped catalog).
|
||||||
|
ActionDriverCatalogList = "access-review:driver-catalog:list"
|
||||||
)
|
)
|
||||||
|
|||||||
58
pkg/accessreview/oauth2_scopes.go
Normal file
58
pkg/accessreview/oauth2_scopes.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,6 +48,17 @@ var ReadAccessPolicy = policy.NewPolicy(
|
|||||||
).WithSID("access-review-read-access").When(organizationCondition),
|
).WithSID("access-review-read-access").When(organizationCondition),
|
||||||
).WithDescription("Read-only access-review access")
|
).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
|
// 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
|
// this package and registered into the authorizer at composition time so the
|
||||||
// access-review authorization rules live alongside the access-review domain
|
// access-review authorization rules live alongside the access-review domain
|
||||||
@@ -56,5 +67,6 @@ func PolicySet() *iam.PolicySet {
|
|||||||
return iam.NewPolicySet().
|
return iam.NewPolicySet().
|
||||||
AddRolePolicy("OWNER", FullAccessPolicy).
|
AddRolePolicy("OWNER", FullAccessPolicy).
|
||||||
AddRolePolicy("ADMIN", FullAccessPolicy).
|
AddRolePolicy("ADMIN", FullAccessPolicy).
|
||||||
AddRolePolicy("VIEWER", ReadAccessPolicy)
|
AddRolePolicy("VIEWER", ReadAccessPolicy).
|
||||||
|
AddIdentityScopedPolicy(DriverCatalogPolicy)
|
||||||
}
|
}
|
||||||
|
|||||||
40
pkg/agentrun/oauth2_scopes.go
Normal file
40
pkg/agentrun/oauth2_scopes.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,32 +27,12 @@ type (
|
|||||||
OAuth2Scopes []OAuth2Scope
|
OAuth2Scopes []OAuth2Scope
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
OAuth2ScopeOpenID OAuth2Scope = "openid"
|
|
||||||
OAuth2ScopeProfile OAuth2Scope = "profile"
|
|
||||||
OAuth2ScopeEmail OAuth2Scope = "email"
|
|
||||||
OAuth2ScopeOfflineAccess OAuth2Scope = "offline_access"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ fmt.Stringer = OAuth2Scope("")
|
_ fmt.Stringer = OAuth2Scope("")
|
||||||
_ encoding.TextMarshaler = OAuth2Scope("")
|
_ encoding.TextMarshaler = OAuth2Scope("")
|
||||||
_ encoding.TextUnmarshaler = (*OAuth2Scope)(nil)
|
_ 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 {
|
func (v OAuth2Scope) String() string {
|
||||||
return string(v)
|
return string(v)
|
||||||
}
|
}
|
||||||
@@ -62,12 +42,7 @@ func (v OAuth2Scope) MarshalText() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *OAuth2Scope) UnmarshalText(text []byte) error {
|
func (v *OAuth2Scope) UnmarshalText(text []byte) error {
|
||||||
val := OAuth2Scope(text)
|
*v = OAuth2Scope(text)
|
||||||
if !val.IsValid() {
|
|
||||||
return fmt.Errorf("invalid OAuth2Scope value: %q", string(text))
|
|
||||||
}
|
|
||||||
|
|
||||||
*v = val
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/iam/oauth2"
|
||||||
"go.probo.inc/probo/pkg/iam/policy"
|
"go.probo.inc/probo/pkg/iam/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ type Authorizer struct {
|
|||||||
pg *pg.Client
|
pg *pg.Client
|
||||||
evaluator *policy.Evaluator
|
evaluator *policy.Evaluator
|
||||||
policySet *PolicySet
|
policySet *PolicySet
|
||||||
|
oauth2ScopeSet *ScopeSet
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ func NewAuthorizer(pgClient *pg.Client, logger *log.Logger) *Authorizer {
|
|||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
evaluator: policy.NewEvaluator(),
|
evaluator: policy.NewEvaluator(),
|
||||||
policySet: NewPolicySet(),
|
policySet: NewPolicySet(),
|
||||||
|
oauth2ScopeSet: NewScopeSet(),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +106,37 @@ func (a *Authorizer) RegisterPolicySet(ps *PolicySet) {
|
|||||||
a.policySet.Merge(ps)
|
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.
|
// 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) {
|
func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) (*coredata.Scope, error) {
|
||||||
return a.AuthorizeBatch(
|
return a.AuthorizeBatch(
|
||||||
@@ -459,6 +493,22 @@ func (a *Authorizer) evaluateMultiInTx(
|
|||||||
continue
|
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{
|
req := policy.AuthorizationRequest{
|
||||||
Principal: params.Principal,
|
Principal: params.Principal,
|
||||||
Resource: item.Resource,
|
Resource: item.Resource,
|
||||||
|
|||||||
85
pkg/iam/authorizer_oauth2_scope_test.go
Normal file
85
pkg/iam/authorizer_oauth2_scope_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
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 {
|
type ErrMixedOrganizationBatch struct {
|
||||||
Action Action
|
Action Action
|
||||||
OrganizationIDs []string
|
OrganizationIDs []string
|
||||||
|
|||||||
@@ -94,9 +94,6 @@ const (
|
|||||||
ActionOAuth2ConsentGet = "iam:oauth2-consent:get"
|
ActionOAuth2ConsentGet = "iam:oauth2-consent:get"
|
||||||
ActionOAuth2ConsentApprove = "iam:oauth2-consent:approve"
|
ActionOAuth2ConsentApprove = "iam:oauth2-consent:approve"
|
||||||
|
|
||||||
// Connector actions
|
|
||||||
ActionConnectorGet = "iam:connector:get"
|
|
||||||
|
|
||||||
// Audit log entry actions
|
// Audit log entry actions
|
||||||
ActionAuditLogEntryGet = "iam:audit-log-entry:get"
|
ActionAuditLogEntryGet = "iam:audit-log-entry:get"
|
||||||
ActionAuditLogEntryList = "iam:audit-log-entry:list"
|
ActionAuditLogEntryList = "iam:audit-log-entry:list"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server
|
package oauth2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server
|
package oauth2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -45,11 +45,11 @@ func NewGarbageCollector(
|
|||||||
) *GarbageCollector {
|
) *GarbageCollector {
|
||||||
h := &gcHandler{
|
h := &gcHandler{
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
logger: logger.Named("oauth2server.garbage_collector"),
|
logger: logger.Named("oauth.garbage_collector"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return worker.New(
|
return worker.New(
|
||||||
"oauth2server.garbage_collector",
|
"oauth.garbage_collector",
|
||||||
h,
|
h,
|
||||||
logger,
|
logger,
|
||||||
append(
|
append(
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server
|
package oauth2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
@@ -96,10 +96,10 @@ func NewIDTokenClaims(
|
|||||||
|
|
||||||
for _, scope := range scopes {
|
for _, scope := range scopes {
|
||||||
switch scope {
|
switch scope {
|
||||||
case coredata.OAuth2ScopeEmail:
|
case ScopeEmail:
|
||||||
claims.Email = email
|
claims.Email = email
|
||||||
claims.EmailVerified = &emailVerified
|
claims.EmailVerified = &emailVerified
|
||||||
case coredata.OAuth2ScopeProfile:
|
case ScopeProfile:
|
||||||
claims.Name = fullName
|
claims.Name = fullName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server_test
|
package oauth2_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"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"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ func TestComputeAtHash(t *testing.T) {
|
|||||||
h := sha256.Sum256([]byte(accessToken))
|
h := sha256.Sum256([]byte(accessToken))
|
||||||
expected := base64.RawURLEncoding.EncodeToString(h[:16])
|
expected := base64.RawURLEncoding.EncodeToString(h[:16])
|
||||||
|
|
||||||
result := oauth2server.ComputeAtHash(accessToken)
|
result := oauth2.ComputeAtHash(accessToken)
|
||||||
assert.Equal(t, expected, result)
|
assert.Equal(t, expected, result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -52,8 +52,8 @@ func TestComputeAtHash(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
hash1 := oauth2server.ComputeAtHash("token-a")
|
hash1 := oauth2.ComputeAtHash("token-a")
|
||||||
hash2 := oauth2server.ComputeAtHash("token-b")
|
hash2 := oauth2.ComputeAtHash("token-b")
|
||||||
assert.NotEqual(t, hash1, hash2)
|
assert.NotEqual(t, hash1, hash2)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,7 +63,7 @@ func TestComputeAtHash(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ComputeAtHash("")
|
result := oauth2.ComputeAtHash("")
|
||||||
assert.NotEmpty(t, result)
|
assert.NotEmpty(t, result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -73,8 +73,8 @@ func TestComputeAtHash(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
hash1 := oauth2server.ComputeAtHash("same-token")
|
hash1 := oauth2.ComputeAtHash("same-token")
|
||||||
hash2 := oauth2server.ComputeAtHash("same-token")
|
hash2 := oauth2.ComputeAtHash("same-token")
|
||||||
assert.Equal(t, hash1, hash2)
|
assert.Equal(t, hash1, hash2)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -92,12 +92,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"user@example.com",
|
"user@example.com",
|
||||||
@@ -123,12 +123,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
|
||||||
"test-nonce",
|
"test-nonce",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -146,12 +146,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
|
||||||
"",
|
"",
|
||||||
"access-token-123",
|
"access-token-123",
|
||||||
"",
|
"",
|
||||||
@@ -160,7 +160,7 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
1*time.Hour,
|
1*time.Hour,
|
||||||
)
|
)
|
||||||
|
|
||||||
expected := oauth2server.ComputeAtHash("access-token-123")
|
expected := oauth2.ComputeAtHash("access-token-123")
|
||||||
assert.Equal(t, expected, claims.AtHash)
|
assert.Equal(t, expected, claims.AtHash)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -170,12 +170,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
|
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail},
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"user@example.com",
|
"user@example.com",
|
||||||
@@ -195,12 +195,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
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) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{
|
coredata.OAuth2Scopes{
|
||||||
coredata.OAuth2ScopeOpenID,
|
oauth2.ScopeOpenID,
|
||||||
coredata.OAuth2ScopeEmail,
|
oauth2.ScopeEmail,
|
||||||
coredata.OAuth2ScopeProfile,
|
oauth2.ScopeProfile,
|
||||||
},
|
},
|
||||||
"nonce-val",
|
"nonce-val",
|
||||||
"access-token",
|
"access-token",
|
||||||
@@ -252,12 +252,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
|
|
||||||
ttl := 2 * time.Hour
|
ttl := 2 * time.Hour
|
||||||
before := time.Now()
|
before := time.Now()
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID},
|
coredata.OAuth2Scopes{oauth2.ScopeOpenID},
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -279,12 +279,12 @@ func TestNewIDTokenClaims(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
claims := oauth2server.NewIDTokenClaims(
|
claims := oauth2.NewIDTokenClaims(
|
||||||
testIssuer,
|
testIssuer,
|
||||||
identityID,
|
identityID,
|
||||||
clientID,
|
clientID,
|
||||||
authTime,
|
authTime,
|
||||||
coredata.OAuth2Scopes{coredata.OAuth2ScopeOpenID, coredata.OAuth2ScopeEmail},
|
coredata.OAuth2Scopes{oauth2.ScopeOpenID, oauth2.ScopeEmail},
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"user@example.com",
|
"user@example.com",
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server
|
package oauth2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/uri"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
@@ -33,6 +35,7 @@ type (
|
|||||||
RevocationEndpoint uri.URI `json:"revocation_endpoint"`
|
RevocationEndpoint uri.URI `json:"revocation_endpoint"`
|
||||||
DeviceAuthorizationEndpoint uri.URI `json:"device_authorization_endpoint"`
|
DeviceAuthorizationEndpoint uri.URI `json:"device_authorization_endpoint"`
|
||||||
ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"`
|
ScopesSupported []coredata.OAuth2Scope `json:"scopes_supported"`
|
||||||
|
ProtectedResources []uri.URI `json:"protected_resources,omitempty"`
|
||||||
ResponseTypesSupported []coredata.OAuth2ResponseType `json:"response_types_supported"`
|
ResponseTypesSupported []coredata.OAuth2ResponseType `json:"response_types_supported"`
|
||||||
GrantTypesSupported []coredata.OAuth2GrantType `json:"grant_types_supported"`
|
GrantTypesSupported []coredata.OAuth2GrantType `json:"grant_types_supported"`
|
||||||
TokenEndpointAuthMethodsSupported []coredata.OAuth2ClientTokenEndpointAuthMethod `json:"token_endpoint_auth_methods_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{
|
return &ServerMetadata{
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
AuthorizationEndpoint: endpoints.Authorization,
|
AuthorizationEndpoint: endpoints.Authorization,
|
||||||
@@ -68,12 +71,16 @@ func NewMetadata(issuer uri.URI, endpoints Endpoints) *ServerMetadata {
|
|||||||
IntrospectionEndpoint: endpoints.Introspection,
|
IntrospectionEndpoint: endpoints.Introspection,
|
||||||
RevocationEndpoint: endpoints.Revocation,
|
RevocationEndpoint: endpoints.Revocation,
|
||||||
DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization,
|
DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization,
|
||||||
ScopesSupported: []coredata.OAuth2Scope{
|
ScopesSupported: slices.Concat(
|
||||||
coredata.OAuth2ScopeOpenID,
|
[]coredata.OAuth2Scope{
|
||||||
coredata.OAuth2ScopeProfile,
|
ScopeOpenID,
|
||||||
coredata.OAuth2ScopeEmail,
|
ScopeProfile,
|
||||||
coredata.OAuth2ScopeOfflineAccess,
|
ScopeEmail,
|
||||||
|
ScopeOfflineAccess,
|
||||||
},
|
},
|
||||||
|
apiScopes,
|
||||||
|
),
|
||||||
|
ProtectedResources: []uri.URI{issuer},
|
||||||
ResponseTypesSupported: []coredata.OAuth2ResponseType{
|
ResponseTypesSupported: []coredata.OAuth2ResponseType{
|
||||||
coredata.OAuth2ResponseTypeCode,
|
coredata.OAuth2ResponseTypeCode,
|
||||||
},
|
},
|
||||||
@@ -12,23 +12,27 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server_test
|
package oauth2_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"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"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewMetadata(t *testing.T) {
|
func TestNewMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead}
|
||||||
|
|
||||||
issuer := uri.URI("https://auth.example.com")
|
issuer := uri.URI("https://auth.example.com")
|
||||||
endpoints := oauth2server.Endpoints{
|
endpoints := oauth2.Endpoints{
|
||||||
Authorization: "https://auth.example.com/authorize",
|
Authorization: "https://auth.example.com/authorize",
|
||||||
Token: "https://auth.example.com/token",
|
Token: "https://auth.example.com/token",
|
||||||
Userinfo: "https://auth.example.com/userinfo",
|
Userinfo: "https://auth.example.com/userinfo",
|
||||||
@@ -39,7 +43,7 @@ func TestNewMetadata(t *testing.T) {
|
|||||||
DeviceAuthorization: "https://auth.example.com/device",
|
DeviceAuthorization: "https://auth.example.com/device",
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := oauth2server.NewMetadata(issuer, endpoints)
|
metadata := oauth2.NewMetadata(issuer, endpoints, apiScopes)
|
||||||
require.NotNil(t, metadata)
|
require.NotNil(t, metadata)
|
||||||
|
|
||||||
t.Run(
|
t.Run(
|
||||||
@@ -72,16 +76,28 @@ func TestNewMetadata(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
assert.Equal(
|
expectedScopes := slices.Concat(
|
||||||
t,
|
|
||||||
[]coredata.OAuth2Scope{
|
[]coredata.OAuth2Scope{
|
||||||
coredata.OAuth2ScopeOpenID,
|
oauth2.ScopeOpenID,
|
||||||
coredata.OAuth2ScopeProfile,
|
oauth2.ScopeProfile,
|
||||||
coredata.OAuth2ScopeEmail,
|
oauth2.ScopeEmail,
|
||||||
coredata.OAuth2ScopeOfflineAccess,
|
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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server
|
package oauth2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server_test
|
package oauth2_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"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 {
|
func computeS256Challenge(verifier string) string {
|
||||||
@@ -41,7 +41,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
verifier,
|
verifier,
|
||||||
challenge,
|
challenge,
|
||||||
coredata.OAuth2CodeChallengeMethodS256,
|
coredata.OAuth2CodeChallengeMethodS256,
|
||||||
@@ -56,7 +56,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
"wrong-verifier",
|
"wrong-verifier",
|
||||||
challenge,
|
challenge,
|
||||||
coredata.OAuth2CodeChallengeMethodS256,
|
coredata.OAuth2CodeChallengeMethodS256,
|
||||||
@@ -71,7 +71,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
verifier,
|
verifier,
|
||||||
"wrong-challenge",
|
"wrong-challenge",
|
||||||
coredata.OAuth2CodeChallengeMethodS256,
|
coredata.OAuth2CodeChallengeMethodS256,
|
||||||
@@ -86,7 +86,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
verifier,
|
verifier,
|
||||||
challenge,
|
challenge,
|
||||||
coredata.OAuth2CodeChallengeMethod("plain"),
|
coredata.OAuth2CodeChallengeMethod("plain"),
|
||||||
@@ -101,7 +101,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
verifier,
|
verifier,
|
||||||
challenge,
|
challenge,
|
||||||
coredata.OAuth2CodeChallengeMethod(""),
|
coredata.OAuth2CodeChallengeMethod(""),
|
||||||
@@ -116,7 +116,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
"",
|
"",
|
||||||
challenge,
|
challenge,
|
||||||
coredata.OAuth2CodeChallengeMethodS256,
|
coredata.OAuth2CodeChallengeMethodS256,
|
||||||
@@ -131,7 +131,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
verifier,
|
verifier,
|
||||||
"",
|
"",
|
||||||
coredata.OAuth2CodeChallengeMethodS256,
|
coredata.OAuth2CodeChallengeMethodS256,
|
||||||
@@ -146,7 +146,7 @@ func TestValidateCodeChallenge(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
result := oauth2server.ValidateCodeChallenge(
|
result := oauth2.ValidateCodeChallenge(
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
coredata.OAuth2CodeChallengeMethodS256,
|
coredata.OAuth2CodeChallengeMethodS256,
|
||||||
49
pkg/iam/oauth2/protected_resource_metadata.go
Normal file
49
pkg/iam/oauth2/protected_resource_metadata.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
45
pkg/iam/oauth2/protected_resource_metadata_test.go
Normal file
45
pkg/iam/oauth2/protected_resource_metadata_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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)
|
||||||
|
}
|
||||||
48
pkg/iam/oauth2/request_context.go
Normal file
48
pkg/iam/oauth2/request_context.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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
|
||||||
|
}
|
||||||
72
pkg/iam/oauth2/scope.go
Normal file
72
pkg/iam/oauth2/scope.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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
|
||||||
|
}
|
||||||
@@ -12,16 +12,17 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package coredata_test
|
package oauth2_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"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.Parallel()
|
||||||
|
|
||||||
t.Run(
|
t.Run(
|
||||||
@@ -29,7 +30,7 @@ func TestOAuth2Scope_IsValid(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
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.Parallel()
|
||||||
|
|
||||||
t.Run(
|
t.Run(
|
||||||
@@ -51,11 +52,9 @@ func TestOAuth2Scope_UnmarshalText(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var scope coredata.OAuth2Scope
|
scope, err := oauth2.UnmarshalScope([]byte("offline_access"))
|
||||||
|
|
||||||
err := scope.UnmarshalText([]byte("offline_access"))
|
|
||||||
assert.NoError(t, err)
|
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) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var scope coredata.OAuth2Scope
|
_, err := oauth2.UnmarshalScope([]byte("admin"))
|
||||||
|
|
||||||
err := scope.UnmarshalText([]byte("admin"))
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOAuth2Scopes_Contains(t *testing.T) {
|
func TestOAuth2ScopesContains(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
t.Run(
|
t.Run(
|
||||||
@@ -81,10 +78,10 @@ func TestOAuth2Scopes_Contains(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
scopes := coredata.OAuth2Scopes{
|
scopes := coredata.OAuth2Scopes{
|
||||||
coredata.OAuth2ScopeOpenID,
|
oauth2.ScopeOpenID,
|
||||||
coredata.OAuth2ScopeOfflineAccess,
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
scopes := coredata.OAuth2Scopes{
|
scopes := coredata.OAuth2Scopes{
|
||||||
coredata.OAuth2ScopeOpenID,
|
oauth2.ScopeOpenID,
|
||||||
coredata.OAuth2ScopeProfile,
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
defaultScopes := coredata.OAuth2Scopes{
|
defaultScopes := coredata.OAuth2Scopes{
|
||||||
coredata.OAuth2ScopeOpenID,
|
oauth2.ScopeOpenID,
|
||||||
coredata.OAuth2ScopeProfile,
|
oauth2.ScopeProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run(
|
t.Run(
|
||||||
@@ -138,7 +135,7 @@ func TestOAuth2Scopes_OrDefault(t *testing.T) {
|
|||||||
func(t *testing.T) {
|
func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
scopes := coredata.OAuth2Scopes{coredata.OAuth2ScopeEmail}
|
scopes := coredata.OAuth2Scopes{oauth2.ScopeEmail}
|
||||||
result := scopes.OrDefault(defaultScopes)
|
result := scopes.OrDefault(defaultScopes)
|
||||||
assert.Equal(t, scopes, result)
|
assert.Equal(t, scopes, result)
|
||||||
},
|
},
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package oauth2server
|
package oauth2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -194,11 +194,6 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
return s.gc.Run(ctx)
|
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.
|
// JWKS returns the public key set.
|
||||||
func (s *Service) JWKS() *jose.JWKS {
|
func (s *Service) JWKS() *jose.JWKS {
|
||||||
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 (
|
var (
|
||||||
idTokenClaims = NewIDTokenClaims(
|
idTokenClaims = NewIDTokenClaims(
|
||||||
s.baseURL,
|
s.baseURL,
|
||||||
@@ -426,7 +421,7 @@ func (s *Service) ExchangeAuthorizationCode(
|
|||||||
return fmt.Errorf("cannot create access token: %w", err)
|
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)
|
refreshTokenValue = rand.MustHexString(refreshTokenByteLength)
|
||||||
|
|
||||||
refreshToken := &coredata.OAuth2RefreshToken{
|
refreshToken := &coredata.OAuth2RefreshToken{
|
||||||
@@ -560,7 +555,7 @@ func (s *Service) RefreshToken(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if previousRefreshToken.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
|
if previousRefreshToken.Scopes.Contains(ScopeOpenID) {
|
||||||
var (
|
var (
|
||||||
claims = NewIDTokenClaims(
|
claims = NewIDTokenClaims(
|
||||||
s.baseURL,
|
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(
|
return NewError(
|
||||||
ErrInvalidScope,
|
ErrInvalidScope,
|
||||||
WithDescription("offline_access requires the refresh_token grant type"),
|
WithDescription("offline_access requires the refresh_token grant type"),
|
||||||
@@ -846,7 +841,7 @@ func (s *Service) PollDeviceCode(
|
|||||||
idToken string
|
idToken string
|
||||||
)
|
)
|
||||||
|
|
||||||
if deviceCode.Scopes.Contains(coredata.OAuth2ScopeOpenID) {
|
if deviceCode.Scopes.Contains(ScopeOpenID) {
|
||||||
var (
|
var (
|
||||||
claims = NewIDTokenClaims(
|
claims = NewIDTokenClaims(
|
||||||
s.baseURL,
|
s.baseURL,
|
||||||
@@ -887,7 +882,7 @@ func (s *Service) PollDeviceCode(
|
|||||||
return fmt.Errorf("cannot create access token: %w", err)
|
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)
|
refreshTokenValue = rand.MustHexString(refreshTokenByteLength)
|
||||||
|
|
||||||
refreshToken := &coredata.OAuth2RefreshToken{
|
refreshToken := &coredata.OAuth2RefreshToken{
|
||||||
@@ -1287,10 +1282,10 @@ func (s *Service) UserInfo(
|
|||||||
|
|
||||||
for _, scope := range scopes {
|
for _, scope := range scopes {
|
||||||
switch scope {
|
switch scope {
|
||||||
case coredata.OAuth2ScopeEmail:
|
case ScopeEmail:
|
||||||
claims["email"] = identity.EmailAddress.String()
|
claims["email"] = identity.EmailAddress.String()
|
||||||
claims["email_verified"] = identity.EmailAddressVerified
|
claims["email_verified"] = identity.EmailAddressVerified
|
||||||
case coredata.OAuth2ScopeProfile:
|
case ScopeProfile:
|
||||||
claims["name"] = identity.FullName
|
claims["name"] = identity.FullName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1445,7 +1440,7 @@ func (s *Service) Authorize(
|
|||||||
return fmt.Errorf("cannot authorize: requested scope exceeds client registration")
|
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(
|
return NewError(
|
||||||
ErrInvalidScope,
|
ErrInvalidScope,
|
||||||
WithDescription("offline_access requires the refresh_token grant type"),
|
WithDescription("offline_access requires the refresh_token grant type"),
|
||||||
70
pkg/iam/oauth2_scope_registrations_test.go
Normal file
70
pkg/iam/oauth2_scope_registrations_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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"))
|
||||||
|
}
|
||||||
87
pkg/iam/oauth2_scopes.go
Normal file
87
pkg/iam/oauth2_scopes.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
102
pkg/iam/scope_set.go
Normal file
102
pkg/iam/scope_set.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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
|
||||||
|
}
|
||||||
88
pkg/iam/scope_set_test.go
Normal file
88
pkg/iam/scope_set_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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"))
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/crypto/passwdhash"
|
"go.probo.inc/probo/pkg/crypto/passwdhash"
|
||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"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/oidc"
|
||||||
"go.probo.inc/probo/pkg/iam/saml"
|
"go.probo.inc/probo/pkg/iam/saml"
|
||||||
"go.probo.inc/probo/pkg/iam/scim"
|
"go.probo.inc/probo/pkg/iam/scim"
|
||||||
@@ -67,7 +67,7 @@ type (
|
|||||||
OIDCService *oidc.Service
|
OIDCService *oidc.Service
|
||||||
SCIMService *scim.Service
|
SCIMService *scim.Service
|
||||||
APIKeyService *APIKeyService
|
APIKeyService *APIKeyService
|
||||||
OAuth2ServerService *oauth2server.Service
|
OAuth2ServerService *oauth2.Service
|
||||||
Authorizer *Authorizer
|
Authorizer *Authorizer
|
||||||
|
|
||||||
samlDomainVerifier *SAMLDomainVerifier
|
samlDomainVerifier *SAMLDomainVerifier
|
||||||
@@ -95,8 +95,8 @@ type (
|
|||||||
SCIMBridgePollInterval time.Duration
|
SCIMBridgePollInterval time.Duration
|
||||||
GoogleOIDC oidc.ProviderConfig
|
GoogleOIDC oidc.ProviderConfig
|
||||||
MicrosoftOIDC oidc.ProviderConfig
|
MicrosoftOIDC oidc.ProviderConfig
|
||||||
OAuth2ServerSigningKeys oauth2server.SigningKeys
|
OAuth2ServerSigningKeys oauth2.SigningKeys
|
||||||
OAuth2ServerOptions []oauth2server.Option
|
OAuth2ServerOptions []oauth2.Option
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +162,7 @@ func NewService(
|
|||||||
cfg.Logger.Named("authorizer"),
|
cfg.Logger.Named("authorizer"),
|
||||||
)
|
)
|
||||||
svc.Authorizer.RegisterPolicySet(IAMPolicySet())
|
svc.Authorizer.RegisterPolicySet(IAMPolicySet())
|
||||||
|
svc.Authorizer.RegisterScopes(IAMOAuth2ScopeSet())
|
||||||
|
|
||||||
samlService, err := saml.NewService(svc.pg, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)
|
samlService, err := saml.NewService(svc.pg, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,11 +195,11 @@ func NewService(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
svc.OAuth2ServerService = oauth2server.NewService(
|
svc.OAuth2ServerService = oauth2.NewService(
|
||||||
pgClient,
|
pgClient,
|
||||||
cfg.OAuth2ServerSigningKeys,
|
cfg.OAuth2ServerSigningKeys,
|
||||||
uri.URI(cfg.BaseURL.String()),
|
uri.URI(cfg.BaseURL.String()),
|
||||||
cfg.Logger.Named("oauth2server"),
|
cfg.Logger.Named("oauth2"),
|
||||||
cfg.OAuth2ServerOptions...,
|
cfg.OAuth2ServerOptions...,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,6 +214,16 @@ func NewService(
|
|||||||
return svc, nil
|
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 {
|
func (s *Service) IsSignUpEnabled() bool {
|
||||||
return !s.disableSignup
|
return !s.disableSignup
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ const (
|
|||||||
|
|
||||||
// Connector actions (generic)
|
// Connector actions (generic)
|
||||||
ActionConnectorCreate = "core:connector:create"
|
ActionConnectorCreate = "core:connector:create"
|
||||||
|
ActionConnectorGet = "core:connector:get"
|
||||||
ActionConnectorList = "core:connector:list"
|
ActionConnectorList = "core:connector:list"
|
||||||
ActionConnectorDelete = "core:connector:delete"
|
ActionConnectorDelete = "core:connector:delete"
|
||||||
|
|
||||||
@@ -460,7 +461,6 @@ const (
|
|||||||
// CommonThirdParty actions (global catalog, no organization scope).
|
// CommonThirdParty actions (global catalog, no organization scope).
|
||||||
ActionCommonThirdPartyGet = "core:common-third-party:get"
|
ActionCommonThirdPartyGet = "core:common-third-party:get"
|
||||||
ActionCommonThirdPartyList = "core:common-third-party:list"
|
ActionCommonThirdPartyList = "core:common-third-party:list"
|
||||||
ActionAccessReviewDriverCatalogList = "core:access-review-driver-catalog:list"
|
|
||||||
|
|
||||||
// ElectronicSignature actions (tenant-scoped via the related document
|
// ElectronicSignature actions (tenant-scoped via the related document
|
||||||
// version signature / trust center access).
|
// version signature / trust center access).
|
||||||
|
|||||||
448
pkg/probo/oauth2_scopes.go
Normal file
448
pkg/probo/oauth2_scopes.go
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -196,18 +196,6 @@ var CommonThirdPartyCatalogPolicy = policy.NewPolicy(
|
|||||||
).WithSID("read-common-third-party-catalog"),
|
).WithSID("read-common-third-party-catalog"),
|
||||||
).WithDescription("Allows every authenticated user to read the global 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.
|
// EmployeePolicy defines permissions for employee role.
|
||||||
var EmployeePolicy = policy.NewPolicy(
|
var EmployeePolicy = policy.NewPolicy(
|
||||||
"probo:employee",
|
"probo:employee",
|
||||||
@@ -241,6 +229,5 @@ func ProboPolicySet() *iam.PolicySet {
|
|||||||
AddRolePolicy("VIEWER", ViewerPolicy).
|
AddRolePolicy("VIEWER", ViewerPolicy).
|
||||||
AddRolePolicy("AUDITOR", AuditorPolicy).
|
AddRolePolicy("AUDITOR", AuditorPolicy).
|
||||||
AddRolePolicy("EMPLOYEE", EmployeePolicy).
|
AddRolePolicy("EMPLOYEE", EmployeePolicy).
|
||||||
AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy).
|
AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy)
|
||||||
AddIdentityScopedPolicy(AccessReviewDriverCatalogPolicy)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ func NewService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
iamService.Authorizer.RegisterPolicySet(ProboPolicySet())
|
iamService.Authorizer.RegisterPolicySet(ProboPolicySet())
|
||||||
|
iamService.Authorizer.RegisterScopes(OAuth2ScopeSet())
|
||||||
|
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/geoloc"
|
"go.probo.inc/probo/pkg/geoloc"
|
||||||
"go.probo.inc/probo/pkg/html2pdf"
|
"go.probo.inc/probo/pkg/html2pdf"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"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/iam/oidc"
|
||||||
"go.probo.inc/probo/pkg/mailer"
|
"go.probo.inc/probo/pkg/mailer"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
@@ -385,7 +385,7 @@ func (impl *Implm) Run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
oauth2SigningKeys oauth2server.SigningKeys
|
oauth2SigningKeys oauth2.SigningKeys
|
||||||
hasActive bool
|
hasActive bool
|
||||||
activeSigningKeyPEM string
|
activeSigningKeyPEM string
|
||||||
)
|
)
|
||||||
@@ -413,7 +413,7 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
oauth2SigningKeys = append(
|
oauth2SigningKeys = append(
|
||||||
oauth2SigningKeys,
|
oauth2SigningKeys,
|
||||||
oauth2server.SigningKey{
|
oauth2.SigningKey{
|
||||||
PrivateKey: rsaKey,
|
PrivateKey: rsaKey,
|
||||||
KID: kid,
|
KID: kid,
|
||||||
Active: keyCfg.Active,
|
Active: keyCfg.Active,
|
||||||
@@ -601,6 +601,8 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
iamService.Authorizer.RegisterPolicySet(agentrun.PolicySet())
|
iamService.Authorizer.RegisterPolicySet(agentrun.PolicySet())
|
||||||
iamService.Authorizer.RegisterPolicySet(accessreview.PolicySet())
|
iamService.Authorizer.RegisterPolicySet(accessreview.PolicySet())
|
||||||
|
iamService.Authorizer.RegisterScopes(agentrun.OAuth2ScopeSet())
|
||||||
|
iamService.Authorizer.RegisterScopes(accessreview.OAuth2ScopeSet())
|
||||||
|
|
||||||
thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter)
|
thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter)
|
||||||
riskManagementService := riskmanagement.NewService(pgClient)
|
riskManagementService := riskmanagement.NewService(pgClient)
|
||||||
@@ -1401,23 +1403,23 @@ func (impl *Implm) runTrustCenterServer(
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func oauth2ServerOptions(cfg OAuth2ServerConfig) []oauth2server.Option {
|
func oauth2ServerOptions(cfg OAuth2ServerConfig) []oauth2.Option {
|
||||||
var opts []oauth2server.Option
|
var opts []oauth2.Option
|
||||||
|
|
||||||
if cfg.AccessTokenDuration > 0 {
|
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 {
|
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 {
|
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 {
|
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
|
return opts
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/bearertoken"
|
"go.probo.inc/probo/pkg/bearertoken"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
|
"go.probo.inc/probo/pkg/iam/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewOAuth2AccessTokenMiddleware(svc *iam.Service) func(next http.Handler) http.Handler {
|
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 = ContextWithIdentity(ctx, identity)
|
||||||
|
ctx = oauth2.ContextWithAccessToken(ctx, accessToken)
|
||||||
|
|
||||||
httpserver.LoggerFromContext(ctx).InfoCtx(
|
httpserver.LoggerFromContext(ctx).InfoCtx(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
AuthorizeFuncOption func(*iam.AuthorizeParams)
|
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)
|
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 {
|
func WithAttr(key, value string) AuthorizeFuncOption {
|
||||||
@@ -78,7 +78,7 @@ func NewAuthorizeFunc(
|
|||||||
return func(
|
return func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
objectID gid.GID,
|
objectID gid.GID,
|
||||||
action string,
|
action iam.Action,
|
||||||
options ...AuthorizeFuncOption,
|
options ...AuthorizeFuncOption,
|
||||||
) (*coredata.Scope, error) {
|
) (*coredata.Scope, error) {
|
||||||
identity := authn.IdentityFromContext(ctx)
|
identity := authn.IdentityFromContext(ctx)
|
||||||
@@ -108,6 +108,10 @@ func NewAuthorizeFunc(
|
|||||||
return nil, gqlutils.Forbidden(ctx, err)
|
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) {
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
return nil, gqlutils.NotFoundf(ctx, "resource not found")
|
return nil, gqlutils.NotFoundf(ctx, "resource not found")
|
||||||
}
|
}
|
||||||
@@ -127,7 +131,7 @@ func NewBatchAuthorizeFunc(
|
|||||||
) BatchAuthorizeFunc {
|
) BatchAuthorizeFunc {
|
||||||
return func(
|
return func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
action string,
|
action iam.Action,
|
||||||
objectIDs []gid.GID,
|
objectIDs []gid.GID,
|
||||||
options ...BatchAuthorizeFuncOption,
|
options ...BatchAuthorizeFuncOption,
|
||||||
) (*coredata.Scope, error) {
|
) (*coredata.Scope, error) {
|
||||||
@@ -158,6 +162,10 @@ func NewBatchAuthorizeFunc(
|
|||||||
return nil, gqlutils.Forbidden(ctx, err)
|
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 {
|
if _, ok := errors.AsType[*iam.ErrMixedOrganizationBatch](err); ok {
|
||||||
return nil, gqlutils.Invalid(ctx, err)
|
return nil, gqlutils.Invalid(ctx, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"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/mail"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"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/schema"
|
||||||
@@ -176,7 +176,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
|||||||
return nil, gqlutils.NotFound(ctx, err)
|
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())
|
return nil, gqlutils.Invalidf(ctx, "%s", oauthErr.Description())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
"go.gearno.de/kit/log"
|
"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"
|
"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) {
|
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 {
|
if !ok {
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, err)
|
httpserver.RenderError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, oauth2server.ErrServerError) {
|
if errors.Is(err, oauth2.ErrServerError) {
|
||||||
h.logger.ErrorCtx(r.Context(), "oauth2 server error", log.Error(err))
|
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 {
|
func isRedirectableError(err error) bool {
|
||||||
return errors.Is(err, oauth2server.ErrAccessDenied) ||
|
return errors.Is(err, oauth2.ErrAccessDenied) ||
|
||||||
errors.Is(err, oauth2server.ErrInvalidRequest) ||
|
errors.Is(err, oauth2.ErrInvalidRequest) ||
|
||||||
errors.Is(err, oauth2server.ErrInvalidScope) ||
|
errors.Is(err, oauth2.ErrInvalidScope) ||
|
||||||
errors.Is(err, oauth2server.ErrUnauthorizedClient) ||
|
errors.Is(err, oauth2.ErrUnauthorizedClient) ||
|
||||||
errors.Is(err, oauth2server.ErrInvalidGrant) ||
|
errors.Is(err, oauth2.ErrInvalidGrant) ||
|
||||||
errors.Is(err, oauth2server.ErrUnsupportedGrantType)
|
errors.Is(err, oauth2.ErrUnsupportedGrantType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func oauth2ErrorStatusCode(err *oauth2server.OAuth2Error) int {
|
func oauth2ErrorStatusCode(err *oauth2.OAuth2Error) int {
|
||||||
switch err.ErrorCode() {
|
switch err.ErrorCode() {
|
||||||
case "access_denied":
|
case "access_denied":
|
||||||
return http.StatusForbidden
|
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 {
|
switch {
|
||||||
case errors.Is(err, oauth2server.ErrClientNotFound):
|
case errors.Is(err, oauth2.ErrClientNotFound):
|
||||||
return oauth2server.NewError(oauth2server.ErrInvalidClient, oauth2server.WithDescription("client not found"))
|
return oauth2.NewError(oauth2.ErrInvalidClient, oauth2.WithDescription("client not found"))
|
||||||
case errors.Is(err, oauth2server.ErrInvalidRedirectURI):
|
case errors.Is(err, oauth2.ErrInvalidRedirectURI):
|
||||||
return oauth2server.ErrInvalidRedirectURI
|
return oauth2.ErrInvalidRedirectURI
|
||||||
case errors.Is(err, oauth2server.ErrUnauthorizedMember):
|
case errors.Is(err, oauth2.ErrUnauthorizedMember):
|
||||||
return oauth2server.NewError(oauth2server.ErrUnauthorizedClient, oauth2server.WithDescription("client is private and user is not a member of the organization"))
|
return oauth2.NewError(oauth2.ErrUnauthorizedClient, oauth2.WithDescription("client is private and user is not a member of the organization"))
|
||||||
case errors.Is(err, oauth2server.ErrDeviceCodeNotPending):
|
case errors.Is(err, oauth2.ErrDeviceCodeNotPending):
|
||||||
return oauth2server.NewError(oauth2server.ErrInvalidGrant, oauth2server.WithDescription("device code is not pending"))
|
return oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("device code is not pending"))
|
||||||
default:
|
default:
|
||||||
if oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err); ok {
|
if oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err); ok {
|
||||||
return oauthErr
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
|
oauthErr, ok := errors.AsType[*oauth2.OAuth2Error](err)
|
||||||
if !ok {
|
if !ok {
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
|
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
package connect_v1
|
package connect_v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -30,18 +29,13 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"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/securecookie"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
|
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
|
||||||
"go.probo.inc/probo/pkg/uri"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
oauth2ClientContextKey = &ctxKey{name: "oauth2_client"}
|
|
||||||
oauth2AccessTokenContextKey = &ctxKey{name: "oauth2_access_token"}
|
|
||||||
)
|
|
||||||
|
|
||||||
type OAuth2Handler struct {
|
type OAuth2Handler struct {
|
||||||
iam *iam.Service
|
iam *iam.Service
|
||||||
sessionCookie *authn.Cookie
|
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
|
// ClientAuthMiddleware authenticates the OAuth2 client from HTTP Basic auth
|
||||||
// or POST body credentials and stores it in the request context.
|
// or POST body credentials and stores it in the request context.
|
||||||
func (h *OAuth2Handler) ClientAuthMiddleware(next http.Handler) http.Handler {
|
func (h *OAuth2Handler) ClientAuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
client, err := h.authenticateClient(r)
|
client, err := h.authenticateClient(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), oauth2ClientContextKey, client)
|
ctx := oauth2.ContextWithClient(r.Context(), client)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,15 +92,15 @@ func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), oauth2AccessTokenContextKey, accessToken)
|
ctx := oauth2.ContextWithAccessToken(r.Context(), accessToken)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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"
|
api := h.baseURL.String() + "/api/connect/v1"
|
||||||
|
|
||||||
return oauth2server.Endpoints{
|
return oauth2.Endpoints{
|
||||||
Authorization: uri.URI(api + "/oauth2/authorize"),
|
Authorization: uri.URI(api + "/oauth2/authorize"),
|
||||||
Token: uri.URI(api + "/oauth2/token"),
|
Token: uri.URI(api + "/oauth2/token"),
|
||||||
Userinfo: uri.URI(api + "/oauth2/userinfo"),
|
Userinfo: uri.URI(api + "/oauth2/userinfo"),
|
||||||
@@ -135,7 +117,7 @@ func (h *OAuth2Handler) endpoints() oauth2server.Endpoints {
|
|||||||
// DiscoveryHandler serves the OpenID Connect Discovery document.
|
// DiscoveryHandler serves the OpenID Connect Discovery document.
|
||||||
// GET /.well-known/openid-configuration
|
// GET /.well-known/openid-configuration
|
||||||
func (h *OAuth2Handler) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
|
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)
|
PublicCache(w, 1*time.Hour)
|
||||||
httpserver.RenderJSON(w, http.StatusOK, metadata)
|
httpserver.RenderJSON(w, http.StatusOK, metadata)
|
||||||
@@ -168,7 +150,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
var in types.OAuth2AuthorizeInput
|
var in types.OAuth2AuthorizeInput
|
||||||
if err := in.DecodeQuery(r.URL.Query()); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +163,7 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
code, err := h.iam.OAuth2ServerService.Authorize(
|
code, err := h.iam.OAuth2ServerService.Authorize(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
&oauth2server.AuthorizeRequest{
|
&oauth2.AuthorizeRequest{
|
||||||
IdentityID: identity.ID,
|
IdentityID: identity.ID,
|
||||||
SessionID: session.ID,
|
SessionID: session.ID,
|
||||||
ResponseType: in.ResponseType,
|
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").
|
consentURL := h.baseURL.WithPath("/auth/consent").
|
||||||
WithQuery("consent_id", consentErr.ConsentID.String()).
|
WithQuery("consent_id", consentErr.ConsentID.String()).
|
||||||
MustString()
|
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) {
|
func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +209,7 @@ func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err := grantType.UnmarshalText([]byte(value)); err != nil {
|
if err := grantType.UnmarshalText([]byte(value)); err != nil {
|
||||||
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrUnsupportedGrantType)
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrUnsupportedGrantType)
|
||||||
return
|
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) {
|
func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
client, ok := oauth2.ClientFromContext(r.Context())
|
||||||
client = oauth2ClientFromContext(r)
|
if !ok {
|
||||||
in = types.OAuth2IntrospectInput{}
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
in := types.OAuth2IntrospectInput{}
|
||||||
if err := in.DecodeForm(r); err != nil {
|
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
|
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) {
|
func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
client = oauth2ClientFromContext(r)
|
client, _ = oauth2.ClientFromContext(r.Context())
|
||||||
in = types.OAuth2RevokeInput{}
|
in = types.OAuth2RevokeInput{}
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := in.DecodeForm(r); err != nil {
|
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
|
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) {
|
func (h *OAuth2Handler) DeviceAuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
in := types.OAuth2DeviceAuthInput{}
|
in := types.OAuth2DeviceAuthInput{}
|
||||||
if err := in.DecodeForm(r); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +329,7 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
h.renderOAuth2ErrorResponse(
|
h.renderOAuth2ErrorResponse(
|
||||||
w,
|
w,
|
||||||
r,
|
r,
|
||||||
oauth2server.NewError(oauth2server.ErrInvalidRequest, oauth2server.WithDescription("invalid JSON body")),
|
oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid JSON body")),
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -369,15 +353,15 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
if len(in.Scopes) == 0 {
|
if len(in.Scopes) == 0 {
|
||||||
in.Scopes = coredata.OAuth2Scopes{
|
in.Scopes = coredata.OAuth2Scopes{
|
||||||
coredata.OAuth2ScopeOpenID,
|
oauth2.ScopeOpenID,
|
||||||
coredata.OAuth2ScopeProfile,
|
oauth2.ScopeProfile,
|
||||||
coredata.OAuth2ScopeEmail,
|
oauth2.ScopeEmail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient(
|
clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
&oauth2server.RegisterClientRequest{
|
&oauth2.RegisterClientRequest{
|
||||||
IdentityID: identity.ID,
|
IdentityID: identity.ID,
|
||||||
OrganizationID: in.OrganizationID,
|
OrganizationID: in.OrganizationID,
|
||||||
ClientName: in.ClientName,
|
ClientName: in.ClientName,
|
||||||
@@ -417,7 +401,13 @@ func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
// UserInfoHandler serves the OIDC UserInfo endpoint.
|
// UserInfoHandler serves the OIDC UserInfo endpoint.
|
||||||
// GET /oauth2/userinfo
|
// GET /oauth2/userinfo
|
||||||
func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) {
|
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(
|
claims, err := h.iam.OAuth2ServerService.UserInfo(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
@@ -425,7 +415,7 @@ func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
accessToken.Scopes,
|
accessToken.Scopes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrServerError)
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,12 +442,12 @@ func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Cli
|
|||||||
}
|
}
|
||||||
|
|
||||||
if clientIDStr == "" {
|
if clientIDStr == "" {
|
||||||
return nil, oauth2server.ErrInvalidClient
|
return nil, oauth2.ErrInvalidClient
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, err := gid.ParseGID(clientIDStr)
|
clientID, err := gid.ParseGID(clientIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oauth2server.ErrInvalidClient
|
return nil, oauth2.ErrInvalidClient
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientID, clientSecret)
|
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) {
|
func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
client, err := h.authenticateClient(r)
|
client, err := h.authenticateClient(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var in types.OAuth2AuthorizationCodeGrantInput
|
var in types.OAuth2AuthorizationCodeGrantInput
|
||||||
if err := in.DecodeForm(r); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +474,7 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h
|
|||||||
in.CodeVerifier,
|
in.CodeVerifier,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,19 +485,19 @@ func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *h
|
|||||||
func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
|
func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
client, err := h.authenticateClient(r)
|
client, err := h.authenticateClient(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderOAuth2ErrorResponse(w, r, oauth2server.ErrInvalidClient)
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var in types.OAuth2RefreshTokenGrantInput
|
var in types.OAuth2RefreshTokenGrantInput
|
||||||
if err := in.DecodeForm(r); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.iam.OAuth2ServerService.RefreshToken(r.Context(), client, in.RefreshToken)
|
result, err := h.iam.OAuth2ServerService.RefreshToken(r.Context(), client, in.RefreshToken)
|
||||||
if err != nil {
|
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
|
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) {
|
func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
var in types.OAuth2DeviceCodeGrantInput
|
var in types.OAuth2DeviceCodeGrantInput
|
||||||
if err := in.DecodeForm(r); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +526,7 @@ func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Req
|
|||||||
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
|
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
func tokenResultToResponse(r *oauth2server.TokenResult) *types.OAuth2TokenResponse {
|
func tokenResultToResponse(r *oauth2.TokenResult) *types.OAuth2TokenResponse {
|
||||||
return &types.OAuth2TokenResponse{
|
return &types.OAuth2TokenResponse{
|
||||||
AccessToken: r.AccessToken,
|
AccessToken: r.AccessToken,
|
||||||
TokenType: r.TokenType,
|
TokenType: r.TokenType,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"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/authn"
|
||||||
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
|
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
|
||||||
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
|
"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)
|
err := r.iam.OAuth2ServerService.AuthorizeDevice(ctx, identity.ID, session.ID, userCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if consentErr, ok := errors.AsType[*oauth2server.ConsentRequiredError](err); ok {
|
if consentErr, ok := errors.AsType[*oauth2.ConsentRequiredError](err); ok {
|
||||||
return &types.AuthorizeDevicePayload{
|
return &types.AuthorizeDevicePayload{
|
||||||
ConsentID: &consentErr.ConsentID,
|
ConsentID: &consentErr.ConsentID,
|
||||||
}, nil
|
}, 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())
|
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(
|
result, err := r.iam.OAuth2ServerService.ApproveConsent(
|
||||||
ctx,
|
ctx,
|
||||||
&oauth2server.ConsentApprovalRequest{
|
&oauth2.ConsentApprovalRequest{
|
||||||
ConsentID: input.ConsentID,
|
ConsentID: input.ConsentID,
|
||||||
IdentityID: identity.ID,
|
IdentityID: identity.ID,
|
||||||
SessionID: session.ID,
|
SessionID: session.ID,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"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"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ func InactiveIntrospectResponse() *OAuth2IntrospectResponse {
|
|||||||
return &OAuth2IntrospectResponse{Active: false}
|
return &OAuth2IntrospectResponse{Active: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ActiveIntrospectResponse(result *oauth2server.IntrospectResult) *OAuth2IntrospectResponse {
|
func ActiveIntrospectResponse(result *oauth2.IntrospectResult) *OAuth2IntrospectResponse {
|
||||||
return &OAuth2IntrospectResponse{
|
return &OAuth2IntrospectResponse{
|
||||||
Active: true,
|
Active: true,
|
||||||
Scope: result.Scopes,
|
Scope: result.Scopes,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/agentrun"
|
"go.probo.inc/probo/pkg/agentrun"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"go.probo.inc/probo/pkg/server/api/authn"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"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() {
|
switch id.EntityType() {
|
||||||
case coredata.OrganizationEntityType:
|
case coredata.OrganizationEntityType:
|
||||||
action = iam.ActionOrganizationGet
|
action = probo.ActionOrganizationGet
|
||||||
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
|
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
|
||||||
organization, err := r.probo.Organizations.Get(ctx, scope, id)
|
organization, err := r.probo.Organizations.Get(ctx, scope, id)
|
||||||
if err != nil {
|
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) {
|
func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.ConnectorProviderInfo, error) {
|
||||||
identity := authn.IdentityFromContext(ctx)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/connector"
|
"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
|
// published for public-client connectors. The deployment's
|
||||||
// (baseURL + CIMDMetadataPath) URL is the OAuth client_id; providers such as
|
// (baseURL + CIMDMetadataPath) URL is the OAuth client_id; providers such as
|
||||||
// PostHog fetch this document server-to-server during authorization to learn
|
// 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"
|
proboLogoURI = "https://www.probo.com/probo-logo-only.svg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type oauthClientMetadata struct {
|
type oauth2ClientMetadata struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientName string `json:"client_name"`
|
ClientName string `json:"client_name"`
|
||||||
ClientURI string `json:"client_uri"`
|
ClientURI string `json:"client_uri"`
|
||||||
@@ -48,11 +48,11 @@ type oauthClientMetadata struct {
|
|||||||
ResponseTypes []string `json:"response_types"`
|
ResponseTypes []string `json:"response_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConnectorOAuthClientMetadata serves the public, unauthenticated CIMD
|
// handleConnectorOAuth2ClientMetadata serves the public, unauthenticated CIMD
|
||||||
// document. It is intentionally outside the auth middleware group: the OAuth
|
// document. It is intentionally outside the auth middleware group: the OAuth2
|
||||||
// provider fetches it without any Probo credentials.
|
// provider fetches it without any Probo credentials.
|
||||||
func handleConnectorOAuthClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc {
|
func handleConnectorOAuth2ClientMetadata(baseURL *baseurl.BaseURL) http.HandlerFunc {
|
||||||
doc := oauthClientMetadata{
|
doc := oauth2ClientMetadata{
|
||||||
ClientID: baseURL.WithPath(connector.CIMDMetadataPath).MustString(),
|
ClientID: baseURL.WithPath(connector.CIMDMetadataPath).MustString(),
|
||||||
ClientName: "Probo",
|
ClientName: "Probo",
|
||||||
ClientURI: proboBrandURI,
|
ClientURI: proboBrandURI,
|
||||||
@@ -25,18 +25,18 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/baseurl"
|
"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,
|
// PostHog fetches it server-to-server during authorization, so client_id,
|
||||||
// redirect_uris (derived from the deployment base URL) and the public-client
|
// 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.
|
// 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()
|
t.Parallel()
|
||||||
|
|
||||||
base, err := baseurl.Parse("https://probo.example.com")
|
base, err := baseurl.Parse("https://probo.example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handleConnectorOAuthClientMetadata(base)(
|
handleConnectorOAuth2ClientMetadata(base)(
|
||||||
rec,
|
rec,
|
||||||
httptest.NewRequest(http.MethodGet, "/api/console/v1/connectors/oauth-client-metadata", nil),
|
httptest.NewRequest(http.MethodGet, "/api/console/v1/connectors/oauth-client-metadata", nil),
|
||||||
)
|
)
|
||||||
@@ -35,7 +35,7 @@ func NewAuthorizeFunc(logger *log.Logger) authz.AuthorizeFunc {
|
|||||||
return func(
|
return func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
objectID gid.GID,
|
objectID gid.GID,
|
||||||
action string,
|
action iam.Action,
|
||||||
options ...authz.AuthorizeFuncOption,
|
options ...authz.AuthorizeFuncOption,
|
||||||
) (*coredata.Scope, error) {
|
) (*coredata.Scope, error) {
|
||||||
loaders := FromContext(ctx)
|
loaders := FromContext(ctx)
|
||||||
@@ -66,6 +66,10 @@ func NewAuthorizeFunc(logger *log.Logger) authz.AuthorizeFunc {
|
|||||||
return nil, gqlutils.Forbidden(ctx, err)
|
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) {
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
return nil, gqlutils.NotFoundf(ctx, "resource not found")
|
return nil, gqlutils.NotFoundf(ctx, "resource not found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type (
|
|||||||
// batch together.
|
// batch together.
|
||||||
AuthorizeKey struct {
|
AuthorizeKey struct {
|
||||||
ResourceID gid.GID
|
ResourceID gid.GID
|
||||||
Action string
|
Action iam.Action
|
||||||
ResourceAttributes string
|
ResourceAttributes string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
SkipAssumptionCheck bool
|
SkipAssumptionCheck bool
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func NewMux(
|
|||||||
// is fetched server-to-server by public-client providers (PostHog)
|
// is fetched server-to-server by public-client providers (PostHog)
|
||||||
// during authorization, with no Probo credentials. Mounted outside the
|
// during authorization, with no Probo credentials. Mounted outside the
|
||||||
// auth group above.
|
// auth group above.
|
||||||
r.Get("/connectors/oauth-client-metadata", handleConnectorOAuthClientMetadata(baseURL))
|
r.Get("/connectors/oauth-client-metadata", handleConnectorOAuth2ClientMetadata(baseURL))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,18 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
scope, err := h.iamSvc.Authorizer.Authorize(ctx, params)
|
scope, err := h.iamSvc.Authorizer.Authorize(ctx, params)
|
||||||
if err != nil {
|
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"))
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ func (r *Resolver) Authorize(ctx context.Context, entityID gid.GID, action iam.A
|
|||||||
return nil, fmt.Errorf("permission denied")
|
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 {
|
if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok {
|
||||||
return nil, fmt.Errorf("assumption required")
|
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")
|
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 {
|
if _, ok := errors.AsType[*iam.ErrAssumptionRequired](err); ok {
|
||||||
return nil, fmt.Errorf("assumption required")
|
return nil, fmt.Errorf("assumption required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/geoloc"
|
"go.probo.inc/probo/pkg/geoloc"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"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/mailman"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/riskmanagement"
|
"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.
|
// document at the issuer root under well-known paths.
|
||||||
s.router.Get("/.well-known/openid-configuration", s.oidcDiscoveryHandler)
|
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-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("/api", http.StripPrefix("/api", s.apiServer))
|
||||||
s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler))
|
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) {
|
func (s *Server) oidcDiscoveryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
api := s.baseURL + "/api/connect/v1"
|
api := s.baseURL + "/api/connect/v1"
|
||||||
|
|
||||||
endpoints := oauth2server.Endpoints{
|
endpoints := oauth2.Endpoints{
|
||||||
Authorization: uri.URI(api + "/oauth2/authorize"),
|
Authorization: uri.URI(api + "/oauth2/authorize"),
|
||||||
Token: uri.URI(api + "/oauth2/token"),
|
Token: uri.URI(api + "/oauth2/token"),
|
||||||
Userinfo: uri.URI(api + "/oauth2/userinfo"),
|
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"),
|
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")
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
httpserver.RenderJSON(w, http.StatusOK, metadata)
|
httpserver.RenderJSON(w, http.StatusOK, metadata)
|
||||||
|
|||||||
Reference in New Issue
Block a user