Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions src/lib/helpers/oauth2-cimd.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
108 changes: 108 additions & 0 deletions src/lib/helpers/oauth2-cimd.ts
Original file line number Diff line number Diff line change
@@ -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<Models.App> {
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 });
}
}
4 changes: 2 additions & 2 deletions src/routes/(console)/account/applications/+page.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 };
})
);
Expand Down
7 changes: 3 additions & 4 deletions src/routes/(public)/oauth2/consent/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,7 +86,7 @@
): Promise<void> {
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;
Expand Down Expand Up @@ -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';
}
Expand Down
5 changes: 2 additions & 3 deletions src/routes/(public)/oauth2/device/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down