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
7 changes: 4 additions & 3 deletions bun.lock

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
90 changes: 90 additions & 0 deletions src/lib/helpers/oauth2-scopes.ts
Original file line number Diff line number Diff line change
@@ -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<string, Omit<ScopeDescriptor, 'id'>> = {
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];
}
4 changes: 4 additions & 0 deletions src/lib/stores/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isMultiRegionSupported, VARS } from '$lib/system';
import { registerImpersonationClients, restoreImpersonation } from '$lib/appwrite/impersonation';
import {
Account,
Apps,
Assistant,
Avatars,
Backups,
Expand All @@ -12,6 +13,7 @@ import {
Locale,
Messaging,
Migrations,
Oauth2,
Organization,
Project,
Project as ProjectApi,
Expand Down Expand Up @@ -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),
Expand Down
31 changes: 31 additions & 0 deletions src/routes/(public)/(guest)/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions src/routes/(public)/oauth2/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script lang="ts">
import { base } from '$app/paths';
import { app } from '$lib/stores/app';
import { loading } from '$routes/store';
import { Typography } from '@appwrite.io/pink-svelte';

loading.set(false);
</script>

<div class="auth-bg">
<section>
<div class="oauth2-shell">
<slot />
</div>
</section>
<footer>
<Typography.Eyebrow color="--fgcolor-neutral-secondary">POWERED BY</Typography.Eyebrow>
{#if $app.themeInUse === 'dark'}
<img
src="{base}/images/appwrite-logo-dark.svg"
width="120"
height="22"
alt="Appwrite Logo" />
{:else}
<img
src="{base}/images/appwrite-logo-light.svg"
width="120"
height="22"
alt="Appwrite Logo" />
{/if}
</footer>
</div>

<style lang="scss">
.auth-bg {
position: fixed;
background: var(--bgcolor-neutral-primary, #fff);
background-size: cover;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: space-between;
section {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-inline: 1rem;
}
.oauth2-shell {
width: 100%;
max-width: 28rem;
}
footer {
padding: 2rem 1rem;
display: flex;
gap: 0.5rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
}
</style>
Loading