diff --git a/bun.lock b/bun.lock index cb48bc3826..15f8662cef 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@82d2831", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@1a5604f", "@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", @@ -96,6 +96,7 @@ "minimatch": "10.2.3", "picomatch": "^4.0.4", "vite": "npm:rolldown-vite@latest", + "ws": "^8.21.0", "yaml": "^1.10.3", }, "packages": { @@ -125,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@82d2831", { "dependencies": { "json-bigint": "1.0.0" } }], + "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@1a5604f", { "dependencies": { "json-bigint": "1.0.0" } }], "@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="], @@ -1463,7 +1464,7 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], diff --git a/package.json b/package.json index 03424e186d..643ba8db43 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@82d2831", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@1a5604f", "@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", @@ -108,6 +108,7 @@ "devalue": "^5.8.1", "yaml": "^1.10.3", "picomatch": "^4.0.4", - "cookie": "^0.7.0" + "cookie": "^0.7.0", + "ws": "^8.21.0" } } diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index 3a8d89622a..e7e014de9e 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -225,6 +225,9 @@ export enum Submit { AccountRecoveryCodesCreate = 'submit_account_recovery_codes_create', AccountRecoveryCodesUpdate = 'submit_account_recovery_codes_update', AccountDeleteIdentity = 'submit_account_delete_identity', + AccountOAuth2ConsentApprove = 'submit_account_oauth2_consent_approve', + AccountOAuth2ConsentDeny = 'submit_account_oauth2_consent_deny', + AccountOAuth2DeviceVerify = 'submit_account_oauth2_device_verify', FeedbackSubmit = 'submit_leave_feedback', FilterClear = 'submit_clear_filter', FilterApply = 'submit_filter_apply', diff --git a/src/lib/helpers/oauth2-scopes.ts b/src/lib/helpers/oauth2-scopes.ts new file mode 100644 index 0000000000..6207316593 --- /dev/null +++ b/src/lib/helpers/oauth2-scopes.ts @@ -0,0 +1,90 @@ +import type { ComponentType } from 'svelte'; +import { + IconShieldCheck, + IconUser, + IconMail, + IconIdentification, + IconKey +} from '@appwrite.io/pink-icons-svelte'; + +export interface ScopeDescriptor { + id: string; + title: string; + description: string; + icon: ComponentType; +} + +/** + * This consent screen always authorizes against the Appwrite **console** + * project. On the server, any OAuth2 access token issued for the console + * project is granted the full `users` (member) role — the same access a + * signed-in console session has — regardless of the OIDC scopes requested. + * The `openid`/`profile`/`email` scopes only shape the OIDC identity claims; + * they do NOT limit what the application can do. So the consent screen must + * lead with the full-access reality rather than implying read-only access. + */ +export const FULL_ACCESS_SCOPE: ScopeDescriptor = { + id: '__full_access__', + title: 'Full access to your account', + description: 'Manage your organizations, projects, and all their resources on your behalf.', + icon: IconShieldCheck +}; + +const BUILTIN_SCOPES: Record> = { + openid: { + title: 'Verify your identity', + description: 'Confirm who you are using your Appwrite account.', + icon: IconIdentification + }, + profile: { + title: 'View your profile', + description: 'Read your name and profile details.', + icon: IconUser + }, + email: { + title: 'View your email address', + description: 'Read the email address associated with your account.', + icon: IconMail + } +}; + +function titleizeScope(scope: string): string { + const cleaned = scope.replace(/[._:-]+/g, ' ').trim(); + if (!cleaned) return scope; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +export function describeScope(scope: string): ScopeDescriptor { + const builtin = BUILTIN_SCOPES[scope]; + if (builtin) { + return { id: scope, ...builtin }; + } + return { + id: scope, + title: titleizeScope(scope), + description: `Access to ${scope}.`, + icon: IconKey + }; +} + +export function describeScopes(scopes: string[]): ScopeDescriptor[] { + return scopes.map(describeScope); +} + +// Identity scopes shown (in this order) as secondary detail beneath the +// full-access item. `openid` is intentionally omitted — identity verification +// is implied by full account access, so listing it separately is redundant. +const CONSENT_IDENTITY_SCOPES = ['profile', 'email'] as const; + +/** + * Build the permission list for the console OAuth2 consent screen. Always leads + * with the full-access item (the true effect of authorizing), followed by the + * identity scopes the application actually reads (profile, email) when present. + */ +export function describeConsentScopes(scopes: string[]): ScopeDescriptor[] { + const requested = new Set(scopes); + const identity = CONSENT_IDENTITY_SCOPES.filter((scope) => requested.has(scope)).map( + describeScope + ); + return [FULL_ACCESS_SCOPE, ...identity]; +} diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index ddfc39d062..cdea8ebd19 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -2,6 +2,7 @@ import { isMultiRegionSupported, VARS } from '$lib/system'; import { registerImpersonationClients, restoreImpersonation } from '$lib/appwrite/impersonation'; import { Account, + Apps, Assistant, Avatars, Backups, @@ -12,6 +13,7 @@ import { Locale, Messaging, Migrations, + Oauth2, Organization, Project, Project as ProjectApi, @@ -48,6 +50,8 @@ function createConsoleSdk(client: Client) { return { client, account: new Account(client), + apps: new Apps(client), + oauth2: new Oauth2(client), avatars: new Avatars(client), functions: new Functions(client), health: new Health(client), diff --git a/src/routes/(public)/(guest)/login/+page.svelte b/src/routes/(public)/(guest)/login/+page.svelte index e45ae0b080..aca660b828 100644 --- a/src/routes/(public)/(guest)/login/+page.svelte +++ b/src/routes/(public)/(guest)/login/+page.svelte @@ -62,6 +62,37 @@ return; } + // Honor the `redirect` query param so OAuth2 consent/device flows + // (and other deep links) resume after email login. MFA-safe: if + // additional factors are required, fall through to invalidate(ACCOUNT) + // so the root layout routes to /mfa carrying the redirect param from + // the current /login URL. + const redirect = page.url.searchParams.get('redirect'); + if (redirect) { + try { + await sdk.forConsole.account.get(); + // Resume to the stored redirect exactly — it already carries + // its own query string. Appending the login page's remaining + // search params would leak login-only params (e.g. `message`) + // into the OAuth2 request route. + await goto(redirect); + await invalidate(Dependencies.ACCOUNT); + return; + } catch (mfaError) { + if (mfaError?.type !== 'user_more_factors_required') { + addNotification({ + type: 'error', + message: mfaError.message + }); + trackError(mfaError, Submit.AccountLogin); + disabled = false; + return; + } + // MFA required: fall through so the root layout redirects to + // /mfa with the redirect param preserved in the URL. + } + } + // no specific redirect, so redirect will happen through invalidating the account await invalidate(Dependencies.ACCOUNT); } catch (error) { diff --git a/src/routes/(public)/oauth2/+layout.svelte b/src/routes/(public)/oauth2/+layout.svelte new file mode 100644 index 0000000000..39bc61b821 --- /dev/null +++ b/src/routes/(public)/oauth2/+layout.svelte @@ -0,0 +1,69 @@ + + +
+
+
+ +
+
+
+ POWERED BY + {#if $app.themeInUse === 'dark'} + Appwrite Logo + {:else} + Appwrite Logo + {/if} +
+
+ + diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte new file mode 100644 index 0000000000..d9eabec4f6 --- /dev/null +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -0,0 +1,366 @@ + + + + + + {#if app.logoUri} + + {:else} +
+ {appInitial} +
+ {/if} + + + Authorize {app.name} + + + {app.tagline || `${app.name} wants to access your Appwrite account.`} + + +
+ + + + This will allow {app.name} to + +
    + {#each scopes as scope (scope.id)} +
  • + + + + + {scope.title} + {scope.description} + +
  • + {/each} +
+
+ + {#if details.length > 0} + + + Requested resources + +
    + {#each details as detail, i (`${detail.type}-${i}`)} +
  • + + {detail.type} +
  • + {/each} +
+
+ {/if} + + {#if error} +
+ + + {error} + +
+ {/if} + +
+ + + + +
+ + + {#if accountLabel} + Signed in as {accountLabel}.{' '} + {/if} + {#if flow === 'authorization' && redirectHost} + You'll be redirected to {redirectHost}. + {/if} + {#if flow === 'device'} + After authorizing, return to your device. + {/if} + + + {#if app.privacyPolicyUrl || app.termsUrl} + + {#if app.privacyPolicyUrl} + + Privacy Policy + + + {/if} + {#if app.privacyPolicyUrl && app.termsUrl} + {' · '} + {/if} + {#if app.termsUrl} + + Terms of Service + + + {/if} + + {/if} +
+
+ + diff --git a/src/routes/(public)/oauth2/consent/+page.svelte b/src/routes/(public)/oauth2/consent/+page.svelte new file mode 100644 index 0000000000..829362e2cd --- /dev/null +++ b/src/routes/(public)/oauth2/consent/+page.svelte @@ -0,0 +1,219 @@ + + + + Authorize application - Appwrite + + + + + diff --git a/src/routes/(public)/oauth2/device/+page.svelte b/src/routes/(public)/oauth2/device/+page.svelte new file mode 100644 index 0000000000..cc06f4bad9 --- /dev/null +++ b/src/routes/(public)/oauth2/device/+page.svelte @@ -0,0 +1,326 @@ + + + + Connect a device - Appwrite + + +
+
+ {#if phase === 'loading'} +
+ +
+ {:else if phase === 'enter-code'} + +
+ + +
+ +
+ + + {hasPrefilledCode ? 'Confirm your code' : 'Connect a device'} + + + {hasPrefilledCode + ? 'Make sure this matches the code shown on your device, then continue.' + : 'Enter the code shown on your device to continue.'} + + +
+ + + + { + code = normalizeUserCode(e.currentTarget.value); + error = null; + }} + placeholder="XXXXXXXX" + autofocus + autocomplete="off" + autocapitalize="characters" + spellcheck="false" + maxlength={12} + disabled={submitting} + class="code-input" /> + {#if error} + + {error} + + {/if} + + + + + {#if account} + + Signed in as + {account.email || account.name}. + + {/if} +
+
+
+ {:else if phase === 'consent' && grant && app} + + {:else if phase === 'approved'} + + +
+ +
+ Device connected + + You've authorized {app?.name ?? 'the application'}. You can return to your + device — it will continue automatically. + +
+
+ {:else if phase === 'denied'} + + +
+ +
+ Request cancelled + + No access was granted. You can close this page. + +
+
+ {/if} +
+
+ +