Skip to content

Commit 00ba9b1

Browse files
authored
Merge pull request #3154 from appwrite/fix-cimd-client-id-resolution
Fix compatibility with external CIMD URLs
2 parents df7f976 + 6b7c35a commit 00ba9b1

7 files changed

Lines changed: 197 additions & 15 deletions

File tree

bun.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"flatted": "^3.4.2",
5252
"ignore": "^6.0.2",
5353
"json5": "^2.2.3",
54-
"nanoid": "^5.1.11",
54+
"nanoid": "^5.1.16",
5555
"nanotar": "^0.3.0",
5656
"pretty-bytes": "^6.1.1",
5757
"remarkable": "^2.0.1",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { cimdDocumentToApp, isCimdClientId } from '$lib/helpers/oauth2-cimd';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('isCimdClientId', () => {
5+
it('accepts https URLs', () => {
6+
expect(isCimdClientId('https://example.com/oauth/client-metadata.json')).toBe(true);
7+
});
8+
9+
it('accepts http only for loopback', () => {
10+
expect(isCimdClientId('http://localhost:3000/client.json')).toBe(true);
11+
expect(isCimdClientId('http://127.0.0.1/client.json')).toBe(true);
12+
expect(isCimdClientId('http://example.com/client.json')).toBe(false);
13+
});
14+
15+
it('rejects plain app IDs and non-http schemes', () => {
16+
expect(isCimdClientId('my-app_1.0')).toBe(false);
17+
expect(isCimdClientId('64f1e2a9b3c4d5e6f7a8')).toBe(false);
18+
expect(isCimdClientId('javascript:alert(1)')).toBe(false);
19+
});
20+
});
21+
22+
describe('cimdDocumentToApp', () => {
23+
const clientId = 'https://example.com/oauth/client-metadata.json';
24+
25+
it('maps RFC 7591 metadata onto the App model', () => {
26+
const app = cimdDocumentToApp(clientId, {
27+
client_id: clientId,
28+
client_name: 'Example App',
29+
client_uri: 'https://example.com',
30+
logo_uri: 'https://example.com/logo.png',
31+
policy_uri: 'https://example.com/privacy',
32+
tos_uri: 'https://example.com/terms',
33+
contacts: ['support@example.com'],
34+
redirect_uris: ['https://example.com/callback'],
35+
token_endpoint_auth_method: 'none',
36+
grant_types: ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code']
37+
});
38+
39+
expect(app.$id).toBe(clientId);
40+
expect(app.name).toBe('Example App');
41+
expect(app.clientUri).toBe('https://example.com');
42+
expect(app.logoUri).toBe('https://example.com/logo.png');
43+
expect(app.privacyPolicyUrl).toBe('https://example.com/privacy');
44+
expect(app.termsUrl).toBe('https://example.com/terms');
45+
expect(app.contacts).toEqual(['support@example.com']);
46+
expect(app.redirectUris).toEqual(['https://example.com/callback']);
47+
expect(app.type).toBe('public');
48+
expect(app.deviceFlow).toBe(true);
49+
expect(app.enabled).toBe(true);
50+
});
51+
52+
it('falls back to the hostname when client_name is missing', () => {
53+
const app = cimdDocumentToApp(clientId, { client_id: clientId });
54+
expect(app.name).toBe('example.com');
55+
expect(app.deviceFlow).toBe(false);
56+
});
57+
58+
it('rejects a document whose client_id does not match its URL', () => {
59+
expect(() =>
60+
cimdDocumentToApp(clientId, { client_id: 'https://evil.example/other.json' })
61+
).toThrow();
62+
expect(() => cimdDocumentToApp(clientId, 'not an object')).toThrow();
63+
});
64+
65+
it('drops unrenderable URI values', () => {
66+
const app = cimdDocumentToApp(clientId, {
67+
client_id: clientId,
68+
logo_uri: 'javascript:alert(1)',
69+
client_uri: 'not a url',
70+
contacts: ['ok', 42]
71+
});
72+
expect(app.logoUri).toBe('');
73+
expect(app.clientUri).toBe('');
74+
expect(app.contacts).toEqual(['ok']);
75+
});
76+
});

src/lib/helpers/oauth2-cimd.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { sdk } from '$lib/stores/sdk';
2+
import type { Models } from '@appwrite.io/console';
3+
4+
// CIMD (Client ID Metadata Document): a client_id may be an HTTPS URL pointing
5+
// to a JSON document of RFC 7591 client metadata. The Appwrite API no longer
6+
// resolves these, so the console fetches the document itself for branding.
7+
8+
const FETCH_TIMEOUT = 10_000;
9+
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
10+
const HTTP_URL = /^https?:\/\//i;
11+
12+
type CimdDocument = {
13+
client_id?: unknown;
14+
client_name?: unknown;
15+
client_uri?: unknown;
16+
logo_uri?: unknown;
17+
policy_uri?: unknown;
18+
tos_uri?: unknown;
19+
contacts?: unknown;
20+
redirect_uris?: unknown;
21+
post_logout_redirect_uris?: unknown;
22+
token_endpoint_auth_method?: unknown;
23+
grant_types?: unknown;
24+
};
25+
26+
// Plain app IDs never parse as URLs; http is allowed for local development only.
27+
export function isCimdClientId(clientId: string): boolean {
28+
try {
29+
const url = new URL(clientId);
30+
return (
31+
url.protocol === 'https:' ||
32+
(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
33+
);
34+
} catch {
35+
return false;
36+
}
37+
}
38+
39+
export function cimdDocumentToApp(clientId: string, document: unknown): Models.App {
40+
if (typeof document !== 'object' || document === null) {
41+
throw new Error('CIMD document is not a JSON object');
42+
}
43+
const doc = document as CimdDocument;
44+
// The document's client_id must equal the URL it was fetched from.
45+
if (doc.client_id !== clientId) {
46+
throw new Error('CIMD document client_id does not match its URL');
47+
}
48+
const name = typeof doc.client_name === 'string' ? doc.client_name.trim() : '';
49+
return {
50+
$id: clientId,
51+
$createdAt: '',
52+
$updatedAt: '',
53+
name: name || new URL(clientId).hostname,
54+
description: '',
55+
// Untrusted values rendered in href/src must be http(s) URLs.
56+
clientUri:
57+
typeof doc.client_uri === 'string' && HTTP_URL.test(doc.client_uri)
58+
? doc.client_uri
59+
: '',
60+
logoUri:
61+
typeof doc.logo_uri === 'string' && HTTP_URL.test(doc.logo_uri) ? doc.logo_uri : '',
62+
privacyPolicyUrl:
63+
typeof doc.policy_uri === 'string' && HTTP_URL.test(doc.policy_uri)
64+
? doc.policy_uri
65+
: '',
66+
termsUrl: typeof doc.tos_uri === 'string' && HTTP_URL.test(doc.tos_uri) ? doc.tos_uri : '',
67+
contacts: Array.isArray(doc.contacts)
68+
? doc.contacts.filter((contact) => typeof contact === 'string')
69+
: [],
70+
tagline: '',
71+
tags: [],
72+
images: [],
73+
supportUrl: '',
74+
dataDeletionUrl: '',
75+
redirectUris: Array.isArray(doc.redirect_uris)
76+
? doc.redirect_uris.filter((uri) => typeof uri === 'string')
77+
: [],
78+
postLogoutRedirectUris: Array.isArray(doc.post_logout_redirect_uris)
79+
? doc.post_logout_redirect_uris.filter((uri) => typeof uri === 'string')
80+
: [],
81+
enabled: true,
82+
type: doc.token_endpoint_auth_method === 'none' ? 'public' : 'confidential',
83+
deviceFlow: Array.isArray(doc.grant_types) && doc.grant_types.includes(DEVICE_GRANT_TYPE),
84+
teamId: '',
85+
userId: '',
86+
secrets: []
87+
};
88+
}
89+
90+
// Plain IDs resolve via the API; CIMD URLs are fetched directly. Fetch or
91+
// validation failures fall back to hostname-only branding rather than blocking
92+
// the flow — the server still validates the client during authorization.
93+
export async function getOAuth2App(appId: string): Promise<Models.App> {
94+
if (!isCimdClientId(appId)) {
95+
return sdk.forConsole.apps.get({ appId });
96+
}
97+
try {
98+
const response = await fetch(appId, {
99+
headers: { accept: 'application/json' },
100+
credentials: 'omit',
101+
signal: AbortSignal.timeout(FETCH_TIMEOUT)
102+
});
103+
if (!response.ok) throw new Error(`CIMD document request failed: ${response.status}`);
104+
return cimdDocumentToApp(appId, await response.json());
105+
} catch {
106+
return cimdDocumentToApp(appId, { client_id: appId });
107+
}
108+
}

src/routes/(console)/account/applications/+page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Dependencies } from '$lib/constants';
2-
import { sdk } from '$lib/stores/sdk';
2+
import { getOAuth2App } from '$lib/helpers/oauth2-cimd';
33
import type { Models } from '@appwrite.io/console';
44
import type { PageLoad } from './$types';
55

@@ -19,7 +19,7 @@ export const load: PageLoad = async ({ depends, parent }) => {
1919
const connectedApps = await Promise.all(
2020
grants.map(async (identity) => {
2121
const appId = identity.provider.slice(OAUTH2_PREFIX.length);
22-
const app = await sdk.forConsole.apps.get({ appId }).catch(() => null);
22+
const app = await getOAuth2App(appId).catch(() => null);
2323
return { identity, appId, app };
2424
})
2525
);

src/routes/(public)/oauth2/consent/+page.svelte

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { sdk } from '$lib/stores/sdk';
1010
import { logout } from '$lib/helpers/logout';
1111
import { isWebRedirect } from '$lib/helpers/oauth2-redirect';
12+
import { getOAuth2App } from '$lib/helpers/oauth2-cimd';
1213
import OAuth2ConsentCard, { type OAuth2Outcome } from '../consent-card.svelte';
1314
import OAuth2OutcomeCard from '../outcome-card.svelte';
1415
import { OAuth2ErrorMessage, OAuth2ErrorType } from '../errors';
@@ -85,7 +86,7 @@
8586
): Promise<void> {
8687
const loadedGrant = await sdk.forConsole.oauth2.getGrant({ grantId });
8788
const [loadedApp, loadedAccount] = await Promise.all([
88-
sdk.forConsole.apps.get({ appId: loadedGrant.appId }),
89+
getOAuth2App(loadedGrant.appId),
8990
knownAccount !== undefined ? Promise.resolve(knownAccount) : getAccount()
9091
]);
9192
if (cancelled()) return;
@@ -122,9 +123,7 @@
122123
if (!isWebRedirect(result.redirectUrl)) {
123124
completedRedirectUrl = result.redirectUrl;
124125
account = loggedInAccount;
125-
app = clientId
126-
? await sdk.forConsole.apps.get({ appId: clientId }).catch(() => null)
127-
: null;
126+
app = clientId ? await getOAuth2App(clientId).catch(() => null) : null;
128127
if (cancelled()) return;
129128
phase = 'approved';
130129
}

src/routes/(public)/oauth2/device/+page.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { addNotification } from '$lib/stores/notifications';
1111
import { sdk } from '$lib/stores/sdk';
1212
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
13+
import { getOAuth2App } from '$lib/helpers/oauth2-cimd';
1314
import OAuth2ConsentCard, { type OAuth2Flow, type OAuth2Outcome } from '../consent-card.svelte';
1415
import OAuth2OutcomeCard from '../outcome-card.svelte';
1516
@@ -95,9 +96,7 @@
9596
const loadedGrant = await sdk.forConsole.oauth2.createGrant({
9697
userCode: normalized
9798
});
98-
const loadedApp = await sdk.forConsole.apps.get({
99-
appId: loadedGrant.appId
100-
});
99+
const loadedApp = await getOAuth2App(loadedGrant.appId);
101100
// A fresh `user_code` may have arrived while we awaited. Ignore this
102101
// now-stale result so we never show consent for a superseded request.
103102
if (normalizeUserCode(code) !== normalized) return;

0 commit comments

Comments
 (0)