diff --git a/apps/console/src/pages/iam/auth/ConsentPage.tsx b/apps/console/src/pages/iam/auth/ConsentPage.tsx index cb6c314e8..6f3b150fe 100644 --- a/apps/console/src/pages/iam/auth/ConsentPage.tsx +++ b/apps/console/src/pages/iam/auth/ConsentPage.tsx @@ -18,6 +18,7 @@ import { useTranslate } from "@probo/i18n"; import { Button, IconArrowsClockwise, + IconChevronDown, IconEnvelope, IconKey, IconLockOpen, @@ -25,12 +26,13 @@ import { IconUserCircle, useToast, } from "@probo/ui"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay"; import { graphql } from "relay-runtime"; import type { ConsentPageMutation } from "#/__generated__/iam/ConsentPageMutation.graphql"; import type { ConsentPageQuery } from "#/__generated__/iam/ConsentPageQuery.graphql"; +import { formatApiScopeLabel } from "#/pages/iam/oauthTokens/_components/scopeLabels"; export const consentPageQuery = graphql` query ConsentPageQuery($consentId: ID!) { @@ -74,7 +76,81 @@ function scopeIcon(name: string): React.ReactNode { } function scopeLabel(name: string): string { - return scopeLabels[name] ?? name; + return scopeLabels[name] ?? formatApiScopeLabel(name); +} + +function isApiScope(scope: string): boolean { + return scope.startsWith("v1:"); +} + +function partitionScopes(scopes: readonly string[]) { + const oidcScopes: string[] = []; + const apiScopes: string[] = []; + + for (const scope of scopes) { + if (isApiScope(scope)) { + apiScopes.push(scope); + } else { + oidcScopes.push(scope); + } + } + + return { oidcScopes, apiScopes }; +} + +function ConsentScopeRow(props: { + scope: string; + translate: (label: string) => string; + nested?: boolean; +}) { + const label = scopeLabel(props.scope); + const translated = label !== props.scope ? props.translate(label) : label; + + return ( +
  • + {scopeIcon(props.scope)} + {translated} +
  • + ); +} + +function ConsentApiScopesAccordion(props: { + scopes: readonly string[]; + translate: (label: string) => string; + summaryLabel: string; +}) { + if (props.scopes.length === 0) { + return null; + } + + return ( +
    + + + {props.summaryLabel} + + + +
    + ); } export default function ConsentPage(props: { @@ -92,6 +168,16 @@ export default function ConsentPage(props: { const [approveConsent, isInFlight] = useMutation(approveConsentMutation); + const { oidcScopes, apiScopes } = useMemo( + () => partitionScopes(consent.scopes ?? []), + [consent.scopes], + ); + + const apiScopesSummary = useMemo( + () => `${__("API access")} (${apiScopes.length})`, + [__, apiScopes.length], + ); + const handleAction = useCallback( (approved: boolean) => { if (!consent.id) return; @@ -200,22 +286,25 @@ export default function ConsentPage(props: {

    - + +
    + + +
    + + + + + + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPageLoader.tsx b/apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPageLoader.tsx new file mode 100644 index 000000000..bc7bc74ca --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPageLoader.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { IAMRelayProvider } from "#/providers/IAMRelayProvider"; + +import { NewOAuthTokenPage } from "./NewOAuthTokenPage"; + +export default function NewOAuthTokenPageLoader() { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/OAuthTokensPage.tsx b/apps/console/src/pages/iam/oauthTokens/OAuthTokensPage.tsx new file mode 100644 index 000000000..585846cd2 --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/OAuthTokensPage.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Card, + IconChevronDown, + PageHeader, + Spinner, + Table, + Tbody, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { + graphql, + type PreloadedQuery, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; +import { Link } from "react-router"; + +import type { OAuthTokensPageFragment$key } from "#/__generated__/iam/OAuthTokensPageFragment.graphql"; +import type { OAuthTokensPageQuery } from "#/__generated__/iam/OAuthTokensPageQuery.graphql"; +import type { OAuthTokensPageRefetchQuery } from "#/__generated__/iam/OAuthTokensPageRefetchQuery.graphql"; + +import { OAuthTokenRow } from "./_components/OAuthTokenRow"; + +export const oauthTokensPageQuery = graphql` + query OAuthTokensPageQuery { + viewer @required(action: THROW) { + id + canCreateOAuth2AccessToken: permission( + action: "iam:oauth2-access-token:create" + ) + ...OAuthTokensPageFragment + } + } +`; + +const oauthTokensPageFragment = graphql` + fragment OAuthTokensPageFragment on Identity + @refetchable(queryName: "OAuthTokensPageRefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + after: { type: "CursorKey" } + ) { + oauth2AccessTokens(first: $first, after: $after) + @connection(key: "OAuthTokensPage_oauth2AccessTokens") + @required(action: THROW) { + edges { + node { + id + ...OAuthTokenRowFragment + } + } + totalCount @required(action: THROW) + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +export function OAuthTokensPage(props: { + queryRef: PreloadedQuery; +}) { + const { queryRef } = props; + const { __ } = useTranslate(); + + usePageTitle(__("OAuth tokens")); + + const { viewer } = usePreloadedQuery(oauthTokensPageQuery, queryRef); + const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment< + OAuthTokensPageRefetchQuery, + OAuthTokensPageFragment$key + >(oauthTokensPageFragment, viewer); + + const tokens = data.oauth2AccessTokens.edges; + const totalCount = data.oauth2AccessTokens.totalCount; + + return ( +
    + + {viewer.canCreateOAuth2AccessToken && ( + + )} + + + {tokens.length === 0 + ? ( + +
    +

    + {__("No OAuth tokens")} +

    +

    + {__( + "Create a token to authenticate API requests on your behalf.", + )} +

    +
    +
    + ) + : ( + + {totalCount > tokens.length && ( +

    + {`${__("Showing")} ${tokens.length} ${__("of")} ${totalCount}`} +

    + )} + + + + + + + + + + + {tokens.map(edge => ( + + ))} + +
    {__("Name")}{__("Scopes")}{__("Created")}{__("Expires")} +
    + {hasNext && ( + + )} +
    + )} +
    + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/OAuthTokensPageLoader.tsx b/apps/console/src/pages/iam/oauthTokens/OAuthTokensPageLoader.tsx new file mode 100644 index 000000000..3e3300560 --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/OAuthTokensPageLoader.tsx @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { OAuthTokensPageQuery } from "#/__generated__/iam/OAuthTokensPageQuery.graphql"; +import { IAMRelayProvider } from "#/providers/IAMRelayProvider"; + +import { OAuthTokensPage, oauthTokensPageQuery } from "./OAuthTokensPage"; +import OAuthTokensPageSkeleton from "./OAuthTokensPageSkeleton"; + +function OAuthTokensPageLoaderInner() { + const [queryRef, loadQuery] + = useQueryLoader(oauthTokensPageQuery); + + useEffect(() => { + loadQuery({}, { fetchPolicy: "store-and-network" }); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} + +export default function OAuthTokensPageLoader() { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/OAuthTokensPageSkeleton.tsx b/apps/console/src/pages/iam/oauthTokens/OAuthTokensPageSkeleton.tsx new file mode 100644 index 000000000..95afb59d6 --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/OAuthTokensPageSkeleton.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +export default function OAuthTokensPageSkeleton() { + return ( +
    +
    +
    +
    +
    +
    +
    + {Array.from({ length: 4 }).map((_, i) => ( +
    + ))} +
    +
    + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenCredentialsDialog.tsx b/apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenCredentialsDialog.tsx new file mode 100644 index 000000000..2a701e8fd --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenCredentialsDialog.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { useCopy } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, + IconCheckmark1, + IconSquareBehindSquare2, + IconWarning, +} from "@probo/ui"; +import { clsx } from "clsx"; + +export function OAuthTokenCredentialsDialog(props: { + dialogRef: React.RefObject<{ open: () => void; close: () => void } | null>; + token: string; + onDone: () => void; +}) { + const { dialogRef, token, onDone } = props; + const { __ } = useTranslate(); + const [isCopied, copy] = useCopy(); + + return ( + } + > + +
    + +

    + {__( + "Copy this bearer token now. You will not be able to see it again.", + )} +

    +
    + + {token} + + +
    + + + +
    + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenRow.tsx b/apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenRow.tsx new file mode 100644 index 000000000..ac1369428 --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenRow.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { formatError, type GraphQLError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogFooter, + Td, + Tr, + useToast, +} from "@probo/ui"; +import * as Popover from "@radix-ui/react-popover"; +import { ConnectionHandler, graphql, useFragment, useMutation } from "react-relay"; + +import type { OAuthTokenRowFragment$key } from "#/__generated__/iam/OAuthTokenRowFragment.graphql"; +import type { OAuthTokenRowRevokeMutation } from "#/__generated__/iam/OAuthTokenRowRevokeMutation.graphql"; + +import { formatApiScopeLabel } from "./scopeLabels"; + +const VISIBLE_SCOPE_COUNT = 3; + +const fragment = graphql` + fragment OAuthTokenRowFragment on OAuth2AccessToken { + id + name + scopes + expiresAt + createdAt + canDelete: permission(action: "iam:oauth2-access-token:delete") + } +`; + +const revokeMutation = graphql` + mutation OAuthTokenRowRevokeMutation( + $input: RevokeOAuth2AccessTokenInput! + $connections: [ID!]! + ) { + revokeOAuth2AccessToken(input: $input) { + oauth2AccessTokenId @deleteEdge(connections: $connections) + } + } +`; + +export function OAuthTokenRow(props: { + tokenKey: OAuthTokenRowFragment$key; + identityId: string; +}) { + const { tokenKey, identityId } = props; + const { __ } = useTranslate(); + const { toast } = useToast(); + + const token = useFragment(fragment, tokenKey); + const visibleScopes = token.scopes.slice(0, VISIBLE_SCOPE_COUNT); + const hiddenScopes = token.scopes.slice(VISIBLE_SCOPE_COUNT); + + const [revoke, isRevoking] = useMutation(revokeMutation); + + const handleRevoke = () => { + const connectionID = ConnectionHandler.getConnectionID( + identityId, + "OAuthTokensPage_oauth2AccessTokens", + ); + + revoke({ + variables: { + input: { oauth2AccessTokenId: token.id }, + connections: [connectionID], + }, + onCompleted: (_response, errors) => { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to revoke OAuth token."), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("OAuth token revoked."), + variant: "success", + }); + }, + onError: (error) => { + toast({ + title: __("Error"), + description: formatError(__("Failed to revoke OAuth token."), error), + variant: "error", + }); + }, + }); + }; + + return ( + + {token.name} + +
    + {visibleScopes.map(scope => ( + + {formatApiScopeLabel(scope)} + + ))} + {hiddenScopes.length > 0 && ( + + + + + + +
    + {hiddenScopes.map(scope => ( + + {formatApiScopeLabel(scope)} + + ))} +
    +
    +
    +
    + )} +
    + + {new Date(token.createdAt).toLocaleDateString()} + {new Date(token.expiresAt).toLocaleDateString()} + + {token.canDelete && ( + + {__("Revoke")} + + )} + > + +

    + {__( + "This token will stop working immediately. This action cannot be undone.", + )} +

    +
    + + + +
    + )} + + + ); +} diff --git a/apps/console/src/pages/iam/oauthTokens/_components/scopeLabels.ts b/apps/console/src/pages/iam/oauthTokens/_components/scopeLabels.ts new file mode 100644 index 000000000..d79903218 --- /dev/null +++ b/apps/console/src/pages/iam/oauthTokens/_components/scopeLabels.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +const apiScopeLabels: Record = { + "v1:access-review:read": "Read access reviews", + "v1:access-review": "Manage access reviews", + "v1:agent:read": "Read agents", + "v1:agent": "Manage agents", + "v1:asset:read": "Read assets", + "v1:asset": "Manage assets", + "v1:audit:read": "Read audits", + "v1:audit": "Manage audits", + "v1:common-third-party:read": "Read common third parties", + "v1:common-third-party": "Manage common third parties", + "v1:compliance-page:read": "Read compliance pages", + "v1:compliance-page": "Manage compliance pages", + "v1:connector:read": "Read connectors", + "v1:connector": "Manage connectors", + "v1:control:read": "Read controls", + "v1:control": "Manage controls", + "v1:datum:read": "Read data", + "v1:datum": "Manage data", + "v1:document:read": "Read documents", + "v1:document": "Manage documents", + "v1:iam:read": "Read IAM settings", + "v1:iam": "Manage IAM settings", + "v1:org:read": "Read organization", + "v1:org": "Manage organization", + "v1:privacy:read": "Read privacy settings", + "v1:privacy": "Manage privacy settings", + "v1:risk:read": "Read risks", + "v1:risk": "Manage risks", + "v1:slack-connection:read": "Read Slack connections", + "v1:slack-connection": "Manage Slack connections", + "v1:task:read": "Read tasks", + "v1:task": "Manage tasks", + "v1:third-party:read": "Read third parties", + "v1:third-party": "Manage third parties", + "v1:webhook:read": "Read webhooks", + "v1:webhook": "Manage webhooks", +}; + +export function formatApiScopeLabel(scope: string): string { + return apiScopeLabels[scope] ?? scope; +} diff --git a/apps/console/src/pages/iam/organizations/_components/ViewerMembershipDropdown.tsx b/apps/console/src/pages/iam/organizations/_components/ViewerMembershipDropdown.tsx index 23b1af261..58ce93cab 100644 --- a/apps/console/src/pages/iam/organizations/_components/ViewerMembershipDropdown.tsx +++ b/apps/console/src/pages/iam/organizations/_components/ViewerMembershipDropdown.tsx @@ -38,6 +38,9 @@ export const fragment = graphql` identity @required(action: THROW) { email canListAPIKeys: permission(action: "iam:personal-api-key:list") + canListOAuth2AccessTokens: permission( + action: "iam:oauth2-access-token:list" + ) } } } @@ -63,7 +66,7 @@ export function ViewerMembershipDropdown(props: { const { viewer: { fullName, - identity: { canListAPIKeys, email }, + identity: { canListAPIKeys, canListOAuth2AccessTokens, email }, }, } = useFragment(fragment, fKey); const [signOut] = useMutation(signOutMutation); @@ -103,6 +106,13 @@ export function ViewerMembershipDropdown(props: { label={__("API Keys")} /> )} + {canListOAuth2AccessTokens && ( + + )} import("./pages/iam/apiKeys/APIKeysPageLoader"), ), }, + { + path: "me/oauth-tokens", + Component: lazy( + () => import("./pages/iam/oauthTokens/OAuthTokensPageLoader"), + ), + }, + { + path: "me/oauth-tokens/new", + Component: lazy( + () => import("./pages/iam/oauthTokens/NewOAuthTokenPageLoader"), + ), + }, { Component: CenteredLayout, children: [ diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md index fec0438cf..c1c28d00b 100644 --- a/contrib/claude/authorization.md +++ b/contrib/claude/authorization.md @@ -277,25 +277,7 @@ const ( ## OAuth2 API scopes -OAuth2 scopes for API access are defined as `coredata.OAuth2Scope` constants in each owning package (for example `pkg/probo/oauth2_scopes.go`, `pkg/iam/oauth2_scopes.go`). `pkg/coredata/oauth2_scope.go` defines the persistence type. Standard OIDC scopes live in `pkg/iam/oauth2/scope.go`. Register scope sets with `Authorizer.RegisterScopes`. - -**Enforcement:** OAuth2 bearer-token requests carry the validated access token on the request context (`pkg/iam/oauth2/request_context.go`). Before IAM policy evaluation, `iam.Authorizer` checks registered `iam.ScopeSet` mappings via `ScopeSet.Allows` (`RegisterScopes`, same composition model as `RegisterPolicySet`). Each domain package exports an `OAuth2ScopeSet()` (or `IAMOAuth2ScopeSet()` in `pkg/iam`) and registers it at service startup. The check uses explicit scope→action lists — no `:read` / `:get` heuristics at enforcement time. Session, personal API key, and SCIM auth skip the check (no access token on context). Unmapped IAM actions **deny** OAuth requests (fail closed). Enforcement reads scopes from the access token directly. - -To add a new OAuth surface for OAuth clients: add namespace-level scope constants in the owning package's `oauth2_scopes.go`, map IAM actions in that package's `OAuth2ScopeSet()`, and register the set on the authorizer at service startup. Write scopes are registered only when their mutating IAM actions are mapped. - -## Built-in role policies - -| Role | Access level | -|------|-------------| -| `OWNER` | Full access to all features including org management | -| `ADMIN` | Full access to core features, restricted org management | -| `VIEWER` | Read-only access to most entities | -| `AUDITOR` | Read-only, excludes internal/employee content | -| `EMPLOYEE` | Can sign documents and view internal content | - -## OAuth2 API scopes - -OAuth2 scopes for API access are defined as `coredata.OAuth2Scope` constants in each owning package (for example [`pkg/probo/oauth2_scopes.go`](../../pkg/probo/oauth2_scopes.go), [`pkg/iam/oauth2_scopes.go`](../../pkg/iam/oauth2_scopes.go)). [`pkg/coredata/oauth2_scope.go`](../../pkg/coredata/oauth2_scope.go) defines the persistence type. Standard OIDC scopes live in [`pkg/iam/oauth2/scope.go`](../../pkg/iam/oauth2/scope.go). Register scope sets with `Authorizer.RegisterScopes`; discovery scopes are derived from each `ScopeSet` automatically. +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`. **Format:** @@ -309,10 +291,35 @@ Scopes are namespace- or product-level only — no resource segments (e.g. `v1:p - 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. +**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. 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. +**Well-known Probo CLI client:** `iam_oauth2_clients` scopes for `AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp` must match `CLIClientScopes` in `pkg/cli/config/config.go` (requested by `prb auth login`). When adding API scopes, update the client migration, `CLIClientScopes`, and scope registration together. + +### Personal OAuth2 access tokens + +Manual bearer tokens created from the console are stored in `iam_oauth2_access_tokens` with a `NULL` `client_id` and are scoped to the creating identity. They are managed via Connect GraphQL on the signed-in user's `Identity`, similar to personal API keys. IAM actions: + +| Action | Purpose | +|--------|---------| +| `iam:oauth2-access-token:create` | Create a manual token | +| `iam:oauth2-access-token:list` | List your tokens | +| `iam:oauth2-access-token:get` | Read token metadata | +| `iam:oauth2-access-token:delete` | Revoke (delete) a token | + +**Policies:** `IAMSelfManageIdentityPolicy` allows listing on your identity; `IAMSelfManageOAuth2AccessTokenPolicy` allows create/get/delete when `principal.id == resource.identity_id`. **OAuth2 scope gate:** create/list/get/delete map to `v1:iam:read` / `v1:iam` in `pkg/iam/oauth2_scopes.go`. + +## Built-in role policies + +| Role | Access level | +|------|-------------| +| `OWNER` | Full access to all features including org management | +| `ADMIN` | Full access to core features, restricted org management | +| `VIEWER` | Read-only access to most entities | +| `AUDITOR` | Read-only, excludes internal/employee content | +| `EMPLOYEE` | Can sign documents and view internal content | + ## New entity IAM wiring When adding a new entity that needs authorization: diff --git a/e2e/console/oauth2_access_token_test.go b/e2e/console/oauth2_access_token_test.go new file mode 100644 index 000000000..fd0b3c821 --- /dev/null +++ b/e2e/console/oauth2_access_token_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestOAuth2AccessToken_CreateListUseRevoke(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + organizationID := owner.GetOrganizationID().String() + expiresAt := time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339) + + const createMutation = ` + mutation CreateOAuth2AccessToken($input: CreateOAuth2AccessTokenInput!) { + createOAuth2AccessToken(input: $input) { + token + oauth2AccessTokenEdge { + node { + id + name + scopes + } + } + } + } + ` + + createResp, err := owner.DoConnect(createMutation, map[string]any{ + "input": map[string]any{ + "name": "E2E manual token", + "expiresAt": expiresAt, + "scopes": []string{"v1:org:read"}, + }, + }) + require.NoError(t, err) + require.NotNil(t, createResp) + + var createData struct { + CreateOAuth2AccessToken struct { + Token string `json:"token"` + Edge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + } `json:"node"` + } `json:"oauth2AccessTokenEdge"` + } `json:"createOAuth2AccessToken"` + } + require.NoError(t, json.Unmarshal(createResp.Data, &createData)) + + tokenID := createData.CreateOAuth2AccessToken.Edge.Node.ID + tokenValue := createData.CreateOAuth2AccessToken.Token + + require.NotEmpty(t, tokenID) + require.NotEmpty(t, tokenValue) + assert.Equal(t, "E2E manual token", createData.CreateOAuth2AccessToken.Edge.Node.Name) + assert.Equal(t, []string{"v1:org:read"}, createData.CreateOAuth2AccessToken.Edge.Node.Scopes) + + const listQuery = ` + query ListOAuth2AccessTokens { + viewer { + oauth2AccessTokens(first: 10) { + totalCount + edges { + node { + id + name + } + } + } + } + } + ` + + listResp, err := owner.DoConnect(listQuery, map[string]any{}) + require.NoError(t, err) + require.NotNil(t, listResp) + + var listData struct { + Viewer struct { + OAuth2AccessTokens struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"oauth2AccessTokens"` + } `json:"viewer"` + } + require.NoError(t, json.Unmarshal(listResp.Data, &listData)) + require.GreaterOrEqual(t, listData.Viewer.OAuth2AccessTokens.TotalCount, 1) + + found := false + + for _, edge := range listData.Viewer.OAuth2AccessTokens.Edges { + if edge.Node.ID == tokenID { + found = true + + assert.Equal(t, "E2E manual token", edge.Node.Name) + } + } + + require.True(t, found, "created token should appear in identity list") + + const getOrganizationQuery = ` + query GetOrganization($id: ID!) { + node(id: $id) { + ... on Organization { + id + name + } + } + } + ` + + allowedResp, err := testutil.ConsoleGraphQLWithAccessToken( + t, + tokenValue, + getOrganizationQuery, + map[string]any{"id": organizationID}, + ) + require.NoError(t, err) + require.NotNil(t, allowedResp) + + const revokeMutation = ` + mutation RevokeOAuth2AccessToken($input: RevokeOAuth2AccessTokenInput!) { + revokeOAuth2AccessToken(input: $input) { + oauth2AccessTokenId + } + } + ` + + revokeResp, err := owner.DoConnect(revokeMutation, map[string]any{ + "input": map[string]any{ + "oauth2AccessTokenId": tokenID, + }, + }) + require.NoError(t, err) + require.NotNil(t, revokeResp) + + deniedResp, err := testutil.ConsoleGraphQLWithAccessToken( + t, + tokenValue, + getOrganizationQuery, + map[string]any{"id": organizationID}, + ) + require.Error(t, err) + require.Nil(t, deniedResp) +} diff --git a/pkg/coredata/migrations/20260617T120001Z.sql b/pkg/coredata/migrations/20260617T120001Z.sql new file mode 100644 index 000000000..593a2cd0b --- /dev/null +++ b/pkg/coredata/migrations/20260617T120001Z.sql @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TABLE iam_oauth2_access_tokens ADD COLUMN name TEXT; + +UPDATE iam_oauth2_access_tokens SET name = 'OAuth grant' WHERE name IS NULL; + +ALTER TABLE iam_oauth2_access_tokens ALTER COLUMN name SET NOT NULL; diff --git a/pkg/coredata/migrations/20260618T120000Z.sql b/pkg/coredata/migrations/20260618T120000Z.sql new file mode 100644 index 000000000..c48b7bb19 --- /dev/null +++ b/pkg/coredata/migrations/20260618T120000Z.sql @@ -0,0 +1,16 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +-- Manual console tokens are identity-scoped and have no OAuth2 client. +ALTER TABLE iam_oauth2_access_tokens ALTER COLUMN client_id DROP NOT NULL; diff --git a/pkg/coredata/oauth2_access_token.go b/pkg/coredata/oauth2_access_token.go index a0b123828..9b45fb71a 100644 --- a/pkg/coredata/oauth2_access_token.go +++ b/pkg/coredata/oauth2_access_token.go @@ -18,25 +18,40 @@ import ( "context" "errors" "fmt" + "maps" "time" "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" + "go.probo.inc/probo/pkg/page" ) type ( OAuth2AccessToken struct { ID gid.GID `db:"id"` + Name string `db:"name"` HashedValue []byte `db:"hashed_value"` - ClientID gid.GID `db:"client_id"` + ClientID *gid.GID `db:"client_id"` IdentityID gid.GID `db:"identity_id"` Scopes OAuth2Scopes `db:"scopes"` CreatedAt time.Time `db:"created_at"` ExpiresAt time.Time `db:"expires_at"` } + + OAuth2AccessTokens []*OAuth2AccessToken ) +func (t *OAuth2AccessToken) CursorKey(orderBy OAuth2AccessTokenOrderField) page.CursorKey { + switch orderBy { + case OAuth2AccessTokenOrderFieldCreatedAt: + return page.NewCursorKey(t.ID, t.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + func (t *OAuth2AccessToken) ExpiresIn(now time.Time) time.Duration { return t.ExpiresAt.Sub(now) } @@ -45,6 +60,7 @@ func (t *OAuth2AccessToken) Insert(ctx context.Context, conn pg.Tx) error { q := ` INSERT INTO iam_oauth2_access_tokens ( id, + name, hashed_value, client_id, identity_id, @@ -53,6 +69,7 @@ INSERT INTO iam_oauth2_access_tokens ( expires_at ) VALUES ( @id, + @name, @hashed_value, @client_id, @identity_id, @@ -64,6 +81,7 @@ INSERT INTO iam_oauth2_access_tokens ( args := pgx.StrictNamedArgs{ "id": t.ID, + "name": t.Name, "hashed_value": t.HashedValue, "client_id": t.ClientID, "identity_id": t.IdentityID, @@ -80,10 +98,48 @@ INSERT INTO iam_oauth2_access_tokens ( return nil } +func (t *OAuth2AccessToken) LoadByID(ctx context.Context, conn pg.Querier, id gid.GID) error { + q := ` +SELECT + id, + name, + hashed_value, + client_id, + identity_id, + scopes, + created_at, + expires_at +FROM + iam_oauth2_access_tokens +WHERE + id = @id +LIMIT 1; +` + + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"id": id}) + if err != nil { + return fmt.Errorf("cannot query oauth2_access_token: %w", err) + } + + token, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OAuth2AccessToken]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect oauth2_access_token: %w", err) + } + + *t = token + + return nil +} + func (t *OAuth2AccessToken) LoadByHashedValue(ctx context.Context, conn pg.Querier, hashedValue []byte) error { q := ` SELECT id, + name, hashed_value, client_id, identity_id, @@ -125,6 +181,7 @@ func (t *OAuth2AccessToken) LoadByHashedValueAndClientID( q := ` SELECT id, + name, hashed_value, client_id, identity_id, @@ -163,6 +220,127 @@ LIMIT 1; return nil } +func (t *OAuth2AccessToken) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := ` +SELECT + t.id, + t.identity_id +FROM + iam_oauth2_access_tokens t +WHERE + t.id = ANY(@resource_ids::text[]) +` + + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query oauth2 access token authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + + for rows.Next() { + var ( + id gid.GID + identityID gid.GID + ) + + err = rows.Scan(&id, &identityID) + if err != nil { + return nil, fmt.Errorf("cannot scan oauth2 access token authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{"identity_id": identityID.String()} + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate oauth2 access token authorization attributes: %w", err) + } + + return attrsByID, nil +} + +func (ts *OAuth2AccessTokens) LoadByIdentityID( + ctx context.Context, + conn pg.Querier, + identityID gid.GID, + cursor *page.Cursor[OAuth2AccessTokenOrderField], +) error { + q := ` +SELECT + id, + name, + hashed_value, + client_id, + identity_id, + scopes, + created_at, + expires_at +FROM + iam_oauth2_access_tokens +WHERE + identity_id = @identity_id + AND client_id IS NULL + AND %s +` + + q = fmt.Sprintf(q, cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "identity_id": identityID, + } + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query oauth2 access tokens: %w", err) + } + + tokens, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[OAuth2AccessToken]) + if err != nil { + return fmt.Errorf("cannot collect oauth2 access tokens: %w", err) + } + + *ts = tokens + + return nil +} + +func (ts *OAuth2AccessTokens) CountByIdentityID( + ctx context.Context, + conn pg.Querier, + identityID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + iam_oauth2_access_tokens +WHERE + identity_id = @identity_id + AND client_id IS NULL; +` + + args := pgx.StrictNamedArgs{ + "identity_id": identityID, + } + + var count int + if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count oauth2 access tokens: %w", err) + } + + return count, nil +} + func (t *OAuth2AccessToken) Delete(ctx context.Context, conn pg.Tx) error { q := ` DELETE FROM iam_oauth2_access_tokens diff --git a/pkg/coredata/oauth2_access_token_order_field.go b/pkg/coredata/oauth2_access_token_order_field.go new file mode 100644 index 000000000..24b408d43 --- /dev/null +++ b/pkg/coredata/oauth2_access_token_order_field.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type OAuth2AccessTokenOrderField string + +const ( + OAuth2AccessTokenOrderFieldCreatedAt OAuth2AccessTokenOrderField = "CREATED_AT" +) + +var ( + _ page.OrderField = OAuth2AccessTokenOrderField("") + _ fmt.Stringer = OAuth2AccessTokenOrderField("") + _ encoding.TextMarshaler = OAuth2AccessTokenOrderField("") + _ encoding.TextUnmarshaler = (*OAuth2AccessTokenOrderField)(nil) +) + +func OAuth2AccessTokenOrderFields() []OAuth2AccessTokenOrderField { + return []OAuth2AccessTokenOrderField{ + OAuth2AccessTokenOrderFieldCreatedAt, + } +} + +func (v OAuth2AccessTokenOrderField) IsValid() bool { + switch v { + case OAuth2AccessTokenOrderFieldCreatedAt: + return true + } + + return false +} + +func (v OAuth2AccessTokenOrderField) String() string { + return string(v) +} + +func (v OAuth2AccessTokenOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *OAuth2AccessTokenOrderField) UnmarshalText(text []byte) error { + val := OAuth2AccessTokenOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid OAuth2AccessTokenOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (f OAuth2AccessTokenOrderField) Column() string { + switch f { + case OAuth2AccessTokenOrderFieldCreatedAt: + return "created_at" + } + + panic(fmt.Sprintf("unsupported order by: %s", f)) +} diff --git a/pkg/iam/authorizer_batch_test.go b/pkg/iam/authorizer_batch_test.go index f1f1c475b..deb9c32fd 100644 --- a/pkg/iam/authorizer_batch_test.go +++ b/pkg/iam/authorizer_batch_test.go @@ -151,7 +151,7 @@ func TestAuthorizer_AuthorizeBatch(t *testing.T) { Principal: fixture.identityID, Action: action, Resources: []gid.GID{ - gid.New(fixture.tenantID, coredata.OAuth2AccessTokenEntityType), + gid.New(fixture.tenantID, coredata.OAuth2RefreshTokenEntityType), }, }, ) @@ -159,7 +159,7 @@ func TestAuthorizer_AuthorizeBatch(t *testing.T) { errUnsupported, ok := errors.AsType[*iam.ErrBatchAuthorizationUnsupportedResourceType](err) require.True(t, ok) - assert.Equal(t, coredata.OAuth2AccessTokenEntityType, errUnsupported.EntityType) + assert.Equal(t, coredata.OAuth2RefreshTokenEntityType, errUnsupported.EntityType) }) t.Run("single deny rolls back entire batch", func(t *testing.T) { diff --git a/pkg/iam/authorizer_unit_test.go b/pkg/iam/authorizer_unit_test.go index c9c7a9b7f..6a487ee0b 100644 --- a/pkg/iam/authorizer_unit_test.go +++ b/pkg/iam/authorizer_unit_test.go @@ -91,7 +91,7 @@ func TestAuthorizer_InternalErrorPaths(t *testing.T) { ctx := context.Background() identityID := gid.New(gid.NilTenant, coredata.IdentityEntityType) unknownResourceID := gid.New(gid.NewTenantID(), 65535) - unsupportedResourceID := gid.New(gid.NewTenantID(), coredata.OAuth2AccessTokenEntityType) + unsupportedResourceID := gid.New(gid.NewTenantID(), coredata.OAuth2RefreshTokenEntityType) a := &Authorizer{ evaluator: policy.NewEvaluator(), @@ -156,7 +156,7 @@ func TestAuthorizer_InternalErrorPaths(t *testing.T) { require.Error(t, err) errUnsupported, ok := errors.AsType[*ErrBatchAuthorizationUnsupportedResourceType](err) require.True(t, ok) - assert.Equal(t, coredata.OAuth2AccessTokenEntityType, errUnsupported.EntityType) + assert.Equal(t, coredata.OAuth2RefreshTokenEntityType, errUnsupported.EntityType) }) t.Run("build principal attributes keeps defaults when entity type is unknown", func(t *testing.T) { diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index be93db965..e177e389e 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -94,6 +94,12 @@ const ( ActionOAuth2ConsentGet = "iam:oauth2-consent:get" ActionOAuth2ConsentApprove = "iam:oauth2-consent:approve" + // OAuth2 Access Token actions + ActionOAuth2AccessTokenCreate = "iam:oauth2-access-token:create" + ActionOAuth2AccessTokenGet = "iam:oauth2-access-token:get" + ActionOAuth2AccessTokenList = "iam:oauth2-access-token:list" + ActionOAuth2AccessTokenDelete = "iam:oauth2-access-token:delete" + // Audit log entry actions ActionAuditLogEntryGet = "iam:audit-log-entry:get" ActionAuditLogEntryList = "iam:audit-log-entry:list" diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go index 523ceddfb..385ef0d18 100644 --- a/pkg/iam/iam_policies.go +++ b/pkg/iam/iam_policies.go @@ -42,6 +42,7 @@ var IAMSelfManageIdentityPolicy = policy.NewPolicy( ActionInvitationList, ActionSessionList, ActionPersonalAPIKeyList, + ActionOAuth2AccessTokenList, ). WithSID("list-own-associations"). When(policy.Equals("principal.id", "resource.identity_id")), @@ -123,6 +124,21 @@ var IAMSelfManagePersonalAPIKeyPolicy = policy.NewPolicy( ). WithDescription("Allows users to manage their own personal API keys") +// IAMSelfManageOAuth2AccessTokenPolicy allows users to manage their own OAuth2 access tokens. +var IAMSelfManageOAuth2AccessTokenPolicy = policy.NewPolicy( + "iam:self-manage-oauth2-access-token", + "Self-Manage OAuth2 Access Tokens", + + policy.Allow( + ActionOAuth2AccessTokenCreate, + ActionOAuth2AccessTokenGet, + ActionOAuth2AccessTokenDelete, + ). + WithSID("manage-own-oauth2-access-tokens"). + When(policy.Equals("principal.id", "resource.identity_id")), +). + WithDescription("Allows users to manage their own manually created OAuth2 access tokens") + // IAMSelfManageOAuth2ConsentPolicy allows users to manage their own OAuth2 consents. var IAMSelfManageOAuth2ConsentPolicy = policy.NewPolicy( "iam:self-manage-oauth2-consent", diff --git a/pkg/iam/oauth2/service.go b/pkg/iam/oauth2/service.go index 2a13586cf..bd04fdf6a 100644 --- a/pkg/iam/oauth2/service.go +++ b/pkg/iam/oauth2/service.go @@ -32,6 +32,7 @@ import ( "go.probo.inc/probo/pkg/crypto/rand" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/net" + "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/uri" ) @@ -41,9 +42,10 @@ import ( var CLIClientID = gid.MustParseGID("AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp") const ( - tokenByteLength = 32 - refreshTokenByteLength = 48 - tokenTypeBearer = "Bearer" + tokenByteLength = 32 + refreshTokenByteLength = 48 + tokenTypeBearer = "Bearer" + oauthGrantAccessTokenName = "OAuth grant" // userCodeAlphabet excludes ambiguous characters: 0/O, 1/I/L. userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" @@ -120,6 +122,14 @@ type ( ExpiresAt time.Time TokenType string } + + CreateManualAccessTokenRequest struct { + IdentityID gid.GID + Name string + ExpiresAt time.Time + Scopes coredata.OAuth2Scopes + AllowedAPIScopes []coredata.OAuth2Scope + } ) func WithAccessTokenDuration(d time.Duration) Option { @@ -221,8 +231,9 @@ func (s *Service) CreateAccessToken( now := time.Now() token := &coredata.OAuth2AccessToken{ ID: gid.New(clientID.TenantID(), coredata.OAuth2AccessTokenEntityType), + Name: oauthGrantAccessTokenName, HashedValue: hash.SHA256String(tokenValue), - ClientID: clientID, + ClientID: new(clientID), IdentityID: identityID, Scopes: scopes, CreatedAt: now, @@ -409,8 +420,9 @@ func (s *Service) ExchangeAuthorizationCode( func(ctx context.Context, tx pg.Tx) error { accessToken := &coredata.OAuth2AccessToken{ ID: accessTokenID, + Name: oauthGrantAccessTokenName, HashedValue: hash.SHA256String(accessTokenValue), - ClientID: client.ID, + ClientID: new(client.ID), IdentityID: code.IdentityID, Scopes: code.Scopes, CreatedAt: now, @@ -602,8 +614,9 @@ func (s *Service) RefreshToken( accessToken := &coredata.OAuth2AccessToken{ ID: gid.New(client.ID.TenantID(), coredata.OAuth2AccessTokenEntityType), + Name: oauthGrantAccessTokenName, HashedValue: hash.SHA256String(accessTokenValue), - ClientID: client.ID, + ClientID: new(client.ID), IdentityID: previousRefreshToken.IdentityID, Scopes: previousRefreshToken.Scopes, CreatedAt: now, @@ -871,8 +884,9 @@ func (s *Service) PollDeviceCode( func(ctx context.Context, tx pg.Tx) error { accessToken := &coredata.OAuth2AccessToken{ ID: gid.New(clientID.TenantID(), coredata.OAuth2AccessTokenEntityType), + Name: oauthGrantAccessTokenName, HashedValue: hash.SHA256String(accessTokenValue), - ClientID: clientID, + ClientID: new(clientID), IdentityID: *deviceCode.IdentityID, Scopes: deviceCode.Scopes, CreatedAt: now, @@ -1230,8 +1244,13 @@ func (s *Service) IntrospectToken( return nil, nil } + var resultClientID gid.GID + if accessToken.ClientID != nil { + resultClientID = *accessToken.ClientID + } + return &IntrospectResult{ - ClientID: accessToken.ClientID, + ClientID: resultClientID, IdentityID: accessToken.IdentityID, Scopes: accessToken.Scopes, IssuedAt: accessToken.CreatedAt, @@ -1767,3 +1786,168 @@ func (s *Service) issueAuthorizationCode( return codeValue, nil } + +func (s *Service) GetAccessTokenByID(ctx context.Context, accessTokenID gid.GID) (*coredata.OAuth2AccessToken, error) { + token := &coredata.OAuth2AccessToken{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := token.LoadByID(ctx, conn, accessTokenID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return coredata.ErrResourceNotFound + } + + return fmt.Errorf("cannot load oauth2 access token: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return token, nil +} + +func (s *Service) ListAccessTokensByIdentityID( + ctx context.Context, + identityID gid.GID, + cursor *page.Cursor[coredata.OAuth2AccessTokenOrderField], +) (*page.Page[*coredata.OAuth2AccessToken, coredata.OAuth2AccessTokenOrderField], error) { + var tokens coredata.OAuth2AccessTokens + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := tokens.LoadByIdentityID(ctx, conn, identityID, cursor); err != nil { + return fmt.Errorf("cannot load oauth2 access tokens: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(tokens, cursor), nil +} + +func (s *Service) CountAccessTokensByIdentityID( + ctx context.Context, + identityID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var tokens coredata.OAuth2AccessTokens + + var err error + + count, err = tokens.CountByIdentityID(ctx, conn, identityID) + if err != nil { + return fmt.Errorf("cannot count oauth2 access tokens: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *Service) RevokeAccessToken(ctx context.Context, accessTokenID gid.GID) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + token := &coredata.OAuth2AccessToken{} + + if err := token.LoadByID(ctx, tx, accessTokenID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil + } + + return fmt.Errorf("cannot load oauth2 access token: %w", err) + } + + if err := token.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot revoke oauth2 access token: %w", err) + } + + return nil + }, + ) +} + +func (s *Service) CreateManualAccessToken( + ctx context.Context, + req *CreateManualAccessTokenRequest, +) (string, *coredata.OAuth2AccessToken, error) { + if req.Name == "" { + return "", nil, NewError(ErrInvalidRequest, WithDescription("name is required")) + } + + now := time.Now() + if !req.ExpiresAt.After(now) { + return "", nil, NewError(ErrInvalidRequest, WithDescription("expires_at must be in the future")) + } + + if len(req.Scopes) == 0 { + return "", nil, NewError(ErrInvalidRequest, WithDescription("scopes are required")) + } + + if err := validateManualAccessTokenScopes(req.Scopes, req.AllowedAPIScopes); err != nil { + return "", nil, err + } + + tokenValue := rand.MustHexString(tokenByteLength) + + accessToken := &coredata.OAuth2AccessToken{ + ID: gid.New(req.IdentityID.TenantID(), coredata.OAuth2AccessTokenEntityType), + Name: req.Name, + HashedValue: hash.SHA256String(tokenValue), + ClientID: nil, + IdentityID: req.IdentityID, + Scopes: req.Scopes, + CreatedAt: now, + ExpiresAt: req.ExpiresAt, + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := accessToken.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert oauth2 access token: %w", err) + } + + return nil + }, + ) + if err != nil { + return "", nil, err + } + + return tokenValue, accessToken, nil +} + +func validateManualAccessTokenScopes(scopes, allowedAPIScopes coredata.OAuth2Scopes) error { + allowed := make(map[coredata.OAuth2Scope]struct{}, len(allowedAPIScopes)) + for _, scope := range allowedAPIScopes { + allowed[scope] = struct{}{} + } + + for _, scope := range scopes { + if _, ok := allowed[scope]; !ok { + return NewError(ErrInvalidScope, WithDescription("invalid scope: "+string(scope))) + } + } + + return nil +} diff --git a/pkg/iam/oauth2_scopes.go b/pkg/iam/oauth2_scopes.go index 75aeb018f..7f68bab77 100644 --- a/pkg/iam/oauth2_scopes.go +++ b/pkg/iam/oauth2_scopes.go @@ -48,6 +48,8 @@ func IAMOAuth2ScopeSet() *ScopeSet { ActionOAuth2ConsentGet, ActionAuditLogEntryGet, ActionAuditLogEntryList, + ActionOAuth2AccessTokenGet, + ActionOAuth2AccessTokenList, }, ScopeV1IAM: { ActionOrganizationCreate, diff --git a/pkg/iam/policy_set.go b/pkg/iam/policy_set.go index ca2745378..adc801b05 100644 --- a/pkg/iam/policy_set.go +++ b/pkg/iam/policy_set.go @@ -71,6 +71,7 @@ func IAMPolicySet() *PolicySet { IAMSelfManageProfilePolicy, IAMSelfManageMembershipPolicy, IAMSelfManagePersonalAPIKeyPolicy, + IAMSelfManageOAuth2AccessTokenPolicy, IAMSelfManageOAuth2ConsentPolicy, ) } diff --git a/pkg/server/api/connect/v1/base_resolvers.go b/pkg/server/api/connect/v1/base_resolvers.go index b0cbf9f3c..ba2c1db4a 100644 --- a/pkg/server/api/connect/v1/base_resolvers.go +++ b/pkg/server/api/connect/v1/base_resolvers.go @@ -122,6 +122,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewPersonalAPIKey(personalAPIKey), nil } + case coredata.OAuth2AccessTokenEntityType: + action = iam.ActionOAuth2AccessTokenGet + loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { + accessToken, err := r.iam.OAuth2ServerService.GetAccessTokenByID(ctx, id) + if err != nil { + return nil, err + } + + return types.NewOAuth2AccessToken(accessToken), nil + } case coredata.SCIMConfigurationEntityType: action = iam.ActionSCIMConfigurationGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { @@ -256,6 +266,18 @@ func (r *queryResolver) SignUpEnabled(ctx context.Context) (bool, error) { return r.iam.IsSignUpEnabled(), nil } +// Oauth2ScopesSupported is the resolver for the oauth2ScopesSupported field. +func (r *queryResolver) Oauth2ScopesSupported(ctx context.Context) ([]string, error) { + apiScopes := r.iam.Authorizer.APIScopes() + + scopes := make([]string, len(apiScopes)) + for i, scope := range apiScopes { + scopes[i] = scope.String() + } + + return scopes, nil +} + // Mutation returns schema.MutationResolver implementation. func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} } diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql index 2e7ee8b4c..b4deb6afd 100644 --- a/pkg/server/api/connect/v1/graphql/base.graphql +++ b/pkg/server/api/connect/v1/graphql/base.graphql @@ -33,6 +33,9 @@ type Query { signUpEnabled: Boolean! @goField(forceResolver: true) @authentication(required: OPTIONAL) + oauth2ScopesSupported: [String!]! + @goField(forceResolver: true) + @authentication(required: OPTIONAL) } type OIDCProviderInfo { diff --git a/pkg/server/api/connect/v1/graphql/identity.graphql b/pkg/server/api/connect/v1/graphql/identity.graphql index 000db1a6e..7efe655be 100644 --- a/pkg/server/api/connect/v1/graphql/identity.graphql +++ b/pkg/server/api/connect/v1/graphql/identity.graphql @@ -33,6 +33,16 @@ type Identity implements Node { @authentication(required: PRESENT) @sessionOnly + oauth2AccessTokens( + first: Int + after: CursorKey + last: Int + before: CursorKey + ): OAuth2AccessTokenConnection + @goField(forceResolver: true) + @authentication(required: PRESENT) + @sessionOnly + invitingOrganizations: [Organization!]! @goField(forceResolver: true) @authentication(required: PRESENT) diff --git a/pkg/server/api/connect/v1/graphql/oauth2_access_token.graphql b/pkg/server/api/connect/v1/graphql/oauth2_access_token.graphql new file mode 100644 index 000000000..491b23daa --- /dev/null +++ b/pkg/server/api/connect/v1/graphql/oauth2_access_token.graphql @@ -0,0 +1,58 @@ +type OAuth2AccessToken implements Node { + id: ID! + name: String! + scopes: [String!]! + expiresAt: Datetime! + createdAt: Datetime! + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type OAuth2AccessTokenConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.OAuth2AccessTokenConnection" + ) { + edges: [OAuth2AccessTokenEdge!]! + pageInfo: PageInfo! + totalCount: Int @goField(forceResolver: true) +} + +type OAuth2AccessTokenEdge { + node: OAuth2AccessToken! + cursor: CursorKey! +} + +extend type Mutation { + createOAuth2AccessToken( + input: CreateOAuth2AccessTokenInput! + ): CreateOAuth2AccessTokenPayload + @authentication(required: PRESENT) + @sessionOnly + + revokeOAuth2AccessToken( + input: RevokeOAuth2AccessTokenInput! + ): RevokeOAuth2AccessTokenPayload + @authentication(required: PRESENT) + @sessionOnly +} + +input CreateOAuth2AccessTokenInput + @goModel( + model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.CreateOAuth2AccessTokenInput" + ) { + name: String! + expiresAt: Datetime! + scopes: [String!]! +} + +input RevokeOAuth2AccessTokenInput { + oauth2AccessTokenId: ID! +} + +type CreateOAuth2AccessTokenPayload { + oauth2AccessTokenEdge: OAuth2AccessTokenEdge! + token: String! +} + +type RevokeOAuth2AccessTokenPayload { + oauth2AccessTokenId: ID! +} diff --git a/pkg/server/api/connect/v1/identity_resolvers.go b/pkg/server/api/connect/v1/identity_resolvers.go index 3e3759b2e..7dcb47d4d 100644 --- a/pkg/server/api/connect/v1/identity_resolvers.go +++ b/pkg/server/api/connect/v1/identity_resolvers.go @@ -130,6 +130,36 @@ func (r *identityResolver) PersonalAPIKeys(ctx context.Context, obj *types.Ident return types.NewPersonalAPIKeyConnection(page, r, obj.ID), nil } +// Oauth2AccessTokens is the resolver for the oauth2AccessTokens field. +func (r *identityResolver) Oauth2AccessTokens(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.OAuth2AccessTokenConnection, error) { + if _, err := r.authorize(ctx, obj.ID, iam.ActionOAuth2AccessTokenList); err != nil { + return nil, err + } + + if gqlutils.OnlyTotalCountSelected(ctx) { + return &types.OAuth2AccessTokenConnection{ + Resolver: r, + ParentID: obj.ID, + }, nil + } + + pageOrderBy := page.OrderBy[coredata.OAuth2AccessTokenOrderField]{ + Field: coredata.OAuth2AccessTokenOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + + cursor := cursor.NewCursor(first, after, last, before, pageOrderBy) + + tokenPage, err := r.iam.OAuth2ServerService.ListAccessTokensByIdentityID(ctx, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list oauth2 access tokens", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewOAuth2AccessTokenConnection(tokenPage, r, obj.ID), nil +} + // InvitingOrganizations is the resolver for the invitingOrganizations field. func (r *identityResolver) InvitingOrganizations(ctx context.Context, obj *types.Identity) ([]*types.Organization, error) { if _, err := r.authorize(ctx, obj.ID, iam.ActionInvitationList, authz.WithSkipAssumptionCheck()); err != nil { diff --git a/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go b/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go new file mode 100644 index 000000000..c6a99c60b --- /dev/null +++ b/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go @@ -0,0 +1,121 @@ +package connect_v1 + +// This file will be automatically regenerated based on the schema, any resolver +// implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.90 + +import ( + "context" + "errors" + "strings" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/api/connect/v1/schema" + "go.probo.inc/probo/pkg/server/api/connect/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +// CreateOAuth2AccessToken is the resolver for the createOAuth2AccessToken field. +func (r *mutationResolver) CreateOAuth2AccessToken(ctx context.Context, input types.CreateOAuth2AccessTokenInput) (*types.CreateOAuth2AccessTokenPayload, error) { + identity := authn.IdentityFromContext(ctx) + + if _, err := r.authorize(ctx, identity.ID, iam.ActionOAuth2AccessTokenCreate); err != nil { + return nil, err + } + + scopes, err := input.ParsedScopes() + if err != nil { + return nil, gqlutils.Invalid(ctx, err) + } + + tokenValue, accessToken, err := r.iam.OAuth2ServerService.CreateManualAccessToken( + ctx, + &oauth2.CreateManualAccessTokenRequest{ + IdentityID: identity.ID, + Name: strings.TrimSpace(input.Name), + ExpiresAt: input.ExpiresAt, + Scopes: scopes, + AllowedAPIScopes: r.iam.Authorizer.APIScopes(), + }, + ) + if err != nil { + if oauth2Err, ok := errors.AsType[*oauth2.OAuth2Error](err); ok { + return nil, gqlutils.Invalid(ctx, oauth2Err) + } + + r.logger.ErrorCtx(ctx, "cannot create oauth2 access token", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateOAuth2AccessTokenPayload{ + Oauth2AccessTokenEdge: types.NewOAuth2AccessTokenEdge( + accessToken, + coredata.OAuth2AccessTokenOrderFieldCreatedAt, + ), + Token: tokenValue, + }, nil +} + +// RevokeOAuth2AccessToken is the resolver for the revokeOAuth2AccessToken field. +func (r *mutationResolver) RevokeOAuth2AccessToken(ctx context.Context, input types.RevokeOAuth2AccessTokenInput) (*types.RevokeOAuth2AccessTokenPayload, error) { + if _, err := r.authorize(ctx, input.Oauth2AccessTokenID, iam.ActionOAuth2AccessTokenDelete); err != nil { + return nil, err + } + + if err := r.iam.OAuth2ServerService.RevokeAccessToken(ctx, input.Oauth2AccessTokenID); err != nil { + r.logger.ErrorCtx(ctx, "cannot revoke oauth2 access token", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.RevokeOAuth2AccessTokenPayload{ + Oauth2AccessTokenID: input.Oauth2AccessTokenID, + }, nil +} + +// Permission is the resolver for the permission field. +func (r *oAuth2AccessTokenResolver) Permission(ctx context.Context, obj *types.OAuth2AccessToken, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *oAuth2AccessTokenConnectionResolver) TotalCount(ctx context.Context, obj *types.OAuth2AccessTokenConnection) (*int, error) { + switch obj.Resolver.(type) { + case *identityResolver: + if _, err := r.authorize(ctx, obj.ParentID, iam.ActionOAuth2AccessTokenList); err != nil { + return nil, err + } + + count, err := r.iam.OAuth2ServerService.CountAccessTokensByIdentityID(ctx, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count oauth2 access tokens", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &count, nil + } + + r.logger.ErrorCtx(ctx, "unsupported resolver", log.Any("resolver", obj.Resolver)) + + return nil, gqlutils.Internal(ctx) +} + +// OAuth2AccessToken returns schema.OAuth2AccessTokenResolver implementation. +func (r *Resolver) OAuth2AccessToken() schema.OAuth2AccessTokenResolver { + return &oAuth2AccessTokenResolver{r} +} + +// OAuth2AccessTokenConnection returns schema.OAuth2AccessTokenConnectionResolver implementation. +func (r *Resolver) OAuth2AccessTokenConnection() schema.OAuth2AccessTokenConnectionResolver { + return &oAuth2AccessTokenConnectionResolver{r} +} + +type oAuth2AccessTokenResolver struct{ *Resolver } +type oAuth2AccessTokenConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/connect/v1/types/oauth2_access_token.go b/pkg/server/api/connect/v1/types/oauth2_access_token.go new file mode 100644 index 000000000..72cfcaff8 --- /dev/null +++ b/pkg/server/api/connect/v1/types/oauth2_access_token.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "errors" + "time" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + CreateOAuth2AccessTokenInput struct { + Name string `json:"name"` + ExpiresAt time.Time `json:"expiresAt"` + Scopes []string `json:"scopes"` + } + + OAuth2AccessTokenConnection struct { + TotalCount int + Edges []*OAuth2AccessTokenEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + } +) + +func (in *CreateOAuth2AccessTokenInput) ParsedScopes() (coredata.OAuth2Scopes, error) { + if len(in.Scopes) == 0 { + return nil, errors.New("scopes are required") + } + + scopes := make(coredata.OAuth2Scopes, len(in.Scopes)) + for i, scopeString := range in.Scopes { + scopes[i] = coredata.OAuth2Scope(scopeString) + } + + return scopes, nil +} + +func NewOAuth2AccessTokenConnection( + p *page.Page[*coredata.OAuth2AccessToken, coredata.OAuth2AccessTokenOrderField], + resolver any, + parentID gid.GID, +) *OAuth2AccessTokenConnection { + edges := make([]*OAuth2AccessTokenEdge, len(p.Data)) + for i, token := range p.Data { + edges[i] = NewOAuth2AccessTokenEdge(token, p.Cursor.OrderBy.Field) + } + + return &OAuth2AccessTokenConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: resolver, + ParentID: parentID, + } +} + +func NewOAuth2AccessTokenEdge( + token *coredata.OAuth2AccessToken, + orderField coredata.OAuth2AccessTokenOrderField, +) *OAuth2AccessTokenEdge { + return &OAuth2AccessTokenEdge{ + Node: NewOAuth2AccessToken(token), + Cursor: token.CursorKey(orderField), + } +} + +func NewOAuth2AccessToken(token *coredata.OAuth2AccessToken) *OAuth2AccessToken { + scopes := make([]string, len(token.Scopes)) + for i, scope := range token.Scopes { + scopes[i] = string(scope) + } + + return &OAuth2AccessToken{ + ID: token.ID, + Name: token.Name, + Scopes: scopes, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + } +}