From 8db59c814ba47c47eee921db5c4e1ef4fd5e5a45 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 1 Jul 2026 16:23:59 +0530 Subject: [PATCH 1/2] feat(oauth2): redesign cloud consent screen with RAR resource binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the OAuth2 consent screen for the cloud RAR (RFC 9396) flow. Scopes now carry every requested privilege; authorization_details binds the project/organization tiers to concrete resources, and the resource binding is the only thing the user narrows on the screen. UI/UX: - Grouped surface panels, app-to-account handshake header, and a clearer permission list (replaces the old scope picker). - Supabase-style permission rows: resource-name titles, an access chip (READ / WRITE / READ + WRITE, amber when write is involved), and a one-line description per resource. Rows ordered by access strength. - Console-authored copy for every scope Cloud exposes (drops the terse live scope catalog + its network calls); sensible fallback for unknown scopes. Raw scope tokens are no longer shown inline — a hover copy button yields the exact token. - Resource selector: segmented All/Specific control, searchable chips, clearer Change/Done toggle, and an amber "needs selection" state that blocks Authorize until each requested tier is bound. Delete the standalone oauth2-scope-picker in favour of the read-only permission summary + resource selector. --- .../helpers/oauth2-authorization-details.ts | 354 +++---- src/lib/helpers/oauth2-scopes.ts | 696 +++++++++---- .../(public)/oauth2/consent-card.svelte | 985 +++++++++++------- .../oauth2/oauth2-scope-picker.svelte | 201 ---- .../(public)/oauth2/resource-selector.svelte | 452 ++++++++ 5 files changed, 1708 insertions(+), 980 deletions(-) delete mode 100644 src/routes/(public)/oauth2/oauth2-scope-picker.svelte create mode 100644 src/routes/(public)/oauth2/resource-selector.svelte diff --git a/src/lib/helpers/oauth2-authorization-details.ts b/src/lib/helpers/oauth2-authorization-details.ts index f8be6dfa68..d26f051797 100644 --- a/src/lib/helpers/oauth2-authorization-details.ts +++ b/src/lib/helpers/oauth2-authorization-details.ts @@ -3,84 +3,38 @@ import { sdk } from '$lib/stores/sdk'; import { getTeamOrOrganizationList } from '$lib/stores/organization'; /** - * RFC 9396 Rich Authorization Requests (RAR) helpers for the OAuth2 consent - * screen. + * RFC 9396 Rich Authorization Requests (RAR) helpers for the console OAuth2 + * consent screen (contract v2). * - * 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. + * In v2 the `scope` parameter carries every privilege the client requests (see + * `oauth2-scopes.ts`). `authorization_details` only binds those privileges to + * concrete resources, as entries of two types: + * + * { "type": "project", "identifiers": ["", …] | ["*"] } + * { "type": "organization", "identifiers": ["", …] | ["*"] } + * + * `identifiers` is the only field besides `type`; `*` is a wildcard that binds + * the tier's scopes to every project / organization the user owns (present and + * future). The consent screen lets the user narrow the requested identifiers to + * a subset and sends the result back to the approve endpoint, which replaces the + * client's requested details. */ -/** The single RAR `type` the console OAuth2 server understands. */ -export const APPWRITE_PROJECT_RAR_TYPE = 'appwrite_project'; +export const PROJECT_RAR_TYPE = 'project'; +export const ORGANIZATION_RAR_TYPE = 'organization'; + +/** Wildcard identifier — binds a tier's scopes to every owned resource. */ +export const WILDCARD_IDENTIFIER = '*'; /** 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 { type: string; - actions?: string[]; - projectIds?: string[]; - organizationIds?: string[]; - locations?: string[]; + identifiers?: 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 []; @@ -92,144 +46,92 @@ export function parseAuthorizationDetails(raw: string | null | undefined): Autho } } -export function isAppwriteProjectDetail(detail: AuthorizationDetail): boolean { - return detail.type === APPWRITE_PROJECT_RAR_TYPE; -} - -/** Distinct, requested actions for a single `appwrite_project` entry. */ -export function actionsOf(detail: AuthorizationDetail): string[] { - if (!Array.isArray(detail.actions)) return []; +/** Distinct, non-empty identifiers for a single entry. */ +export function identifiersOf(detail: AuthorizationDetail): string[] { + if (!Array.isArray(detail.identifiers)) 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); + for (const identifier of detail.identifiers) { + if (typeof identifier === 'string' && identifier !== '' && !seen.has(identifier)) { + seen.add(identifier); + out.push(identifier); } } 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. + * Union of identifiers across every entry of the given type, request order + * preserved. Keeps the `*` wildcard; drops the reserved `console` project id + * from `project` entries (the server rejects it anyway). */ -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 - }); - } +export function mergeIdentifiers(details: AuthorizationDetail[], type: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const detail of details) { + if (detail.type !== type) continue; + for (const identifier of identifiersOf(detail)) { + if (type === PROJECT_RAR_TYPE && identifier === RESERVED_CONSOLE_PROJECT) continue; + if (seen.has(identifier)) continue; + seen.add(identifier); + out.push(identifier); } } + return out; +} - return catalog; +export function hasWildcard(identifiers: string[]): boolean { + return identifiers.includes(WILDCARD_IDENTIFIER); } -/** 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 - }; +/** Concrete (non-wildcard) ids among a set of identifiers. */ +export function concreteIdentifiers(identifiers: string[]): string[] { + return identifiers.filter((identifier) => identifier !== WILDCARD_IDENTIFIER); } /** - * 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). + * Rebuild the `authorization_details` array from the user's granted selection. + * Emits a `project` / `organization` entry only when that tier ends up bound to + * at least one identifier. 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 (!isAppwriteProjectDetail(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_PROJECT_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); +export function serializeGrantedDetails(granted: { + project?: string[]; + organization?: string[]; +}): string { + const out: AuthorizationDetail[] = []; + if (granted.project && granted.project.length > 0) { + out.push({ type: PROJECT_RAR_TYPE, identifiers: granted.project }); + } + if (granted.organization && granted.organization.length > 0) { + out.push({ type: ORGANIZATION_RAR_TYPE, identifiers: granted.organization }); + } + return JSON.stringify(out); } -export interface ResolvedProject { +/* -------------------------------------------------------------------------- */ +/* Resource name resolution — turn identifiers into display names */ +/* -------------------------------------------------------------------------- */ + +export interface ResolvedResource { id: string; - /** Project name, or the raw id when it couldn't be resolved. */ + /** Display 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. */ + /** True when a real name was found; false means we fell back to the id. */ resolved: boolean; } -export type ProjectNameMap = Map; +export type ResourceNameMap = 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); - } - } +function idOnlyMap(ids: string[]): ResourceNameMap { + const map: ResourceNameMap = new Map(); + for (const id of ids) { + map.set(id, { id, name: id, resolved: false }); } - return [...seen]; + return map; } /** @@ -240,11 +142,8 @@ export function collectProjectIds(details: AuthorizationDetail[]): string[] { * 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 }); - } +export async function resolveProjectNames(ids: string[]): Promise { + const map = idOnlyMap(ids); if (ids.length === 0) return map; try { @@ -278,3 +177,96 @@ export async function resolveProjectNames(ids: string[]): Promise { + const map = idOnlyMap(ids); + if (ids.length === 0) return map; + + try { + const orgs = await getTeamOrOrganizationList([Query.limit(100)]); + for (const org of orgs.teams) { + if (map.has(org.$id)) { + map.set(org.$id, { id: org.$id, name: org.name, resolved: true }); + } + } + } catch { + // Keep the id-only fallback map. + } + + return map; +} + +/* -------------------------------------------------------------------------- */ +/* Type-to-search — narrowing a wildcard grant to specific resources */ +/* -------------------------------------------------------------------------- */ + +/** How many results a resource search returns. Keeps the dropdown bounded and + * sidesteps pagination — the user types to narrow rather than scrolling. */ +const SEARCH_LIMIT = 8; +/** Cap on organizations swept per project search, to bound the request fan-out. */ +const SEARCH_ORG_SCAN = 20; + +/** + * Search the user's projects by name for the resource picker. Projects are only + * reachable through their owning organization, so we sweep the user's orgs and + * ask each for name matches, merge, and cap. An empty term returns the first + * projects found (so the picker can show something before the user types). + * Never throws: returns an empty list on failure. + */ +export async function searchProjects(term: string): Promise { + try { + const orgs = await getTeamOrOrganizationList([Query.limit(SEARCH_ORG_SCAN)]); + const trimmed = term.trim(); + const results = await Promise.allSettled( + orgs.teams.map((org) => { + const queries = [ + Query.select(['$id', 'name', 'region', 'teamId']), + Query.limit(SEARCH_LIMIT) + ]; + if (trimmed !== '') { + queries.unshift(Query.startsWith('name', trimmed)); + } + return sdk.forConsole.organization(org.$id).listProjects({ queries }); + }) + ); + + const seen = new Set(); + const out: ResolvedResource[] = []; + for (const result of results) { + if (result.status !== 'fulfilled') continue; + for (const project of result.value.projects) { + if (project.$id === RESERVED_CONSOLE_PROJECT || seen.has(project.$id)) continue; + seen.add(project.$id); + out.push({ + id: project.$id, + name: project.name, + region: project.region, + resolved: true + }); + if (out.length >= SEARCH_LIMIT) return out; + } + } + return out; + } catch { + return []; + } +} + +/** + * Load the user's organizations as resources for the org picker. Organizations + * are few enough to load once and filter client-side, so this returns the full + * list; the picker searches within it. Never throws. + */ +export async function listOrganizationResources(): Promise { + try { + const orgs = await getTeamOrOrganizationList([Query.limit(100)]); + return orgs.teams.map((org) => ({ id: org.$id, name: org.name, resolved: true })); + } catch { + return []; + } +} diff --git a/src/lib/helpers/oauth2-scopes.ts b/src/lib/helpers/oauth2-scopes.ts index cea1196af0..e4d5c6e106 100644 --- a/src/lib/helpers/oauth2-scopes.ts +++ b/src/lib/helpers/oauth2-scopes.ts @@ -1,16 +1,41 @@ import type { ComponentType } from 'svelte'; import { - IconShieldCheck, + IconIdentification, IconUser, - IconUserCircle, - IconUserGroup, - IconViewGrid, - IconGlobe, IconMail, - IconIdentification, + IconDeviceMobile, + IconShieldCheck, IconKey } from '@appwrite.io/pink-icons-svelte'; +/** + * Scope helpers for the console OAuth2 consent screen (RAR contract v2). + * + * The `scope` parameter now carries *every* privilege the client is asking for. + * `authorization_details` only binds those privileges to concrete resources + * (see `oauth2-authorization-details.ts`). A requested scope reads as one of: + * + * - an OIDC identity scope: `openid`, `profile`, `email`, `phone` + * - `all` — console-wide full access (replaces the old `account.admin`) + * - `project:all` / `project:` — project-tier permissions + * - `organization:all` / `organization:` — organization-tier permissions + * + * The consent screen only ever downscopes: it offers the requested scopes and + * sends back the subset the user consented to. + */ + +/** Console-wide full-access scope; grants everything when present. */ +export const ALL_SCOPE = 'all'; +/** Grants every project-tier scope for the bound projects. */ +export const PROJECT_ALL_SCOPE = 'project:all'; +/** Grants every organization-tier scope for the bound organizations. */ +export const ORGANIZATION_ALL_SCOPE = 'organization:all'; +export const PROJECT_SCOPE_PREFIX = 'project:'; +export const ORGANIZATION_SCOPE_PREFIX = 'organization:'; + +/** OIDC identity scopes — always granted, never toggleable, order preserved. */ +const IDENTITY_SCOPES = ['openid', 'profile', 'email', 'phone'] as const; + export interface ScopeDescriptor { id: string; title: string; @@ -18,15 +43,11 @@ export interface ScopeDescriptor { icon: ComponentType; } -export const ACCOUNT_ADMIN_SCOPE = 'account.admin'; - -export const ACCOUNT_ADMIN_DESCRIPTOR: ScopeDescriptor = { - id: ACCOUNT_ADMIN_SCOPE, - title: 'Full access to your account', - description: 'Manage your organizations, projects, and all their resources on your behalf.', - icon: IconShieldCheck -}; - +/** + * Identity + full-access scopes are described locally: they're stable and are + * not returned by the project/organization scope endpoints. Everything else is + * a project/organization scope, described from the authored resource catalog below. + */ const BUILTIN_SCOPES: Record> = { openid: { title: 'Verify your identity', @@ -40,60 +61,18 @@ const BUILTIN_SCOPES: Record> = { }, email: { title: 'View your email address', - description: 'Read the email address associated with your account.', + description: "Read your account's email address.", icon: IconMail }, - [ACCOUNT_ADMIN_SCOPE]: { - 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 + phone: { + title: 'View your phone number', + description: "Read your account's phone number.", + icon: IconDeviceMobile }, - '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 + [ALL_SCOPE]: { + title: 'Full access to your account', + description: 'Manage all your organizations, projects, and their resources on your behalf.', + icon: IconShieldCheck } }; @@ -116,171 +95,492 @@ export function describeScope(scope: string): ScopeDescriptor { }; } -export function describeScopes(scopes: string[]): ScopeDescriptor[] { - return scopes.map(describeScope); +/** Per-tier (project / organization) requested scopes, prefix already stripped. */ +export interface TierScopes { + /** `:all` was requested — the tier's full-access toggle. */ + all: boolean; + /** Bare scope ids (prefix stripped, `all` excluded), request order preserved. */ + scopes: string[]; +} + +export interface ConsentScopeModel { + /** OIDC identity scopes, read-only, always granted. */ + identity: ScopeDescriptor[]; + /** Console-wide `all` full access, read-only, subsumes everything else. */ + all: ScopeDescriptor | null; + project: TierScopes; + organization: TierScopes; } -const CONSENT_IDENTITY_SCOPES = ['profile', 'email'] as const; +function collectTier(scopes: string[], prefix: string, allScope: string): TierScopes { + const seen = new Set(); + const out: string[] = []; + let all = false; + for (const scope of scopes) { + if (scope === allScope) { + all = true; + continue; + } + if (!scope.startsWith(prefix)) continue; + const bare = scope.slice(prefix.length); + if (bare === '' || seen.has(bare)) continue; + seen.add(bare); + out.push(bare); + } + return { all, scopes: out }; +} /** - * Build the permission list for the console OAuth2 consent screen. The server - * enforces scope-based access, so this maps 1:1 to the issued token: full access - * only for `account.admin`, then identity scopes, then any granular scopes. - * Falls back to the lone `openid` "Verify your identity" row so a minimal OIDC - * request is never empty. + * Split the requested `scope` values into the shape the consent screen renders: + * identity (read-only), console-wide `all` (read-only), and the project / + * organization tiers with their `all` flag and individual scopes. */ -export function describeConsentScopes(scopes: string[]): ScopeDescriptor[] { +export function splitConsentScopes(scopes: string[]): ConsentScopeModel { const requested = new Set(scopes); - const admin = requested.has(ACCOUNT_ADMIN_SCOPE) ? [ACCOUNT_ADMIN_DESCRIPTOR] : []; - const identity = CONSENT_IDENTITY_SCOPES.filter((scope) => requested.has(scope)).map( - describeScope - ); - const remaining = scopes.filter( - (scope) => - scope !== 'openid' && - scope !== ACCOUNT_ADMIN_SCOPE && - !(CONSENT_IDENTITY_SCOPES as readonly string[]).includes(scope) - ); + return { + identity: IDENTITY_SCOPES.filter((scope) => requested.has(scope)).map(describeScope), + all: requested.has(ALL_SCOPE) ? describeScope(ALL_SCOPE) : null, + project: collectTier(scopes, PROJECT_SCOPE_PREFIX, PROJECT_ALL_SCOPE), + organization: collectTier(scopes, ORGANIZATION_SCOPE_PREFIX, ORGANIZATION_ALL_SCOPE) + }; +} - const described = [...admin, ...identity, ...remaining.map(describeScope)]; - if (described.length === 0 && requested.has('openid')) { - return [describeScope('openid')]; - } +/* -------------------------------------------------------------------------- */ +/* Resource catalog — console-authored copy for every scope Cloud exposes */ +/* -------------------------------------------------------------------------- */ - return described; +/** + * Console-authored copy per resource (the part of a scope before the last dot, + * e.g. `tables` in `tables.read`). The server's own scope descriptions are terse + * ("Access to read database tables") and read/write pairs arrive as two lines; + * this catalog gives each resource a clean title plus a description. + * + * The read/write signal lives in the row *title* ("Read Tables" / "Write Tables" + * / "Read and write Tables"), generated from the requested actions. The + * description just says what the resource is — `noun` supplies it as a sentence + * ("database tables and their structure" → "Database tables and their structure."). + * Provide an explicit `read` / `write` / `both` description only when a noun + * phrase misses nuance (read-only services, the organization tier's project + * metadata, execute-style scopes). Resources not listed fall back to a titleized + * name with no description, so future scopes still render a sensible title. + */ +interface ResourceCopy { + /** Row title — just the resource name, e.g. "Tables". */ + name: string; + /** One full-line description of what the resource covers. */ + desc?: string; } -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[]; -} +const PROJECT_RESOURCE_COPY: Record = { + project: { + name: 'Project settings', + desc: "This project's general settings, name, and configuration." + }, + keys: { + name: 'API keys', + desc: "API keys that grant server-side access to this project's resources." + }, + platforms: { + name: 'Platforms', + desc: 'The web, mobile, and native app platforms registered with this project.' + }, + mocks: { + name: 'Mock numbers', + desc: 'Mock phone numbers used to test phone authentication flows.' + }, + 'project.policies': { + name: 'Project policies', + desc: "This project's security and access policies." + }, + templates: { + name: 'Templates', + desc: "The project's customizable email and SMS message templates." + }, + stages: { + name: 'Stages', + desc: 'Deployment stages used to promote changes across environments.' + }, + oauth2: { + name: 'OAuth2', + desc: "This project's OAuth2 provider configuration and token introspection." + }, + users: { + name: 'Users', + desc: 'End-user accounts, including their profiles, preferences, and identifiers.' + }, + sessions: { + name: 'Sessions', + desc: "Active login sessions belonging to this project's users." + }, + teams: { + name: 'Teams', + desc: 'Teams and their memberships, used to group and organize users.' + }, + databases: { + name: 'Databases', + desc: 'Databases and their overall configuration within this project.' + }, + tables: { + name: 'Tables', + desc: 'Database tables along with their columns, indexes, and structure.' + }, + columns: { + name: 'Columns', + desc: 'The columns that define the structure of your database tables.' + }, + indexes: { + name: 'Indexes', + desc: 'The indexes that speed up queries against your database tables.' + }, + rows: { + name: 'Rows', + desc: 'The individual rows of data stored inside your database tables.' + }, + buckets: { + name: 'Storage buckets', + desc: 'Storage buckets and their file-level permission and security settings.' + }, + files: { + name: 'Files', + desc: 'Files stored in your buckets, including uploads, downloads, and previews.' + }, + tokens: { + name: 'File tokens', + desc: 'Access tokens that grant shareable links to individual storage files.' + }, + functions: { + name: 'Functions', + desc: 'Serverless functions along with their code deployments and configuration.' + }, + executions: { + name: 'Executions', + desc: 'The execution history and logs of your serverless functions.' + }, + sites: { + name: 'Sites', + desc: 'Hosted sites and their deployments, builds, and configuration.' + }, + log: { name: 'Site logs', desc: 'Runtime and build logs produced by your sites.' }, + providers: { + name: 'Messaging providers', + desc: 'Messaging providers used to send email, SMS, and push notifications.' + }, + topics: { + name: 'Topics', + desc: 'Messaging topics that group subscribers for targeted broadcasts.' + }, + subscribers: { + name: 'Subscribers', + desc: 'Subscribers enrolled in your messaging topics.' + }, + targets: { + name: 'Targets', + desc: 'The delivery targets (email, phone, or device) attached to your users.' + }, + messages: { + name: 'Messages', + desc: 'Email, SMS, and push messages, including drafts and delivery status.' + }, + rules: { + name: 'Proxy rules', + desc: "Proxy rules that route custom domains to this project's resources." + }, + webhooks: { + name: 'Webhooks', + desc: 'Webhooks that notify external services when project events occur.' + }, + locale: { + name: 'Locale', + desc: 'The Locale service for reading locale, language, and geo information.' + }, + avatars: { + name: 'Avatars', + desc: 'The Avatars service for generating avatars, icons, flags, and QR codes.' + }, + health: { + name: 'Health', + desc: "The health and operational status of this project's services." + }, + assistant: { + name: 'AI Assistant', + desc: 'The AI Assistant that suggests answers and configuration.' + }, + migrations: { + name: 'Migrations', + desc: 'Data migrations that import from or export to other projects.' + }, + schedules: { + name: 'Schedules', + desc: 'Scheduled tasks that run functions or messages at set times.' + }, + vcs: { + name: 'Git', + desc: 'Connected Git repositories used to deploy functions and sites.' + }, + insights: { + name: 'Advisor insights', + desc: 'Advisor insights that surface recommendations for your project.' + }, + reports: { + name: 'Advisor reports', + desc: "Advisor reports generated from your project's activity." + }, + presences: { + name: 'Presence', + desc: 'Realtime presence data showing which users are currently online.' + }, + 'backups.policies': { + name: 'Backup policies', + desc: 'Policies that define when and how your data is backed up.' + }, + archives: { + name: 'Backup archives', + desc: "Backup archives captured from this project's data." + }, + restorations: { + name: 'Restorations', + desc: 'Restore operations that recover data from backup archives.' + }, + dedicatedDatabases: { + name: 'Dedicated SQL', + desc: 'Direct SQL access to run statements against dedicated databases.' + }, + domains: { name: 'Domains', desc: 'Custom domains connected to this project.' }, + events: { + name: 'Events', + desc: 'The realtime and system events emitted by this project.' + }, + apps: { + name: 'OAuth2 apps', + desc: 'OAuth2 applications registered to authorize against this project.' + }, + usage: { + name: 'Usage', + desc: "Usage statistics and metrics for this project's resources." + } +}; + +const ORGANIZATION_RESOURCE_COPY: Record = { + projects: { + name: 'Projects', + // The organization tier grants org-level management of project metadata, + // never the data inside any project — spell that out so it can't be misread. + desc: "The names, IDs, and settings of this organization's projects, but not the data inside them." + }, + 'organization.keys': { + name: 'Organization keys', + desc: 'Organization-level API keys that authorize access across projects.' + }, + domains: { + name: 'Organization domains', + desc: 'Custom domains owned and managed at the organization level.' + } +}; /** - * 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. + * Resolve the row for one resource, given which actions (`read` / `write` / + * `execute` / …) of it were requested. Following Supabase's consent screen, the + * title is just the resource name and the read/write signal lives in a chip + * ("READ" / "WRITE" / "READ + WRITE"); the description says what the resource is. */ -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 - ); +function describeResource( + resource: string, + actions: string[], + copyMap: Record +): { title: string; description?: string; access: string; accessStrong: boolean } { + const copy = copyMap[resource]; + const title = copy?.name ?? titleizeScope(resource); + const hasRead = actions.includes('read'); + const hasWrite = actions.includes('write'); - 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)); + let access: string; + let accessStrong: boolean; + if (hasRead && hasWrite) { + access = 'Read + Write'; + accessStrong = true; + } else if (hasWrite) { + access = 'Write'; + accessStrong = true; + } else if (hasRead) { + access = 'Read'; + accessStrong = false; + } else { + // Non-CRUD actions (e.g. `execute`) — label with the action itself. + access = actions[0] ? titleizeScope(actions[0]) : 'Access'; + accessStrong = true; } - return { admin, identity, selectable }; + return { title, description: copy?.desc, access, accessStrong }; } -export interface ScopeAction { - /** Full scope id, e.g. `teams.write`. */ - id: string; - /** Trailing verb, e.g. `read` / `write`. */ - action: string; - /** Display label for the verb, e.g. `Read` / `Write`. */ - label: string; -} +/* -------------------------------------------------------------------------- */ +/* Read-only permission summary (Railway-style consent list) */ +/* -------------------------------------------------------------------------- */ -export interface ScopeResourceGroup { - /** Resource prefix, e.g. `teams` or `backups.policies`. */ - resource: string; - /** Display title for the resource, e.g. `Teams`. */ +export interface PermissionLine { + /** Row title — the resource name ("Tables"), or a descriptive title for account lines. */ title: string; - icon: ComponentType; - actions: ScopeAction[]; + /** One-line description of what the resource covers. */ + description?: string; + /** Raw scope token(s), space-joined, exactly as issued (incl. tier prefix). Never rendered + * inline — surfaced only via the hover copy affordance. */ + token: string; + /** Access-level chip label, e.g. "Read" / "Write" / "Read + Write". Absent on account lines. */ + access?: string; + /** Whether the chip should read as elevated (write access) rather than neutral (read-only). */ + accessStrong?: boolean; } -export interface GroupedConsentScopes { - /** `account.admin` full-access descriptor, when requested. */ - admin: ScopeDescriptor | null; - /** OIDC identity scopes (openid/profile/email), always shown in full. */ - identity: ScopeDescriptor[]; - /** Every other granular scope, bucketed by resource for collapsing. */ - groups: ScopeResourceGroup[]; - /** Total count of granular scopes across all groups. */ - granularCount: number; +export interface PermissionGroup { + /** Section heading, e.g. `Account`, `Projects`, `Organizations`. */ + heading: string; + /** Contextual note under the heading, e.g. which resources it applies to. */ + note?: string; + lines: PermissionLine[]; } -/** - * Split a granular scope into its resource prefix and trailing action verb. - * `teams.read` -> { resource: 'teams', action: 'read' }, and - * `backups.policies.write` -> { resource: 'backups.policies', action: 'write' }. - * Scopes without a `.` fall back to the whole scope as the resource. - */ -function splitScope(scope: string): { resource: string; action: string } { - const lastDot = scope.lastIndexOf('.'); - if (lastDot <= 0) { - return { resource: scope, action: '' }; - } - return { resource: scope.slice(0, lastDot), action: scope.slice(lastDot + 1) }; +/** Rank actions so `read` sorts before `write`, everything else after. */ +function actionRank(action: string): number { + if (action === 'read') return 0; + if (action === 'write') return 1; + return 2; } -/** - * Group the consent scopes for the OAuth2 consent screen. `account.admin` and the - * OIDC identity scopes stay as full, always-visible rows; everything else is - * bucketed by resource (`teams`, `projects`, …) so the granular console scopes can - * be tucked into a single collapsible section rather than a long flat list. - * Resource and action order follow first appearance in the requested scopes. - */ -export function groupConsentScopes(scopes: string[]): GroupedConsentScopes { - 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 - ); +function actionOf(scope: string): string { + const dot = scope.lastIndexOf('.'); + return dot === -1 ? scope : scope.slice(dot + 1); +} - const remaining = scopes.filter( - (scope) => - scope !== 'openid' && - scope !== ACCOUNT_ADMIN_SCOPE && - !(CONSENT_IDENTITY_SCOPES as readonly string[]).includes(scope) - ); +function resourceOf(scope: string): string { + const dot = scope.lastIndexOf('.'); + return dot === -1 ? scope : scope.slice(0, dot); +} + +function tierLines( + tier: TierScopes, + prefix: string, + allScope: string, + copyMap: Record +): PermissionLine[] { + const lines: PermissionLine[] = []; + if (tier.all) { + lines.push({ + title: + prefix === PROJECT_SCOPE_PREFIX + ? 'Full project access' + : 'Full organization access', + description: `Grant every available permission on the selected ${ + prefix === PROJECT_SCOPE_PREFIX ? 'projects' : 'organizations' + }.`, + token: allScope + }); + } + + // Group scopes by resource (everything before the last dot) so that, e.g., + // `tables.read` and `tables.write` collapse into a single "Tables" row with + // one combined description. + const groups = new Map(); + for (const scope of tier.scopes) { + const resource = resourceOf(scope); + const bucket = groups.get(resource); + if (bucket) bucket.push(scope); + else groups.set(resource, [scope]); + } - const byResource = new Map(); - for (const scope of remaining) { - const { resource, action } = splitScope(scope); - let group = byResource.get(resource); - if (!group) { - group = { + // Order rows by how much access they grant — Read + Write first, then Write, + // then any other action, and read-only last — so the strongest grants lead. + const accessRank = (actions: string[]): number => { + const hasRead = actions.includes('read'); + const hasWrite = actions.includes('write'); + if (hasRead && hasWrite) return 0; + if (hasWrite) return 1; + if (hasRead) return 3; + return 2; + }; + + const groupLines = [...groups.entries()] + .map(([resource, scopes]) => { + scopes.sort((a, b) => actionRank(actionOf(a)) - actionRank(actionOf(b))); + const actions = scopes.map(actionOf); + const { title, description, access, accessStrong } = describeResource( resource, - title: titleizeScope(resource), - icon: IconKey, - actions: [] + actions, + copyMap + ); + return { + line: { + title, + description, + token: scopes.map((scope) => prefix + scope).join(' '), + access, + accessStrong + }, + rank: accessRank(actions) }; - byResource.set(resource, group); - } - if (!group.actions.some((existing) => existing.id === scope)) { - group.actions.push({ id: scope, action, label: titleizeScope(action || scope) }); - } + }) + // Stable sort: equal ranks keep their requested order. + .sort((a, b) => a.rank - b.rank); + + for (const { line } of groupLines) lines.push(line); + + return lines; +} + +/** + * Build the read-only permission summary the consent screen displays. Scopes are + * not individually selectable — the client decides what it requests — so this is + * purely descriptive: each requested scope becomes a titled line grouped under + * Account / Projects / Organizations, with the raw token shown for transparency. + */ +export function buildConsentPermissions(model: ConsentScopeModel): PermissionGroup[] { + const groups: PermissionGroup[] = []; + + const account: PermissionLine[] = []; + if (model.identity.length > 0) { + account.push({ + title: 'View your identity', + description: 'Confirm who you are and read your basic profile details.', + token: model.identity.map((scope) => scope.id).join(' ') + }); + } + if (model.all) { + account.push({ + title: model.all.title, + description: model.all.description, + token: model.all.id + }); + } + if (account.length > 0) { + groups.push({ heading: 'Account', lines: account }); } - const groups = [...byResource.values()]; + const projectLines = tierLines( + model.project, + PROJECT_SCOPE_PREFIX, + PROJECT_ALL_SCOPE, + PROJECT_RESOURCE_COPY + ); + if (projectLines.length > 0) { + groups.push({ + heading: 'Projects', + note: 'Applies only to the projects you select below.', + lines: projectLines + }); + } - // Keep a non-empty consent screen for a minimal OIDC request. - if (!admin && identity.length === 0 && groups.length === 0 && requested.has('openid')) { - return { admin: null, identity: [describeScope('openid')], groups: [], granularCount: 0 }; + const organizationLines = tierLines( + model.organization, + ORGANIZATION_SCOPE_PREFIX, + ORGANIZATION_ALL_SCOPE, + ORGANIZATION_RESOURCE_COPY + ); + if (organizationLines.length > 0) { + groups.push({ + heading: 'Organizations', + note: 'Applies only to the organizations you select below.', + lines: organizationLines + }); } - return { admin, identity, groups, granularCount: remaining.length }; + return groups; } diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index bcdcd8d4e3..b55598f60a 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -1,39 +1,40 @@ - - - - {#if app.logoUri} - - {:else} -
- {appInitial} -
- {/if} - +{#snippet copyBtn(token: string)} + +{/snippet} + + + + diff --git a/src/routes/(public)/oauth2/oauth2-scope-picker.svelte b/src/routes/(public)/oauth2/oauth2-scope-picker.svelte deleted file mode 100644 index b4f0f6abd0..0000000000 --- a/src/routes/(public)/oauth2/oauth2-scope-picker.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - Permissions - - {selectedCount}/{actions.length} - - - - - - - - - - {#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} -
- - diff --git a/src/routes/(public)/oauth2/resource-selector.svelte b/src/routes/(public)/oauth2/resource-selector.svelte new file mode 100644 index 0000000000..c6b6ea103c --- /dev/null +++ b/src/routes/(public)/oauth2/resource-selector.svelte @@ -0,0 +1,452 @@ + + +
+
+ + {#if isEmpty} + + {/if} + {summary} + {#if isDefault} + Default + {/if} + + +
+ + {#if expanded} +
+ {#if wildcard} +
+ + +
+ {/if} + + {#if specificIds.length > 0} +
+ {#each specificIds as id (id)} + {@const r = labelFor(id)} + + {r.name} + {#if r.region} + + {/if} + + + {/each} +
+ {/if} + + {#if searchable} + +
    + {#if searching} +
  • + {:else if suggestions.length === 0} +
  • + {term.trim() + ? `No matching ${pluralLabel}` + : `Type to search ${pluralLabel}`} +
  • + {:else} + {#each suggestions as r (r.id)} +
  • + +
  • + {/each} + {/if} +
+ {/if} +
+ {/if} +
+ + From 11cdeef0fd7a4e7d520854f564cbb389843a08fd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 1 Jul 2026 16:35:04 +0530 Subject: [PATCH 2/2] (fix): key oauth2 consent rows by token to avoid title collisions Addresses Greptile review: two resources whose names titleize to the same string would collide when keyed by title. The token is the space-joined raw scopes and is unique per row within a group. --- src/routes/(public)/oauth2/consent-card.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index b55598f60a..f1eb0075f5 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -310,7 +310,7 @@ {/if}
    - {#each group.lines as line (line.title)} + {#each group.lines as line (line.token)}