diff --git a/bun.lock b/bun.lock index 263bb6ac76..eed9e9aa41 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "flatted": "^3.4.2", "ignore": "^6.0.2", "json5": "^2.2.3", - "nanoid": "^5.1.11", + "nanoid": "^5.1.16", "nanotar": "^0.3.0", "pretty-bytes": "^6.1.1", "remarkable": "^2.0.1", @@ -1128,7 +1128,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], "nanotar": ["nanotar@0.3.0", "", {}, "sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg=="], @@ -1500,7 +1500,7 @@ "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.0.11", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-CPyImHGiT3svyfmvPvAFTianZzWFtm0qK82XjwlQIA1C3IQ2iku/PMQXi7aFyrX0TyMh3VTkJPB03tjU2VXVrw=="], - "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], "@ai-sdk/ui-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.0.11", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-CPyImHGiT3svyfmvPvAFTianZzWFtm0qK82XjwlQIA1C3IQ2iku/PMQXi7aFyrX0TyMh3VTkJPB03tjU2VXVrw=="], @@ -1524,7 +1524,7 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "@melt-ui/svelte/nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="], + "@melt-ui/svelte/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], "@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], @@ -1574,7 +1574,7 @@ "popmotion/tslib": ["tslib@2.4.0", "", {}, "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="], - "postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "postcss/nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], "style-value-types/tslib": ["tslib@2.4.0", "", {}, "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="], diff --git a/package.json b/package.json index d8a7e51e64..86727369cc 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "flatted": "^3.4.2", "ignore": "^6.0.2", "json5": "^2.2.3", - "nanoid": "^5.1.11", + "nanoid": "^5.1.16", "nanotar": "^0.3.0", "pretty-bytes": "^6.1.1", "remarkable": "^2.0.1", diff --git a/src/lib/helpers/oauth2-cimd.test.ts b/src/lib/helpers/oauth2-cimd.test.ts new file mode 100644 index 0000000000..fce9d1b2a2 --- /dev/null +++ b/src/lib/helpers/oauth2-cimd.test.ts @@ -0,0 +1,76 @@ +import { cimdDocumentToApp, isCimdClientId } from '$lib/helpers/oauth2-cimd'; +import { describe, expect, it } from 'vitest'; + +describe('isCimdClientId', () => { + it('accepts https URLs', () => { + expect(isCimdClientId('https://example.com/oauth/client-metadata.json')).toBe(true); + }); + + it('accepts http only for loopback', () => { + expect(isCimdClientId('http://localhost:3000/client.json')).toBe(true); + expect(isCimdClientId('http://127.0.0.1/client.json')).toBe(true); + expect(isCimdClientId('http://example.com/client.json')).toBe(false); + }); + + it('rejects plain app IDs and non-http schemes', () => { + expect(isCimdClientId('my-app_1.0')).toBe(false); + expect(isCimdClientId('64f1e2a9b3c4d5e6f7a8')).toBe(false); + expect(isCimdClientId('javascript:alert(1)')).toBe(false); + }); +}); + +describe('cimdDocumentToApp', () => { + const clientId = 'https://example.com/oauth/client-metadata.json'; + + it('maps RFC 7591 metadata onto the App model', () => { + const app = cimdDocumentToApp(clientId, { + client_id: clientId, + client_name: 'Example App', + client_uri: 'https://example.com', + logo_uri: 'https://example.com/logo.png', + policy_uri: 'https://example.com/privacy', + tos_uri: 'https://example.com/terms', + contacts: ['support@example.com'], + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code'] + }); + + expect(app.$id).toBe(clientId); + expect(app.name).toBe('Example App'); + expect(app.clientUri).toBe('https://example.com'); + expect(app.logoUri).toBe('https://example.com/logo.png'); + expect(app.privacyPolicyUrl).toBe('https://example.com/privacy'); + expect(app.termsUrl).toBe('https://example.com/terms'); + expect(app.contacts).toEqual(['support@example.com']); + expect(app.redirectUris).toEqual(['https://example.com/callback']); + expect(app.type).toBe('public'); + expect(app.deviceFlow).toBe(true); + expect(app.enabled).toBe(true); + }); + + it('falls back to the hostname when client_name is missing', () => { + const app = cimdDocumentToApp(clientId, { client_id: clientId }); + expect(app.name).toBe('example.com'); + expect(app.deviceFlow).toBe(false); + }); + + it('rejects a document whose client_id does not match its URL', () => { + expect(() => + cimdDocumentToApp(clientId, { client_id: 'https://evil.example/other.json' }) + ).toThrow(); + expect(() => cimdDocumentToApp(clientId, 'not an object')).toThrow(); + }); + + it('drops unrenderable URI values', () => { + const app = cimdDocumentToApp(clientId, { + client_id: clientId, + logo_uri: 'javascript:alert(1)', + client_uri: 'not a url', + contacts: ['ok', 42] + }); + expect(app.logoUri).toBe(''); + expect(app.clientUri).toBe(''); + expect(app.contacts).toEqual(['ok']); + }); +}); diff --git a/src/lib/helpers/oauth2-cimd.ts b/src/lib/helpers/oauth2-cimd.ts new file mode 100644 index 0000000000..7d631f678e --- /dev/null +++ b/src/lib/helpers/oauth2-cimd.ts @@ -0,0 +1,108 @@ +import { sdk } from '$lib/stores/sdk'; +import type { Models } from '@appwrite.io/console'; + +// CIMD (Client ID Metadata Document): a client_id may be an HTTPS URL pointing +// to a JSON document of RFC 7591 client metadata. The Appwrite API no longer +// resolves these, so the console fetches the document itself for branding. + +const FETCH_TIMEOUT = 10_000; +const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; +const HTTP_URL = /^https?:\/\//i; + +type CimdDocument = { + client_id?: unknown; + client_name?: unknown; + client_uri?: unknown; + logo_uri?: unknown; + policy_uri?: unknown; + tos_uri?: unknown; + contacts?: unknown; + redirect_uris?: unknown; + post_logout_redirect_uris?: unknown; + token_endpoint_auth_method?: unknown; + grant_types?: unknown; +}; + +// Plain app IDs never parse as URLs; http is allowed for local development only. +export function isCimdClientId(clientId: string): boolean { + try { + const url = new URL(clientId); + return ( + url.protocol === 'https:' || + (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) + ); + } catch { + return false; + } +} + +export function cimdDocumentToApp(clientId: string, document: unknown): Models.App { + if (typeof document !== 'object' || document === null) { + throw new Error('CIMD document is not a JSON object'); + } + const doc = document as CimdDocument; + // The document's client_id must equal the URL it was fetched from. + if (doc.client_id !== clientId) { + throw new Error('CIMD document client_id does not match its URL'); + } + const name = typeof doc.client_name === 'string' ? doc.client_name.trim() : ''; + return { + $id: clientId, + $createdAt: '', + $updatedAt: '', + name: name || new URL(clientId).hostname, + description: '', + // Untrusted values rendered in href/src must be http(s) URLs. + clientUri: + typeof doc.client_uri === 'string' && HTTP_URL.test(doc.client_uri) + ? doc.client_uri + : '', + logoUri: + typeof doc.logo_uri === 'string' && HTTP_URL.test(doc.logo_uri) ? doc.logo_uri : '', + privacyPolicyUrl: + typeof doc.policy_uri === 'string' && HTTP_URL.test(doc.policy_uri) + ? doc.policy_uri + : '', + termsUrl: typeof doc.tos_uri === 'string' && HTTP_URL.test(doc.tos_uri) ? doc.tos_uri : '', + contacts: Array.isArray(doc.contacts) + ? doc.contacts.filter((contact) => typeof contact === 'string') + : [], + tagline: '', + tags: [], + images: [], + supportUrl: '', + dataDeletionUrl: '', + redirectUris: Array.isArray(doc.redirect_uris) + ? doc.redirect_uris.filter((uri) => typeof uri === 'string') + : [], + postLogoutRedirectUris: Array.isArray(doc.post_logout_redirect_uris) + ? doc.post_logout_redirect_uris.filter((uri) => typeof uri === 'string') + : [], + enabled: true, + type: doc.token_endpoint_auth_method === 'none' ? 'public' : 'confidential', + deviceFlow: Array.isArray(doc.grant_types) && doc.grant_types.includes(DEVICE_GRANT_TYPE), + teamId: '', + userId: '', + secrets: [] + }; +} + +// Plain IDs resolve via the API; CIMD URLs are fetched directly. Fetch or +// validation failures fall back to hostname-only branding rather than blocking +// the flow — the server still validates the client during authorization. +export async function getOAuth2App(appId: string): Promise { + if (!isCimdClientId(appId)) { + return sdk.forConsole.apps.get({ appId }); + } + try { + const response = await fetch(appId, { + headers: { accept: 'application/json' }, + credentials: 'omit', + signal: AbortSignal.timeout(FETCH_TIMEOUT) + }); + if (!response.ok) throw new Error(`CIMD document request failed: ${response.status}`); + return cimdDocumentToApp(appId, await response.json()); + } catch { + return cimdDocumentToApp(appId, { client_id: appId }); + } +} diff --git a/src/routes/(console)/account/applications/+page.ts b/src/routes/(console)/account/applications/+page.ts index 14645b4465..c453ee958c 100644 --- a/src/routes/(console)/account/applications/+page.ts +++ b/src/routes/(console)/account/applications/+page.ts @@ -1,5 +1,5 @@ import { Dependencies } from '$lib/constants'; -import { sdk } from '$lib/stores/sdk'; +import { getOAuth2App } from '$lib/helpers/oauth2-cimd'; import type { Models } from '@appwrite.io/console'; import type { PageLoad } from './$types'; @@ -19,7 +19,7 @@ export const load: PageLoad = async ({ depends, parent }) => { const connectedApps = await Promise.all( grants.map(async (identity) => { const appId = identity.provider.slice(OAUTH2_PREFIX.length); - const app = await sdk.forConsole.apps.get({ appId }).catch(() => null); + const app = await getOAuth2App(appId).catch(() => null); return { identity, appId, app }; }) ); diff --git a/src/routes/(public)/oauth2/consent/+page.svelte b/src/routes/(public)/oauth2/consent/+page.svelte index 81553b1727..0599bd277e 100644 --- a/src/routes/(public)/oauth2/consent/+page.svelte +++ b/src/routes/(public)/oauth2/consent/+page.svelte @@ -9,6 +9,7 @@ import { sdk } from '$lib/stores/sdk'; import { logout } from '$lib/helpers/logout'; import { isWebRedirect } from '$lib/helpers/oauth2-redirect'; + import { getOAuth2App } from '$lib/helpers/oauth2-cimd'; import OAuth2ConsentCard, { type OAuth2Outcome } from '../consent-card.svelte'; import OAuth2OutcomeCard from '../outcome-card.svelte'; import { OAuth2ErrorMessage, OAuth2ErrorType } from '../errors'; @@ -85,7 +86,7 @@ ): Promise { const loadedGrant = await sdk.forConsole.oauth2.getGrant({ grantId }); const [loadedApp, loadedAccount] = await Promise.all([ - sdk.forConsole.apps.get({ appId: loadedGrant.appId }), + getOAuth2App(loadedGrant.appId), knownAccount !== undefined ? Promise.resolve(knownAccount) : getAccount() ]); if (cancelled()) return; @@ -122,9 +123,7 @@ if (!isWebRedirect(result.redirectUrl)) { completedRedirectUrl = result.redirectUrl; account = loggedInAccount; - app = clientId - ? await sdk.forConsole.apps.get({ appId: clientId }).catch(() => null) - : null; + app = clientId ? await getOAuth2App(clientId).catch(() => null) : null; if (cancelled()) return; phase = 'approved'; } diff --git a/src/routes/(public)/oauth2/device/+page.svelte b/src/routes/(public)/oauth2/device/+page.svelte index 15f37b0d4c..15e0c43c03 100644 --- a/src/routes/(public)/oauth2/device/+page.svelte +++ b/src/routes/(public)/oauth2/device/+page.svelte @@ -10,6 +10,7 @@ import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; + import { getOAuth2App } from '$lib/helpers/oauth2-cimd'; import OAuth2ConsentCard, { type OAuth2Flow, type OAuth2Outcome } from '../consent-card.svelte'; import OAuth2OutcomeCard from '../outcome-card.svelte'; @@ -95,9 +96,7 @@ const loadedGrant = await sdk.forConsole.oauth2.createGrant({ userCode: normalized }); - const loadedApp = await sdk.forConsole.apps.get({ - appId: loadedGrant.appId - }); + const loadedApp = await getOAuth2App(loadedGrant.appId); // A fresh `user_code` may have arrived while we awaited. Ignore this // now-stale result so we never show consent for a superseded request. if (normalizeUserCode(code) !== normalized) return;