From 26c500293231f1dbd2677b2a4460de6664535248 Mon Sep 17 00:00:00 2001
From: Ludovic Vielle
Date: Wed, 17 Jun 2026 16:07:50 +0200
Subject: [PATCH] Add identity-scoped OAuth token management
Let users create, list, and revoke manual bearer tokens from
/me/oauth-tokens, scoped to their identity rather than an
organization. Manual tokens store a null client_id and are
authorized with a self-manage IAM policy.
Wire Connect GraphQL on Identity (list, create, revoke), add
console UI with scoped create flow and credentials dialog, and
cover the flow in e2e tests. Fix list pagination ordering and
keep the Relay connection in sync after create.
Signed-off-by: Ludovic Vielle
---
.../src/pages/iam/auth/ConsentPage.tsx | 123 ++++++-
.../_components/ViewerDropdown.tsx | 10 +-
.../iam/oauthTokens/NewOAuthTokenPage.tsx | 301 ++++++++++++++++++
.../oauthTokens/NewOAuthTokenPageLoader.tsx | 25 ++
.../pages/iam/oauthTokens/OAuthTokensPage.tsx | 171 ++++++++++
.../iam/oauthTokens/OAuthTokensPageLoader.tsx | 49 +++
.../oauthTokens/OAuthTokensPageSkeleton.tsx | 30 ++
.../OAuthTokenCredentialsDialog.tsx | 76 +++++
.../oauthTokens/_components/OAuthTokenRow.tsx | 180 +++++++++++
.../oauthTokens/_components/scopeLabels.ts | 56 ++++
.../_components/ViewerMembershipDropdown.tsx | 12 +-
apps/console/src/routes.tsx | 12 +
contrib/claude/authorization.md | 47 +--
e2e/console/oauth2_access_token_test.go | 173 ++++++++++
pkg/coredata/migrations/20260617T120001Z.sql | 19 ++
pkg/coredata/migrations/20260618T120000Z.sql | 16 +
pkg/coredata/oauth2_access_token.go | 180 ++++++++++-
.../oauth2_access_token_order_field.go | 78 +++++
pkg/iam/authorizer_batch_test.go | 4 +-
pkg/iam/authorizer_unit_test.go | 4 +-
pkg/iam/iam_actions.go | 6 +
pkg/iam/iam_policies.go | 16 +
pkg/iam/oauth2/service.go | 200 +++++++++++-
pkg/iam/oauth2_scopes.go | 2 +
pkg/iam/policy_set.go | 1 +
pkg/server/api/connect/v1/base_resolvers.go | 22 ++
.../api/connect/v1/graphql/base.graphql | 3 +
.../api/connect/v1/graphql/identity.graphql | 10 +
.../v1/graphql/oauth2_access_token.graphql | 58 ++++
.../api/connect/v1/identity_resolvers.go | 30 ++
.../v1/oauth2_access_token_resolvers.go | 121 +++++++
.../connect/v1/types/oauth2_access_token.go | 98 ++++++
32 files changed, 2081 insertions(+), 52 deletions(-)
create mode 100644 apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPage.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/NewOAuthTokenPageLoader.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/OAuthTokensPage.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/OAuthTokensPageLoader.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/OAuthTokensPageSkeleton.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenCredentialsDialog.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/_components/OAuthTokenRow.tsx
create mode 100644 apps/console/src/pages/iam/oauthTokens/_components/scopeLabels.ts
create mode 100644 e2e/console/oauth2_access_token_test.go
create mode 100644 pkg/coredata/migrations/20260617T120001Z.sql
create mode 100644 pkg/coredata/migrations/20260618T120000Z.sql
create mode 100644 pkg/coredata/oauth2_access_token_order_field.go
create mode 100644 pkg/server/api/connect/v1/graphql/oauth2_access_token.graphql
create mode 100644 pkg/server/api/connect/v1/oauth2_access_token_resolvers.go
create mode 100644 pkg/server/api/connect/v1/types/oauth2_access_token.go
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}
+
+
+
+ {props.scopes.map(scope => (
+
+ ))}
+
+
+ );
}
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: {
-