From 39af2c8f673ca821efeb6a617d862f1da9fec2d5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 26 Jun 2026 10:25:21 +0530 Subject: [PATCH 1/3] feat(oauth2): per-project permission selection on the consent screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users approve OAuth2 access at the granularity the new console RAR model expects: identity + account-tier scopes shown for context, and the project-tier permissions from `authorization_details` chosen individually. - New `oauth2-authorization-details.ts` helper: parse the grant's RAR entries, build the action catalog from the project/organization scope endpoints, and serialize the user's selection back as downscope-only authorization_details. - New `oauth2-scope-picker.svelte`: actions grouped by category in accordions, search, per-category select-all/indeterminate, and a global select/deselect — built to scale to the full ~80-90 action catalog. - `consent-card.svelte`: render one bordered picker section per requested project (with its resource context), keep base scopes read-only, and pass the consented subset to `oauth2.approve`. - `+layout.svelte`: let a tall consent card scroll instead of overflowing the fixed shell; the picker list is also internally bounded so the actions stay visible. - gitignore the local `.playwright-mcp/` screenshot scratch dir. --- .gitignore | 1 + .../helpers/oauth2-authorization-details.ts | 202 ++++++++++++++++++ .../(public)/oauth2/consent-card.svelte | 192 ++++++++++++++--- .../oauth2/oauth2-scope-picker.svelte | 201 +++++++++++++++++ 4 files changed, 567 insertions(+), 29 deletions(-) create mode 100644 src/lib/helpers/oauth2-authorization-details.ts create mode 100644 src/routes/(public)/oauth2/oauth2-scope-picker.svelte diff --git a/.gitignore b/.gitignore index acc78571f0..69a75d2ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules !.env.example test-results/ playwright-report/ +.playwright-mcp/ vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/src/lib/helpers/oauth2-authorization-details.ts b/src/lib/helpers/oauth2-authorization-details.ts new file mode 100644 index 0000000000..e18cb71ac1 --- /dev/null +++ b/src/lib/helpers/oauth2-authorization-details.ts @@ -0,0 +1,202 @@ +import { sdk } from '$lib/stores/sdk'; + +/** + * RFC 9396 Rich Authorization Requests (RAR) helpers for the OAuth2 consent + * screen. + * + * The console OAuth2 server keeps a small set of identity scopes on the + * standard `scope` parameter (`openid`, `profile`, `email`, `account.admin`) + * and moves every granular permission into `authorization_details` entries of + * type `appwrite_console`. The consent screen lets the user approve a *subset* + * of the requested actions; the approved set is sent back to the approve + * endpoint and replaces what the client originally requested. We only ever + * downscope — actions the client did not request are never offered. + */ + +/** The single RAR `type` the console OAuth2 server understands. */ +export const APPWRITE_CONSOLE_RAR_TYPE = 'appwrite_console'; + +/** Fields an `appwrite_console` entry may carry, besides `type`/`actions`. */ +const RESOURCE_FIELDS = ['projectIds', 'organizationIds', 'locations'] as const; + +export interface AuthorizationDetail { + type: string; + actions?: string[]; + projectIds?: string[]; + organizationIds?: string[]; + locations?: string[]; + [key: string]: unknown; +} + +export interface ActionDescriptor { + /** Scope id, e.g. `databases.write`. */ + action: string; + /** Human title derived from the scope id. */ + title: string; + /** Catalog description, or a generic fallback. */ + description: string; + /** Catalog category used for grouping, e.g. `Databases`. */ + category: string; + deprecated: boolean; +} + +export type ActionCatalog = Map>; + +/** + * Account-level scopes are not exposed by the project/organization scope + * endpoints, so we describe them locally. They are few and stable. + */ +const ACCOUNT_SCOPE_CATALOG: ActionCatalog = new Map([ + [ + 'account', + { + category: 'Account', + description: 'Manage your account, organizations, sessions, tokens, and billing.', + deprecated: false + } + ], + [ + 'teams.read', + { + category: 'Account', + description: "Read your account's organizations.", + deprecated: false + } + ], + [ + 'teams.write', + { + category: 'Account', + description: "Create, update, and delete your account's organizations and memberships.", + deprecated: false + } + ] +]); + +const UNCATEGORIZED = 'Other'; + +/** Parse the grant's `authorizationDetails` JSON string into entries. */ +export function parseAuthorizationDetails(raw: string | null | undefined): AuthorizationDetail[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as AuthorizationDetail[]) : []; + } catch { + return []; + } +} + +export function isAppwriteConsoleDetail(detail: AuthorizationDetail): boolean { + return detail.type === APPWRITE_CONSOLE_RAR_TYPE; +} + +/** Distinct, requested actions for a single `appwrite_console` entry. */ +export function actionsOf(detail: AuthorizationDetail): string[] { + if (!Array.isArray(detail.actions)) return []; + const seen = new Set(); + const out: string[] = []; + for (const action of detail.actions) { + if (typeof action === 'string' && action !== '' && !seen.has(action)) { + seen.add(action); + out.push(action); + } + } + return out; +} + +/** Turn `databases.write` into `Databases write` for a readable title. */ +export function titleizeAction(action: string): string { + const cleaned = action.replace(/[._:-]+/g, ' ').trim(); + if (!cleaned) return action; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +/** + * Build the action catalog from the live project + organization scope + * endpoints, falling back to the local account-scope map. The consent screen + * always runs for a signed-in console user, so these admin-scoped endpoints are + * available; if they fail we still describe actions via the fallback + titleize + * so the screen never breaks. + */ +export async function loadActionCatalog(): Promise { + const catalog: ActionCatalog = new Map(ACCOUNT_SCOPE_CATALOG); + + const results = await Promise.allSettled([ + sdk.forConsole.console.listProjectScopes(), + sdk.forConsole.console.listOrganizationScopes() + ]); + + for (const result of results) { + if (result.status !== 'fulfilled') continue; + for (const scope of result.value.scopes) { + // Project + organization scopes can overlap (e.g. `teams.read`); + // first writer wins, which keeps the project description. + if (!catalog.has(scope.$id)) { + catalog.set(scope.$id, { + category: scope.category || UNCATEGORIZED, + description: scope.description, + deprecated: scope.deprecated + }); + } + } + } + + return catalog; +} + +/** Resolve a single action to a descriptor, falling back to a titleized one. */ +export function describeAction(action: string, catalog: ActionCatalog): ActionDescriptor { + const entry = catalog.get(action); + return { + action, + title: titleizeAction(action), + description: entry?.description ?? `Access to ${action}.`, + category: entry?.category ?? UNCATEGORIZED, + deprecated: entry?.deprecated ?? false + }; +} + +/** + * Rebuild the `authorization_details` array from the user's selection, keyed by + * entry index. Only `appwrite_console` entries are filtered to the selected + * actions; entries that end up with no actions are dropped, and entries of + * other types pass through untouched. Returns a JSON string ready for the + * approve endpoint, or `'[]'` when nothing remains (an empty string would tell + * the server to keep the originally requested details, which is never what the + * consent screen wants). + */ +export function serializeGrantedDetails( + requested: AuthorizationDetail[], + selectedByIndex: Record> +): string { + const granted: AuthorizationDetail[] = []; + + requested.forEach((detail, index) => { + if (!isAppwriteConsoleDetail(detail)) { + granted.push(detail); + return; + } + + const requestedActions = actionsOf(detail); + const selected = selectedByIndex[index] ?? {}; + const keptActions = requestedActions.filter((action) => selected[action]); + + if (keptActions.length === 0) { + return; + } + + const rebuilt: AuthorizationDetail = { + type: APPWRITE_CONSOLE_RAR_TYPE, + actions: keptActions + }; + for (const field of RESOURCE_FIELDS) { + const value = detail[field]; + if (Array.isArray(value) && value.length > 0) { + rebuilt[field] = value as string[]; + } + } + granted.push(rebuilt); + }); + + return JSON.stringify(granted); +} diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index 1738a3d156..4e7aafcab0 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -7,24 +7,19 @@ import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { groupConsentScopes } from '$lib/helpers/oauth2-scopes'; + import { + actionsOf, + isAppwriteConsoleDetail, + loadActionCatalog, + parseAuthorizationDetails, + serializeGrantedDetails, + type ActionCatalog, + type AuthorizationDetail + } from '$lib/helpers/oauth2-authorization-details'; + import OAuth2ScopePicker from './oauth2-scope-picker.svelte'; export type OAuth2Flow = 'authorization' | 'device'; - interface AuthorizationDetail { - type: string; - [key: string]: unknown; - } - - function parseAuthorizationDetails(raw: string): AuthorizationDetail[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as AuthorizationDetail[]) : []; - } catch { - return []; - } - } - function hostnameOf(uri: string): string | null { try { return new URL(uri).hostname; @@ -48,13 +43,85 @@ let rejecting = $state(false); let busy = $derived(approving || rejecting); + // Requested scopes (identity + account/console-tier) are shown read-only, + // grouped by resource. The granular per-project permissions live in + // authorization_details and are chosen individually below. const scopes = $derived(groupConsentScopes(grant.scopes ?? [])); - const details = $derived(parseAuthorizationDetails(grant.authorizationDetails ?? '')); + const details = $derived( + parseAuthorizationDetails(grant.authorizationDetails) + ); + const hasConsoleActions = $derived( + details.some((detail) => isAppwriteConsoleDetail(detail) && actionsOf(detail).length > 0) + ); const redirectHost = $derived(hostnameOf(grant.redirectUri)); const appInitial = $derived((app.name || '?').charAt(0).toUpperCase()); const accountInitial = $derived((accountLabel || '?').charAt(0).toUpperCase()); + // Lazily loaded scope metadata (category + descriptions) for grouping the + // requested actions. Never blocks approve/reject — the picker just shows a + // spinner until it resolves. + let catalog = $state(null); + $effect(() => { + if (!hasConsoleActions || catalog) return; + let cancelled = false; + void loadActionCatalog().then((loaded) => { + if (!cancelled) catalog = loaded; + }); + return () => { + cancelled = true; + }; + }); + + // Per-entry action selection: entryIndex -> { action -> granted }. Requested + // actions start selected (the user opts OUT of what they don't want). Reset + // whenever the grant changes so a stale selection can't leak across requests. + let selection = $state>>({}); + $effect(() => { + // Re-init keyed on the grant id + its requested details. + grant.$id; + const next: Record> = {}; + details.forEach((detail, index) => { + if (!isAppwriteConsoleDetail(detail)) return; + const actionMap: Record = {}; + for (const action of actionsOf(detail)) { + actionMap[action] = true; + } + next[index] = actionMap; + }); + selection = next; + }); + + /** A short, human context line for an entry's resource scoping. */ + function detailContext(detail: AuthorizationDetail): string | null { + const parts: string[] = []; + const projects = detail.projectIds?.length ?? 0; + const orgs = detail.organizationIds?.length ?? 0; + if (projects > 0) parts.push(`${projects} project${projects === 1 ? '' : 's'}`); + if (orgs > 0) parts.push(`${orgs} organization${orgs === 1 ? '' : 's'}`); + if (parts.length === 0) return null; + return `Limited to ${parts.join(' and ')}`; + } + + function locationHosts(detail: AuthorizationDetail): string[] { + return (detail.locations ?? []) + .map((location) => hostnameOf(location)) + .filter((host): host is string => Boolean(host)); + } + + // Approving is meaningless if the request offered actions but the user + // unchecked every one and granted no identity scope either. + const grantedActionCount = $derived( + Object.values(selection).reduce( + (total, actionMap) => + total + Object.values(actionMap).filter((isGranted) => isGranted).length, + 0 + ) + ); + const nothingToGrant = $derived( + hasConsoleActions && grantedActionCount === 0 && (grant.scopes ?? []).length === 0 + ); + async function approve() { if (busy) return; // Pin the request we're acting on. If the parent swaps in a different @@ -65,8 +132,16 @@ error = null; approving = true; try { + // When the request carries appwrite_console actions, send back the + // exact subset the user consented to (downscope-only — the picker + // never offers actions the client didn't request). Otherwise omit + // it and keep whatever the client originally requested. + const authorizationDetails = hasConsoleActions + ? serializeGrantedDetails(details, selection) + : undefined; const result = await sdk.forConsole.oauth2.approve({ - grantId + grantId, + authorizationDetails }); if (grant.$id !== grantId) return; trackEvent(Submit.AccountOAuth2ConsentApprove, { @@ -176,7 +251,7 @@ {#if scopes.groups.length > 0}
- +
    {#each scopes.groups as group (group.resource)}
  • @@ -197,15 +272,46 @@ {/if} {#if details.length > 0} - - - Also requested - -
    - {#each details as detail, i (`${detail.type}-${i}`)} - {detail.type} - {/each} -
    + + + Choose what to allow + + {app.name} requested these permissions. Grant only the ones you're comfortable + with — you can turn any of them off. + + + + {#each details as detail, index (`${detail.type}-${index}`)} + {#if isAppwriteConsoleDetail(detail) && actionsOf(detail).length > 0} + {@const context = detailContext(detail)} + {@const hosts = locationHosts(detail)} +
    + {#if context || hosts.length > 0} +
    + {#if context}{context}{/if} + {#if hosts.length > 0}{hosts.join(', ')}{/if} +
    + {/if} + {#if catalog} + + {:else} +
    + +
    + {/if} +
    + {:else} + +
    + {detail.type} +
    + {/if} + {/each}
    {/if} @@ -220,7 +326,7 @@
    - + + + + + + {#if actions.length > 6} + + {/if} + + {#if categories.length === 0} + + No permissions match “{search}”. + + {:else} + +
    + + + {#each categories as category, index (category)} + {@const inCategory = actionsInCategory(category)} + {@const activeCount = inCategory.filter((d) => selected[d.action]).length} + onCategoryChange(event, category)}> + + {#each inCategory as action (action.action)} + + + + + + + {/each} + + + {/each} + +
    + {/if} + + + From 13416cd6a7ab8f162d7433b78c13eaf34ebf2149 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 26 Jun 2026 11:43:19 +0530 Subject: [PATCH 2/3] feat(oauth2): selectable console scopes + named per-project sections on consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the consent screen for the per-project RAR model: - Console access is now individually selectable: each account-tier scope (teams.*, projects.*, organization.keys.*, domains.*) renders as a checkbox with a real description; identity and account.admin stay read-only. The chosen subset is sent as a downscoped `scope` to oauth2.approve (RFC 6749 §3.3) — bumps the @appwrite.io/console SDK to a build that exposes the param. - Project access shows the actual project name + avatar + region per RAR entry (resolved client-side via the user's orgs → listProjects), so the user can tell which project each grant refers to. Unresolved ids fall back to the raw id. Multi-project entries list all names (truncated past five). - Sync the RAR type to the renamed `appwrite_project` (was `appwrite_console`) and add splitSelectableScopes + project-name resolution helpers. Authorize stays enabled whenever anything is granted (incl. identity-only). --- bun.lock | 4 +- package.json | 2 +- .../helpers/oauth2-authorization-details.ts | 106 +++++- src/lib/helpers/oauth2-scopes.ts | 94 ++++++ .../(public)/oauth2/consent-card.svelte | 313 +++++++++++------- 5 files changed, 376 insertions(+), 143 deletions(-) diff --git a/bun.lock b/bun.lock index 2bf9dabc9d..0ce5ad51ea 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@appwrite/console", "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@4851d03", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@88cc66d", "@appwrite.io/pink-icons": "0.25.0", "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", "@appwrite.io/pink-legacy": "^1.0.3", @@ -126,7 +126,7 @@ "@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="], - "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@4851d03", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-Zhisu5a5Y6yS4MTk3G3ZA205rELJ9BSzp65MKqVmim3ii/tBSoAKiVnhBPDRR5V8Kg7FLGFW+D3dKPMspFHC/g=="], + "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@88cc66d", { "dependencies": { "json-bigint": "1.0.0" } }], "@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="], diff --git a/package.json b/package.json index ab65c92354..f8fbe43027 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@4851d03", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@88cc66d", "@appwrite.io/pink-icons": "0.25.0", "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", "@appwrite.io/pink-legacy": "^1.0.3", diff --git a/src/lib/helpers/oauth2-authorization-details.ts b/src/lib/helpers/oauth2-authorization-details.ts index e18cb71ac1..f8be6dfa68 100644 --- a/src/lib/helpers/oauth2-authorization-details.ts +++ b/src/lib/helpers/oauth2-authorization-details.ts @@ -1,22 +1,28 @@ +import { Query } from '@appwrite.io/console'; import { sdk } from '$lib/stores/sdk'; +import { getTeamOrOrganizationList } from '$lib/stores/organization'; /** * RFC 9396 Rich Authorization Requests (RAR) helpers for the OAuth2 consent * screen. * - * The console OAuth2 server keeps a small set of identity scopes on the - * standard `scope` parameter (`openid`, `profile`, `email`, `account.admin`) - * and moves every granular permission into `authorization_details` entries of - * type `appwrite_console`. The consent screen lets the user approve a *subset* - * of the requested actions; the approved set is sent back to the approve - * endpoint and replaces what the client originally requested. We only ever - * downscope — actions the client did not request are never offered. + * The console OAuth2 server keeps identity + account/console-tier scopes on the + * standard `scope` parameter (`openid`, `profile`, `email`, `account.admin`, + * plus `teams.*`, `projects.*`, …) and moves every project-tier permission into + * `authorization_details` entries of type `appwrite_project`, each bound to the + * concrete `projectIds` it applies to. The consent screen lets the user approve + * a *subset* of the requested actions; the approved set is sent back to the + * approve endpoint and replaces what the client originally requested. We only + * ever downscope — actions the client did not request are never offered. */ /** The single RAR `type` the console OAuth2 server understands. */ -export const APPWRITE_CONSOLE_RAR_TYPE = 'appwrite_console'; +export const APPWRITE_PROJECT_RAR_TYPE = 'appwrite_project'; -/** Fields an `appwrite_console` entry may carry, besides `type`/`actions`. */ +/** The reserved internal project id; never a valid RAR target. */ +const RESERVED_CONSOLE_PROJECT = 'console'; + +/** Fields an `appwrite_project` entry may carry, besides `type`/`actions`. */ const RESOURCE_FIELDS = ['projectIds', 'organizationIds', 'locations'] as const; export interface AuthorizationDetail { @@ -86,11 +92,11 @@ export function parseAuthorizationDetails(raw: string | null | undefined): Autho } } -export function isAppwriteConsoleDetail(detail: AuthorizationDetail): boolean { - return detail.type === APPWRITE_CONSOLE_RAR_TYPE; +export function isAppwriteProjectDetail(detail: AuthorizationDetail): boolean { + return detail.type === APPWRITE_PROJECT_RAR_TYPE; } -/** Distinct, requested actions for a single `appwrite_console` entry. */ +/** Distinct, requested actions for a single `appwrite_project` entry. */ export function actionsOf(detail: AuthorizationDetail): string[] { if (!Array.isArray(detail.actions)) return []; const seen = new Set(); @@ -172,7 +178,7 @@ export function serializeGrantedDetails( const granted: AuthorizationDetail[] = []; requested.forEach((detail, index) => { - if (!isAppwriteConsoleDetail(detail)) { + if (!isAppwriteProjectDetail(detail)) { granted.push(detail); return; } @@ -186,7 +192,7 @@ export function serializeGrantedDetails( } const rebuilt: AuthorizationDetail = { - type: APPWRITE_CONSOLE_RAR_TYPE, + type: APPWRITE_PROJECT_RAR_TYPE, actions: keptActions }; for (const field of RESOURCE_FIELDS) { @@ -200,3 +206,75 @@ export function serializeGrantedDetails( return JSON.stringify(granted); } + +export interface ResolvedProject { + id: string; + /** Project name, or the raw id when it couldn't be resolved. */ + name: string; + region?: string; + /** True when a real project name was found; false means we fell back to id. */ + resolved: boolean; +} + +export type ProjectNameMap = Map; + +/** Unique, real project ids referenced across all appwrite_project entries. */ +export function collectProjectIds(details: AuthorizationDetail[]): string[] { + const seen = new Set(); + for (const detail of details) { + if (!isAppwriteProjectDetail(detail)) continue; + for (const id of detail.projectIds ?? []) { + if (typeof id === 'string' && id !== '' && id !== RESERVED_CONSOLE_PROJECT) { + seen.add(id); + } + } + } + return [...seen]; +} + +/** + * Best-effort resolution of project ids to their display names. A bare project + * id can't be fetched directly — projects are reachable only through their + * owning organization — so we list the user's organizations and ask each for + * the requested ids. Anything we can't find (not owned, or in a non-primary + * region the console endpoint doesn't see) falls back to showing the raw id. + * Never throws: on any failure the caller still gets an id-only map. + */ +export async function resolveProjectNames(ids: string[]): Promise { + const map: ProjectNameMap = new Map(); + for (const id of ids) { + map.set(id, { id, name: id, resolved: false }); + } + if (ids.length === 0) return map; + + try { + const orgs = await getTeamOrOrganizationList([Query.limit(100)]); + const results = await Promise.allSettled( + orgs.teams.map((org) => + sdk.forConsole.organization(org.$id).listProjects({ + queries: [ + Query.equal('$id', ids), + Query.select(['$id', 'name', 'region', 'teamId']), + Query.limit(ids.length) + ] + }) + ) + ); + + for (const result of results) { + if (result.status !== 'fulfilled') continue; + for (const project of result.value.projects) { + map.set(project.$id, { + id: project.$id, + name: project.name, + region: project.region, + resolved: true + }); + } + } + } catch { + // Keep the id-only fallback map. + } + + return map; +} diff --git a/src/lib/helpers/oauth2-scopes.ts b/src/lib/helpers/oauth2-scopes.ts index 373d1da5dd..cea1196af0 100644 --- a/src/lib/helpers/oauth2-scopes.ts +++ b/src/lib/helpers/oauth2-scopes.ts @@ -2,6 +2,10 @@ import type { ComponentType } from 'svelte'; import { IconShieldCheck, IconUser, + IconUserCircle, + IconUserGroup, + IconViewGrid, + IconGlobe, IconMail, IconIdentification, IconKey @@ -43,6 +47,53 @@ const BUILTIN_SCOPES: Record> = { title: ACCOUNT_ADMIN_DESCRIPTOR.title, description: ACCOUNT_ADMIN_DESCRIPTOR.description, icon: ACCOUNT_ADMIN_DESCRIPTOR.icon + }, + // Account/console-tier scopes (carried on the `scope` param). These read as + // console-level actions — managing your account, organizations, projects. + account: { + title: 'Manage your account', + description: 'Manage your account, sessions, tokens, and billing.', + icon: IconUserCircle + }, + 'teams.read': { + title: 'View your organizations', + description: 'Read the organizations you belong to.', + icon: IconUserGroup + }, + 'teams.write': { + title: 'Manage your organizations', + description: 'Create, update, and delete your organizations and their members.', + icon: IconUserGroup + }, + 'projects.read': { + title: 'View your projects', + description: 'List the projects in your organizations.', + icon: IconViewGrid + }, + 'projects.write': { + title: 'Manage your projects', + description: 'Create, update, and delete projects in your organizations.', + icon: IconViewGrid + }, + 'organization.keys.read': { + title: 'View organization API keys', + description: "Read your organizations' API keys.", + icon: IconKey + }, + 'organization.keys.write': { + title: 'Manage organization API keys', + description: "Create, update, and delete your organizations' API keys.", + icon: IconKey + }, + 'domains.read': { + title: 'View organization domains', + description: "Read your organizations' domains.", + icon: IconGlobe + }, + 'domains.write': { + title: 'Manage organization domains', + description: "Create, update, and delete your organizations' domains.", + icon: IconGlobe } }; @@ -99,6 +150,49 @@ export function describeConsentScopes(scopes: string[]): ScopeDescriptor[] { return described; } +export interface SelectableConsentScopes { + /** `account.admin` full-access descriptor, when requested (read-only). */ + admin: ScopeDescriptor | null; + /** OIDC identity scopes (profile/email), always granted (read-only). */ + identity: ScopeDescriptor[]; + /** Account/console-tier scopes the user can individually toggle. */ + selectable: ScopeDescriptor[]; +} + +/** + * Split the requested `scope`-param scopes for the consent screen into the + * read-only rows (full-access `account.admin`, identity scopes) and the + * individually-selectable account/console-tier scopes. Anything that isn't + * `openid`, an identity scope, or `account.admin` is treated as a selectable + * console-tier scope — the `scope` param only ever carries console-tier scopes + * (project-tier permissions travel in `authorization_details`). Request order + * is preserved. + */ +export function splitSelectableScopes(scopes: string[]): SelectableConsentScopes { + const requested = new Set(scopes); + const admin = requested.has(ACCOUNT_ADMIN_SCOPE) ? ACCOUNT_ADMIN_DESCRIPTOR : null; + const identity = CONSENT_IDENTITY_SCOPES.filter((scope) => requested.has(scope)).map( + describeScope + ); + + const seen = new Set(); + const selectable: ScopeDescriptor[] = []; + for (const scope of scopes) { + if ( + scope === 'openid' || + scope === ACCOUNT_ADMIN_SCOPE || + (CONSENT_IDENTITY_SCOPES as readonly string[]).includes(scope) || + seen.has(scope) + ) { + continue; + } + seen.add(scope); + selectable.push(describeScope(scope)); + } + + return { admin, identity, selectable }; +} + export interface ScopeAction { /** Full scope id, e.g. `teams.write`. */ id: string; diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index 4e7aafcab0..266ba42f1d 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -1,25 +1,40 @@