diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index a9c28f8580..1430b05a20 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -456,6 +456,26 @@ public function getFiles(): array 'destination' => 'lib/sdks.ts', 'template' => 'cli/lib/sdks.ts', ], + [ + 'scope' => 'copy', + 'destination' => 'lib/flags.ts', + 'template' => 'cli/lib/flags.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/auth/oauth.ts', + 'template' => 'cli/lib/auth/oauth.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/auth/session.ts', + 'template' => 'cli/lib/auth/session.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/auth/login.ts', + 'template' => 'cli/lib/auth/login.ts', + ], [ 'scope' => 'copy', 'destination' => 'lib/services.ts', diff --git a/templates/cli/bun.lock.twig b/templates/cli/bun.lock.twig index 9e8ac0810a..b7ba80e76f 100644 --- a/templates/cli/bun.lock.twig +++ b/templates/cli/bun.lock.twig @@ -5,7 +5,7 @@ "": { "name": "{{ language.params.npmPackage|caseDash }}", "dependencies": { - "@appwrite.io/console": "^14.0.0", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@54cebb6", "chalk": "4.1.2", "chokidar": "^3.6.0", "cli-progress": "^3.12.0", @@ -51,7 +51,7 @@ "tmp": "^0.2.6", }, "packages": { - "@appwrite.io/console": ["@appwrite.io/console@14.0.0", "", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-OazmwL0CGA/a3KrbKx4ljEOlYd07/MBYVgJ6GlBMKv2O2etI1RLqLvXF29LV9QGwrOgN5EwYHmOotfw8r0PG8Q=="], + "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@54cebb6", { "dependencies": { "json-bigint": "1.0.0" } }], "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], diff --git a/templates/cli/lib/auth/login.ts b/templates/cli/lib/auth/login.ts new file mode 100644 index 0000000000..0f61494f2f --- /dev/null +++ b/templates/cli/lib/auth/login.ts @@ -0,0 +1,469 @@ +import inquirer from "inquirer"; +import { Account, type Models } from "@appwrite.io/console"; +import { sdkForConsole } from "../sdks.js"; +import { globalConfig, normalizeCloudConsoleEndpoint } from "../config.js"; +import { EXECUTABLE_NAME } from "../constants.js"; +import { success, hint, warn, log } from "../parser.js"; +import { isCloudLoginEndpoint, isRegionalCloudEndpoint } from "../utils.js"; +import { isFlagEnabled } from "../flags.js"; +import ID from "../id.js"; +import { + questionsListFactors, + questionsLogin, + questionsMFAChallenge, + questionsSwitchAccount, +} from "../questions.js"; +import ClientLegacy from "../client.js"; +import { + OAUTH2_CLIENT_ID, + OAUTH2_SCOPES, + createOauth2, + decodeIdToken, + pollForDeviceToken, +} from "./oauth.js"; +import { + createLegacyConsoleClient, + hasAuthSession, + removeCurrentSession, + removeLegacySessionsExcept, + restoreCurrentSession, + deleteServerSession, +} from "./session.js"; + +const DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"; + +interface AppwriteError { + type?: string; + response?: string; +} + +const isGuestUnauthorizedError = (err: unknown): err is AppwriteError => + (err as AppwriteError)?.type === "general_unauthorized_scope" || + (err as AppwriteError)?.response === "general_unauthorized_scope"; + +const isMfaRequiredError = (err: unknown): err is AppwriteError => + (err as AppwriteError)?.type === "user_more_factors_required" || + (err as AppwriteError)?.response === "user_more_factors_required"; + +const startWaitingForApprovalSpinner = (): (() => void) => { + const message = "Waiting for approval..."; + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + if (!process.stdout.isTTY) { + process.stdout.write(`${message}\n`); + return () => {}; + } + + let frame = 0; + process.stdout.write(`${frames[frame]} ${message}`); + + const interval = setInterval(() => { + frame = (frame + 1) % frames.length; + process.stdout.write(`\r${frames[frame]} ${message}`); + }, 80); + + return () => { + clearInterval(interval); + process.stdout.write("\r\x1b[K"); + }; +}; + +export const getCurrentAccount = async (): Promise => { + if (globalConfig.getEndpoint() === "" || !hasAuthSession()) { + return null; + } + + // sdkForConsole normalizes the endpoint when building the console client, so + // we must not persist it back into the session here — that would overwrite a + // regional Cloud endpoint and route later project calls to the generic host. + const client = await sdkForConsole(); + const accountClient = new Account(client); + + try { + const account = await accountClient.get(); + globalConfig.setEmail(account.email); + return account; + } catch (err) { + if (isGuestUnauthorizedError(err)) { + removeCurrentSession(); + } + return null; + } +}; + +const completeMfaLogin = async ({ + client, + legacyClient, + mfa, + code, +}: { + client: Awaited>; + legacyClient: ClientLegacy; + mfa?: string; + code?: string; +}): Promise => { + let accountClient = new Account(client); + + const savedCookie = globalConfig.getCookie(); + if (savedCookie) { + legacyClient.setCookie(savedCookie); + client.setCookie(savedCookie); + } + + const { factor } = mfa + ? { factor: mfa } + : await inquirer.prompt(questionsListFactors); + const challenge = await accountClient.createMfaChallenge(factor); + + const { otp } = code + ? { otp: code } + : await inquirer.prompt(questionsMFAChallenge); + await legacyClient.call( + "PUT", + "/account/mfa/challenges", + { + "content-type": "application/json", + }, + { + challengeId: challenge.$id, + otp, + }, + ); + + const updatedCookie = globalConfig.getCookie(); + if (updatedCookie) { + client.setCookie(updatedCookie); + } + + accountClient = new Account(client); + return accountClient.get(); +}; + +const loginWithEmailPassword = async ({ + id, + oldCurrent, + configEndpoint, + email, + password, + endpoint, + mfa, + code, +}: { + id: string; + oldCurrent: string; + configEndpoint: string; + email: string; + password: string; + endpoint?: string; + mfa?: string; + code?: string; +}): Promise => { + globalConfig.addSession(id, { endpoint: configEndpoint }); + globalConfig.setCurrentSession(id); + globalConfig.setEndpoint(configEndpoint); + globalConfig.setEmail(email); + + const legacyClient = createLegacyConsoleClient(configEndpoint); + const client = await sdkForConsole({ requiresAuth: false }); + let accountClient = new Account(client); + let account: Models.User; + + try { + await legacyClient.call( + "POST", + "/account/sessions/email", + { + "content-type": "application/json", + }, + { email, password }, + ); + + const savedCookie = globalConfig.getCookie(); + if (savedCookie) { + legacyClient.setCookie(savedCookie); + client.setCookie(savedCookie); + } + + accountClient = new Account(client); + account = await accountClient.get(); + } catch (err) { + if (isMfaRequiredError(err)) { + try { + account = await completeMfaLogin({ + client, + legacyClient, + mfa, + code, + }); + } catch (mfaErr) { + globalConfig.removeSession(id); + restoreCurrentSession(oldCurrent); + throw mfaErr; + } + } else { + globalConfig.removeSession(id); + globalConfig.setCurrentSession(oldCurrent); + + if ( + endpoint !== DEFAULT_ENDPOINT && + ((err as AppwriteError)?.type === "user_invalid_credentials" || + (err as AppwriteError)?.response === "user_invalid_credentials") + ) { + log("Use the --endpoint option for self-hosted instances"); + } + + throw err; + } + } + + globalConfig.setEmail(account.email); + success("Successfully signed in as " + account.email); + hint( + "Next you can create or link to your project using 'appwrite init project'", + ); +}; + +const switchToAccount = async ({ + oldCurrent, + accountId, +}: { + oldCurrent: string; + accountId: string; +}): Promise => { + if (!globalConfig.getSessionIds().includes(accountId)) { + throw Error("Session ID not found"); + } + + if (accountId === oldCurrent) { + const account = await getCurrentAccount(); + if (account) { + success(`Already using ${account.email}`); + return; + } + throw new Error( + `Selected account session is no longer valid. Run '${EXECUTABLE_NAME} login --switch' again.`, + ); + } + + globalConfig.setCurrentSession(accountId); + + let account: Models.User | null = null; + try { + account = await getCurrentAccount(); + } catch (err) { + restoreCurrentSession(oldCurrent); + throw err; + } + + if (!account) { + restoreCurrentSession(oldCurrent); + throw new Error( + `Selected account session is no longer valid. Run '${EXECUTABLE_NAME} login --switch' again.`, + ); + } + + success(`Switched to ${account.email}`); +}; + +const loginWithOAuthDevice = async ({ + id, + oldCurrent, + configEndpoint, +}: { + id: string; + oldCurrent: string; + configEndpoint: string; +}): Promise => { + const clientId = OAUTH2_CLIENT_ID; + const oauth2 = createOauth2(configEndpoint); + + globalConfig.addSession(id, { endpoint: configEndpoint, clientId }); + globalConfig.setCurrentSession(id); + globalConfig.setEndpoint(configEndpoint); + + let deviceAuth; + try { + deviceAuth = await oauth2.createDeviceAuthorization({ + clientId, + scope: OAUTH2_SCOPES, + }); + } catch (err) { + globalConfig.removeSession(id); + globalConfig.setCurrentSession(oldCurrent); + throw err; + } + + const verificationUri = + deviceAuth.verification_uri_complete || deviceAuth.verification_uri; + process.stdout.write( + "\nTo sign in, confirm the code below in your browser:\n\n" + + ` Code: ${deviceAuth.user_code}\n` + + ` URL: ${verificationUri}\n\n`, + ); + + let token: Models.Oauth2Token | null = null; + const stopWaitingForApprovalSpinner = startWaitingForApprovalSpinner(); + + try { + token = await pollForDeviceToken(oauth2, deviceAuth, clientId); + } catch (err) { + globalConfig.removeSession(id); + globalConfig.setCurrentSession(oldCurrent); + throw err; + } finally { + stopWaitingForApprovalSpinner(); + } + + if (!token || !token.access_token) { + globalConfig.removeSession(id); + globalConfig.setCurrentSession(oldCurrent); + throw new Error("Device authorization timed out or was denied."); + } + + const tokenExpiry = Date.now() + token.expires_in * 1000; + globalConfig.setAccessToken(token.access_token); + globalConfig.setRefreshToken(token.refresh_token || ""); + globalConfig.setTokenExpiry(tokenExpiry); + + let tokenEmail = ""; + if (token.id_token) { + tokenEmail = decodeIdToken(token.id_token).email || ""; + } + + if (tokenEmail) { + globalConfig.setEmail(tokenEmail); + } + + let account: Models.User | null = null; + try { + account = await getCurrentAccount(); + if (!account?.email) { + throw new Error("Unable to verify the new session."); + } + } catch (err) { + await deleteServerSession(id); + globalConfig.removeSession(id); + restoreCurrentSession(oldCurrent); + throw err; + } + + globalConfig.setEmail(account.email); + + const { removed: removedLegacySessions, failed: failedLegacySessions } = + await removeLegacySessionsExcept(id); + + success("Successfully signed in as " + globalConfig.getEmail()); + if (removedLegacySessions > 0) { + hint("Removed legacy cookie session data from this CLI configuration."); + } + if (failedLegacySessions > 0) { + warn("Could not revoke all legacy sessions; kept them in local config."); + } + hint( + "Next you can create or link to your project using 'appwrite init project'", + ); +}; + +export const loginCommand = async ({ + email, + password, + endpoint, + mfa, + code, + switch: switchAccount, + new: newAccount, +}: { + email?: string; + password?: string; + endpoint?: string; + mfa?: string; + code?: string; + switch?: boolean; + new?: boolean; +}): Promise => { + let oldCurrent = globalConfig.getCurrentSession(); + + if (switchAccount && newAccount) { + throw new Error("Use either --switch or --new, not both."); + } + + if (endpoint && isRegionalCloudEndpoint(endpoint)) { + throw new Error( + `Cloud login uses ${DEFAULT_ENDPOINT}. Regional Cloud endpoints are for project API calls, not account login.`, + ); + } + + const configEndpoint = normalizeCloudConsoleEndpoint( + (endpoint ?? globalConfig.getEndpoint()) || DEFAULT_ENDPOINT, + ); + const shouldUseCloudLogin = + isFlagEnabled("oauthLogin") && isCloudLoginEndpoint(configEndpoint); + + oldCurrent = globalConfig.getCurrentSession(); + + if (oldCurrent !== "" && !newAccount) { + let account: Models.User | null = null; + try { + account = await getCurrentAccount(); + } catch (_err) { + account = null; + } + oldCurrent = globalConfig.getCurrentSession(); + + if (account) { + if ( + isFlagEnabled("oauthLogin") && + !globalConfig.getAccessToken() && + globalConfig.getCookie() + ) { + warn( + `You are using a legacy cookie session. Run '${EXECUTABLE_NAME} login --new' to switch to the new browser-based login flow.`, + ); + } + + if (!email && !password && !endpoint && !switchAccount && !newAccount) { + success("Already logged in as " + account.email); + hint(`Use '${EXECUTABLE_NAME} login --new' to add another account`); + return; + } + } + } + + let answers; + if (switchAccount) { + if (!globalConfig.getSessions().some((session) => session.email)) { + throw new Error( + `No signed-in accounts found. Run '${EXECUTABLE_NAME} login' to sign in.`, + ); + } + answers = await inquirer.prompt(questionsSwitchAccount); + } else if (!shouldUseCloudLogin) { + answers = + email && password + ? { email, password } + : await inquirer.prompt(questionsLogin); + } + + if (switchAccount && answers?.accountId) { + await switchToAccount({ oldCurrent, accountId: answers.accountId }); + return; + } + + const id = ID.unique(); + + if (!shouldUseCloudLogin) { + await loginWithEmailPassword({ + id, + oldCurrent, + configEndpoint, + email: answers.email, + password: answers.password, + endpoint, + mfa, + code, + }); + return; + } + + await loginWithOAuthDevice({ id, oldCurrent, configEndpoint }); +}; diff --git a/templates/cli/lib/auth/oauth.ts b/templates/cli/lib/auth/oauth.ts new file mode 100644 index 0000000000..9d3a304e4a --- /dev/null +++ b/templates/cli/lib/auth/oauth.ts @@ -0,0 +1,177 @@ +import { + AppwriteException, + Client, + Oauth2, + type Models, +} from "@appwrite.io/console"; +import { globalConfig, normalizeCloudConsoleEndpoint } from "../config.js"; +import { EXECUTABLE_NAME } from "../constants.js"; + +export const OAUTH2_CLIENT_ID = "appwrite-cli"; +export const OAUTH2_SCOPES = "openid email profile"; + +export type DeviceAuthorization = Awaited< + ReturnType +>; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Build an Oauth2 client for the console project at the given endpoint. + * The endpoint is used as-is; callers normalize it when required. + */ +export const createOauth2 = (endpoint: string): Oauth2 => { + const client = new Client() + .setEndpoint(endpoint) + .setProject("console") + .setSelfSigned(globalConfig.getSelfSigned()); + return new Oauth2(client); +}; + +export const decodeIdToken = ( + idToken: string, +): { email?: string; name?: string; sub?: string } => { + try { + const payload = idToken.split(".")[1]; + if (!payload) return {}; + const decoded = JSON.parse( + Buffer.from(payload, "base64url").toString("utf-8"), + ); + return { + email: decoded.email, + name: decoded.name, + sub: decoded.sub, + }; + } catch (_error) { + return {}; + } +}; + +const matchesOAuthError = (err: unknown, code: string): boolean => { + if (!(err instanceof AppwriteException)) { + return false; + } + + const response = typeof err.response === "string" ? err.response : ""; + + return err.type === code || err.message === code || response.includes(code); +}; + +export const isAuthorizationPendingError = (err: unknown): boolean => + matchesOAuthError(err, "authorization_pending") || + matchesOAuthError(err, "slow_down"); + +const isSlowDownError = (err: unknown): boolean => + matchesOAuthError(err, "slow_down"); + +// An empty/unrecognized error body during polling (e.g. a 400 with no type, +// message, or response) is treated as a transient pending response rather than +// aborting the device flow. +const isEmptyDevicePollError = (err: unknown): boolean => { + if (!(err instanceof AppwriteException)) { + return false; + } + + const type = typeof err.type === "string" ? err.type.trim() : ""; + const message = typeof err.message === "string" ? err.message.trim() : ""; + const response = typeof err.response === "string" ? err.response.trim() : ""; + + return type === "" && message === "" && response === ""; +}; + +/** + * Poll the token endpoint until the device authorization is approved. + * Returns the token, or `null` if the authorization window expires. + * Pending and empty-body responses are retried; `slow_down` additionally + * increases the polling interval by 5s (RFC 8628 §3.5); any other error throws. + */ +export const pollForDeviceToken = async ( + oauth2: Oauth2, + deviceAuth: DeviceAuthorization, + clientId: string, +): Promise => { + const expiresAt = Date.now() + deviceAuth.expires_in * 1000; + // Default to a 5s interval when the server omits one (RFC 8628 §3.5); + // an omitted interval would otherwise be NaN and busy-poll the endpoint. + let intervalMs = + (Number.isFinite(deviceAuth.interval) && deviceAuth.interval >= 0 + ? deviceAuth.interval + : 5) * 1000; + + while (Date.now() < expiresAt) { + await sleep(intervalMs); + + let token: Models.Oauth2Token; + try { + token = await oauth2.createToken({ + grantType: "urn:ietf:params:oauth:grant-type:device_code", + deviceCode: deviceAuth.device_code, + clientId, + }); + } catch (err) { + if (isAuthorizationPendingError(err) || isEmptyDevicePollError(err)) { + if (isSlowDownError(err)) { + intervalMs += 5000; + } + continue; + } + throw err; + } + + if (token) { + return token; + } + } + + return null; +}; + +export const revokeRefreshToken = async ( + endpoint: string, + refreshToken: string, + clientId: string, +): Promise => { + const oauth2 = createOauth2(endpoint); + await oauth2.revoke({ + projectId: "console", + token: refreshToken, + tokenTypeHint: "refresh_token", + clientId, + }); +}; + +export const getValidAccessToken = async ( + endpoint: string, +): Promise => { + const accessToken = globalConfig.getAccessToken(); + const refreshToken = globalConfig.getRefreshToken(); + const tokenExpiry = globalConfig.getTokenExpiry(); + const clientId = globalConfig.getClientId() || OAUTH2_CLIENT_ID; + const consoleEndpoint = normalizeCloudConsoleEndpoint(endpoint); + + if (accessToken && tokenExpiry > Date.now() + 60_000) { + return accessToken; + } + + if (!refreshToken) { + throw new Error( + `Session expired. Please run \`${EXECUTABLE_NAME} login\` to create a new session.`, + ); + } + + const oauth2 = createOauth2(consoleEndpoint); + const token = await oauth2.createToken({ + grantType: "refresh_token", + refreshToken, + clientId, + }); + const newExpiry = Date.now() + token.expires_in * 1000; + globalConfig.setAccessToken(token.access_token); + if (token.refresh_token) { + globalConfig.setRefreshToken(token.refresh_token); + } + globalConfig.setTokenExpiry(newExpiry); + + return token.access_token; +}; diff --git a/templates/cli/lib/auth/session.ts b/templates/cli/lib/auth/session.ts new file mode 100644 index 0000000000..50446fd314 --- /dev/null +++ b/templates/cli/lib/auth/session.ts @@ -0,0 +1,205 @@ +import { globalConfig, normalizeCloudConsoleEndpoint } from "../config.js"; +import type { SessionData } from "../types.js"; +import ClientLegacy from "../client.js"; +import { OAUTH2_CLIENT_ID, revokeRefreshToken } from "./oauth.js"; + +/** + * Typed accessor for a stored session, avoiding repeated inline casts. + */ +export const getSession = (sessionId: string): SessionData | undefined => + globalConfig.get(sessionId) as SessionData | undefined; + +export const createLegacyConsoleClient = ( + endpoint: string, + selfSigned: boolean = globalConfig.getSelfSigned(), +): ClientLegacy => { + const legacyClient = new ClientLegacy(); + legacyClient.setEndpoint(endpoint); + legacyClient.setProject("console"); + if (selfSigned) { + legacyClient.setSelfSigned(true); + } + return legacyClient; +}; + +export const hasAuthSession = (): boolean => + globalConfig.getAccessToken() !== "" || globalConfig.getCookie() !== ""; + +/** + * A session that exists only in local config (no server-side credential to + * revoke), e.g. an endpoint/key-only entry created by `client --endpoint`. + */ +export const isLocalOnlySession = (sessionId: string): boolean => { + const session = getSession(sessionId); + return Boolean(session && !session.refreshToken && !session.cookie); +}; + +/** + * A legacy cookie session that predates the OAuth device-login flow. + */ +export const isLegacySession = (sessionId: string): boolean => { + const session = getSession(sessionId); + return Boolean(session?.cookie && !session?.accessToken); +}; + +export const getSessionAccountKey = (sessionId: string): string | undefined => { + const session = getSession(sessionId); + if (!session) return undefined; + return `${session.email ?? ""}|${normalizeCloudConsoleEndpoint( + session.endpoint ?? "", + )}`; +}; + +export const restoreCurrentSession = (sessionId: string): void => { + globalConfig.setCurrentSession( + globalConfig.getSessionIds().includes(sessionId) ? sessionId : "", + ); +}; + +export const restoreCurrentSessionFallback = ( + preferredSessionId: string, + fallbackSessionIds: string[], +): void => { + const sessionIds = globalConfig.getSessionIds(); + globalConfig.setCurrentSession( + [preferredSessionId, ...fallbackSessionIds].find((sessionId) => + sessionIds.includes(sessionId), + ) ?? "", + ); +}; + +export const removeCurrentSession = (): void => { + const current = globalConfig.getCurrentSession(); + globalConfig.setCurrentSession(""); + globalConfig.removeSession(current); +}; + +/** + * Revoke a session on the server. OAuth sessions revoke their refresh token; + * legacy cookie sessions delete the current server session. Returns whether + * the server-side cleanup succeeded. + */ +export const deleteServerSession = async ( + sessionId: string, +): Promise => { + const session = getSession(sessionId); + if (!session?.endpoint) { + return false; + } + + try { + if (session.refreshToken) { + await revokeRefreshToken( + session.endpoint, + session.refreshToken, + session.clientId || OAUTH2_CLIENT_ID, + ); + return true; + } + + if (session.cookie) { + // Use the target session's own self-signed setting, not the current + // session's, so revoking a self-signed legacy session works even when a + // different (e.g. new OAuth) session is current. + const legacyClient = createLegacyConsoleClient( + session.endpoint, + Boolean(session.selfSigned), + ); + legacyClient.setCookie(session.cookie); + await legacyClient.call("DELETE", "/account/sessions/current", { + "content-type": "application/json", + }); + return true; + } + + return false; + } catch (_e) { + return false; + } +}; + +/** + * Log out a set of session IDs: local-only sessions are removed locally; + * the rest are revoked server-side and removed locally on success. Returns + * the IDs that could not be revoked so callers can restore/warn as needed. + */ +export const logoutSessions = async ( + sessionIds: string[], +): Promise<{ failed: number; failedIds: string[] }> => { + let failed = 0; + const failedIds: string[] = []; + + for (const sessionId of sessionIds) { + if (isLocalOnlySession(sessionId)) { + globalConfig.removeSession(sessionId); + continue; + } + + globalConfig.setCurrentSession(sessionId); + const serverDeleted = await deleteServerSession(sessionId); + if (serverDeleted) { + globalConfig.removeSession(sessionId); + } else { + failed++; + failedIds.push(sessionId); + } + } + + return { failed, failedIds }; +}; + +export const removeLegacySessionsExcept = async ( + sessionIdToKeep: string, +): Promise<{ removed: number; failed: number }> => { + let removed = 0; + let failed = 0; + + for (const sessionId of globalConfig.getSessionIds()) { + if (sessionId === sessionIdToKeep || !isLegacySession(sessionId)) { + continue; + } + + const serverDeleted = await deleteServerSession(sessionId); + if (serverDeleted) { + globalConfig.removeSession(sessionId); + removed++; + } else { + failed++; + } + } + + return { removed, failed }; +}; + +/** + * Given selected session IDs, determine which local sessions belong to the + * selected accounts and should be individually logged out from the server. + * + * @param selectedSessionIds Array of session IDs to process for logout. + * @returns Session IDs that belong to selected account groups. + */ +export const planSessionLogout = (selectedSessionIds: string[]): string[] => { + // Map to group all session IDs by their unique account key (email+endpoint) + const sessionIdsByAccount = new Map(); + for (const sessionId of globalConfig.getSessionIds()) { + const key = getSessionAccountKey(sessionId); + if (!key) continue; // Skip sessions without proper account key + + // For each account key, gather all associated session IDs + const ids = sessionIdsByAccount.get(key) ?? []; + ids.push(sessionId); + sessionIdsByAccount.set(key, ids); + } + + // Map to store one selected session ID per unique account for server logout + const selectedByAccount = new Map(); + for (const selectedSessionId of selectedSessionIds) { + const key = getSessionAccountKey(selectedSessionId); + if (!key || selectedByAccount.has(key)) continue; // Skip if key missing or already considered for this account + selectedByAccount.set(key, selectedSessionId); + } + + return Array.from(selectedByAccount.keys()).flatMap( + (accountKey) => sessionIdsByAccount.get(accountKey) ?? [], + ); +}; diff --git a/templates/cli/lib/commands/generic.ts b/templates/cli/lib/commands/generic.ts index 2e4e6883d8..9e88e20fae 100644 --- a/templates/cli/lib/commands/generic.ts +++ b/templates/cli/lib/commands/generic.ts @@ -1,12 +1,7 @@ import inquirer from "inquirer"; import { Command } from "commander"; import { Client } from "@appwrite.io/console"; -import { sdkForConsole } from "../sdks.js"; -import { - globalConfig, - localConfig, - normalizeCloudConsoleEndpoint, -} from "../config.js"; +import { globalConfig, localConfig } from "../config.js"; import { EXECUTABLE_NAME } from "../constants.js"; import { actionRunner, @@ -20,397 +15,23 @@ import { drawTable, cliConfig, } from "../parser.js"; -import { isCloudHostname } from "../utils.js"; import ID from "../id.js"; +import { questionsLogout } from "../questions.js"; +import { getCurrentAccount, loginCommand } from "../auth/login.js"; import { - questionsLogin, - questionsLogout, - questionsListFactors, - questionsMFAChallenge, - questionsSwitchAccount, -} from "../questions.js"; -import { - Account, - Client as ConsoleClient, - type Models, -} from "@appwrite.io/console"; -import ClientLegacy from "../client.js"; - -const DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"; - -interface AppwriteError { - type?: string; - response?: string; -} - -const isMfaRequiredError = (err: unknown): err is AppwriteError => - (err as AppwriteError)?.type === "user_more_factors_required" || - (err as AppwriteError)?.response === "user_more_factors_required"; + hasAuthSession, + logoutSessions, + planSessionLogout, + restoreCurrentSessionFallback, +} from "../auth/session.js"; -const isGuestUnauthorizedError = (err: unknown): err is AppwriteError => - (err as AppwriteError)?.type === "general_unauthorized_scope" || - (err as AppwriteError)?.response === "general_unauthorized_scope"; - -const isRegionalCloudEndpoint = (endpoint: string): boolean => { - try { - const hostname = new URL(endpoint).hostname; - return isCloudHostname(hostname) && hostname !== "cloud.appwrite.io"; - } catch (_error) { - return false; - } -}; - -const restoreCurrentSession = (sessionId: string): void => { - globalConfig.setCurrentSession( - globalConfig.getSessionIds().includes(sessionId) ? sessionId : "", - ); -}; - -const removeCurrentSession = (): void => { - const current = globalConfig.getCurrentSession(); - globalConfig.setCurrentSession(""); - globalConfig.removeSession(current); -}; - -const getCurrentAccount = async (): Promise => { - if (globalConfig.getEndpoint() === "" || globalConfig.getCookie() === "") { - return null; - } - - const endpoint = normalizeCloudConsoleEndpoint(globalConfig.getEndpoint()); - if (endpoint !== globalConfig.getEndpoint()) { - globalConfig.setEndpoint(endpoint); - } - - const client = await sdkForConsole({ requiresAuth: false }); - const accountClient = new Account(client); - - try { - const account = await accountClient.get(); - globalConfig.setEmail(account.email); - return account; - } catch (err) { - if (isGuestUnauthorizedError(err)) { - removeCurrentSession(); - } - return null; - } -}; - -const createLegacyConsoleClient = (endpoint: string): ClientLegacy => { - const legacyClient = new ClientLegacy(); - legacyClient.setEndpoint(endpoint); - legacyClient.setProject("console"); - if (globalConfig.getSelfSigned()) { - legacyClient.setSelfSigned(true); - } - return legacyClient; -}; - -const completeMfaLogin = async ({ - client, - legacyClient, - mfa, - code, -}: { - client: ConsoleClient; - legacyClient: ClientLegacy; - mfa?: string; - code?: string; -}): Promise => { - let accountClient = new Account(client); - - const savedCookie = globalConfig.getCookie(); - if (savedCookie) { - legacyClient.setCookie(savedCookie); - client.setCookie(savedCookie); - } - - const { factor } = mfa - ? { factor: mfa } - : await inquirer.prompt(questionsListFactors); - const challenge = await accountClient.createMfaChallenge(factor); - - const { otp } = code - ? { otp: code } - : await inquirer.prompt(questionsMFAChallenge); - await legacyClient.call( - "PUT", - "/account/mfa/challenges", - { - "content-type": "application/json", - }, - { - challengeId: challenge.$id, - otp, - }, - ); - - const updatedCookie = globalConfig.getCookie(); - if (updatedCookie) { - client.setCookie(updatedCookie); - } - - accountClient = new Account(client); - return accountClient.get(); -}; - -const deleteServerSession = async (sessionId: string): Promise => { - try { - const client = await sdkForConsole(); - const accountClient = new Account(client); - await accountClient.deleteSession(sessionId); - return true; - } catch (_e) { - return false; - } -}; - -const deleteLocalSession = (accountId: string): void => { - globalConfig.removeSession(accountId); -}; - -const getSessionAccountKey = (sessionId: string): string | undefined => { - const session = globalConfig.get(sessionId) as - | { email?: string; endpoint?: string } - | undefined; - if (!session) return undefined; - return `${session.email ?? ""}|${normalizeCloudConsoleEndpoint( - session.endpoint ?? "", - )}`; -}; - -/** - * Given selected session IDs, determine which sessions should be logged out - * from the server (one per unique account) and which should be removed locally (all sessions for those accounts). - * - * @param selectedSessionIds Array of session IDs to process for logout. - * @returns Object containing `serverTargets` (sessions to logout from server) - * and `localTargets` (sessions to remove locally). - */ -const planSessionLogout = ( - selectedSessionIds: string[], -): { serverTargets: string[]; localTargets: string[] } => { - // Map to group all session IDs by their unique account key (email+endpoint) - const sessionIdsByAccount = new Map(); - for (const sessionId of globalConfig.getSessionIds()) { - const key = getSessionAccountKey(sessionId); - if (!key) continue; // Skip sessions without proper account key - - // For each account key, gather all associated session IDs - const ids = sessionIdsByAccount.get(key) ?? []; - ids.push(sessionId); - sessionIdsByAccount.set(key, ids); - } - - // Map to store one selected session ID per unique account for server logout - const selectedByAccount = new Map(); - for (const selectedSessionId of selectedSessionIds) { - const key = getSessionAccountKey(selectedSessionId); - if (!key || selectedByAccount.has(key)) continue; // Skip if key missing or already considered for this account - selectedByAccount.set(key, selectedSessionId); - } - - // Sessions to target for server logout: one per unique account - const serverTargets = Array.from(selectedByAccount.values()); - // Sessions to remove locally: all sessions under selected accounts - const localTargets = Array.from(selectedByAccount.keys()).flatMap( - (accountKey) => sessionIdsByAccount.get(accountKey) ?? [], - ); - - return { serverTargets, localTargets }; -}; - -export const loginCommand = async ({ - email, - password, - endpoint, - mfa, - code, - switch: switchAccount, - new: newAccount, -}: { - email?: string; - password?: string; - endpoint?: string; - mfa?: string; - code?: string; - switch?: boolean; - new?: boolean; -}): Promise => { - let oldCurrent = globalConfig.getCurrentSession(); - - if (switchAccount && newAccount) { - throw new Error("Use either --switch or --new, not both."); - } - - if (endpoint && isRegionalCloudEndpoint(endpoint)) { - throw new Error( - `Cloud login uses ${DEFAULT_ENDPOINT}. Regional Cloud endpoints are for project API calls, not account login.`, - ); - } - - const configEndpoint = normalizeCloudConsoleEndpoint( - (endpoint ?? globalConfig.getEndpoint()) || DEFAULT_ENDPOINT, - ); - - if (globalConfig.getCurrentSession() !== "") { - const account = await getCurrentAccount(); - oldCurrent = globalConfig.getCurrentSession(); - - if (account) { - if (!email && !password && !endpoint && !switchAccount && !newAccount) { - success("Already logged in as " + account.email); - hint(`Use '${EXECUTABLE_NAME} login --new' to add another account`); - return; - } - } - } - - let answers; - if (switchAccount) { - if (!globalConfig.getSessions().some((session) => session.email)) { - throw new Error( - `No signed-in accounts found. Run '${EXECUTABLE_NAME} login' to sign in.`, - ); - } - answers = await inquirer.prompt(questionsSwitchAccount); - } else if (email && password) { - answers = { email, password }; - } else { - answers = await inquirer.prompt(questionsLogin); - } - - if (!answers.method) { - answers.method = switchAccount ? "select" : "login"; - } - - if (answers.method === "select") { - const accountId = answers.accountId; - - if (!globalConfig.getSessionIds().includes(accountId)) { - throw Error("Session ID not found"); - } - - if (accountId === oldCurrent) { - const account = await getCurrentAccount(); - if (account) { - success(`Already using ${account.email}`); - return; - } - throw new Error( - `Selected account session is no longer valid. Run '${EXECUTABLE_NAME} login --switch' again.`, - ); - } - - globalConfig.setCurrentSession(accountId); - globalConfig.setEndpoint( - normalizeCloudConsoleEndpoint(globalConfig.getEndpoint()), - ); - - const client = await sdkForConsole({ requiresAuth: false }); - const accountClient = new Account(client); - const legacyClient = createLegacyConsoleClient( - globalConfig.getEndpoint() || DEFAULT_ENDPOINT, - ); - - try { - await accountClient.get(); - } catch (err) { - if (!isMfaRequiredError(err)) { - if (isGuestUnauthorizedError(err)) { - globalConfig.removeSession(accountId); - } - restoreCurrentSession(oldCurrent); - throw err; - } - - await completeMfaLogin({ - client, - legacyClient, - mfa, - code, - }); - } - - success(`Switched to ${globalConfig.getEmail()}`); - - return; - } - - const id = ID.unique(); - - globalConfig.addSession(id, { endpoint: configEndpoint }); - globalConfig.setCurrentSession(id); - globalConfig.setEndpoint(configEndpoint); - globalConfig.setEmail(answers.email); - - // Use legacy client for login to extract cookies from response - const legacyClient = createLegacyConsoleClient(configEndpoint); - - const client = await sdkForConsole({ requiresAuth: false }); - let accountClient = new Account(client); - - let account; - - try { - await legacyClient.call( - "POST", - "/account/sessions/email", - { - "content-type": "application/json", - }, - { - email: answers.email, - password: answers.password, - }, - ); - - const savedCookie = globalConfig.getCookie(); - - if (savedCookie) { - legacyClient.setCookie(savedCookie); - client.setCookie(savedCookie); - } - - accountClient = new Account(client); - account = await accountClient.get(); - } catch (err) { - if (isMfaRequiredError(err)) { - account = await completeMfaLogin({ - client, - legacyClient, - mfa, - code, - }); - } else { - globalConfig.removeSession(id); - globalConfig.setCurrentSession(oldCurrent); - if ( - endpoint !== DEFAULT_ENDPOINT && - (err.type === "user_invalid_credentials" || - err.response === "user_invalid_credentials") - ) { - log("Use the --endpoint option for self-hosted instances"); - } - throw err; - } - } - - success("Successfully signed in as " + account.email); - hint( - "Next you can create or link to your project using 'appwrite init project'", - ); -}; +export { loginCommand }; export const whoami = new Command("whoami") .description(commandDescriptions["whoami"]) .action( actionRunner(async () => { - if ( - globalConfig.getEndpoint() === "" || - globalConfig.getCookie() === "" - ) { + if (globalConfig.getEndpoint() === "" || !hasAuthSession()) { error("No user is signed in. To sign in, run 'appwrite login'"); return; } @@ -450,17 +71,17 @@ export const register = new Command("register") export const login = new Command("login") .description(commandDescriptions["login"]) - .option(`--email [email]`, `User email`) - .option(`--password [password]`, `User password`) + .option(`--email [email]`, `Email`) + .option(`--password [password]`, `Password`) .option( - `--endpoint [endpoint]`, - `Appwrite endpoint for self hosted instances`, + `--mfa [factor]`, + `Factor used for MFA. Must be one of: email, phone, totp, recoveryCode`, ) + .option(`--code [code]`, `Code used for MFA`) .option( - `--mfa [factor]`, - `Multi-factor authentication login factor: totp, email, phone or recoveryCode`, + `--endpoint [endpoint]`, + `Appwrite endpoint for self hosted instances`, ) - .option(`--code [code]`, `Multi-factor code`) .option(`--switch`, `Switch to another signed-in account`) .option(`--new`, `Sign in to another account`) .configureHelp({ @@ -484,16 +105,17 @@ export const logout = new Command("logout") return; } if (sessions.length === 1) { - // Try to delete from server, then remove locally - const serverDeleted = await deleteServerSession(current); - // Remove all local sessions with the same email+endpoint - const allSessionIds = globalConfig.getSessionIds(); - for (const sessId of allSessionIds) { - deleteLocalSession(sessId); - } - globalConfig.setCurrentSession(""); - if (!serverDeleted) { - hint("Could not reach server, removed local session data"); + const { failed, failedIds } = await logoutSessions( + planSessionLogout([current]), + ); + + if (failed > 0) { + restoreCurrentSessionFallback(originalCurrent, failedIds); + hint( + "Could not reach server for all sessions; kept local session data", + ); + } else { + globalConfig.setCurrentSession(""); } success("Logged out successfully"); @@ -503,17 +125,14 @@ export const logout = new Command("logout") const answers = await inquirer.prompt(questionsLogout); if (answers.accounts?.length) { - const { serverTargets, localTargets } = planSessionLogout( - answers.accounts as string[], + const { failed } = await logoutSessions( + planSessionLogout(answers.accounts as string[]), ); - for (const sessionId of serverTargets) { - globalConfig.setCurrentSession(sessionId); - await deleteServerSession(sessionId); - } - - for (const sessionId of localTargets) { - deleteLocalSession(sessionId); + if (failed > 0) { + hint( + "Could not reach server for all sessions; kept local session data", + ); } } @@ -600,21 +219,14 @@ export const client = new Command("client") ? "********" : ""; const project = localConfig.getProject(); - const cookie = globalConfig.getCookie(); - let maskedCookie = ""; - if (cookie) { - const [cookieName, cookieValueAndRest = ""] = cookie.split("=", 2); - const cookieValue = cookieValueAndRest.split(";")[0] ?? ""; - const tail = - cookieValue.length > 8 - ? cookieValue.slice(-8) - : cookieValue || "********"; - maskedCookie = `${cookieName}=...${tail}`; - } + const accessToken = globalConfig.getAccessToken(); + const maskedAccessToken = accessToken + ? `${accessToken.slice(0, 8)}...${accessToken.slice(-8)}` + : ""; const config = { endpoint: globalConfig.getEndpoint(), key: maskedKey, - cookie: maskedCookie, + accessToken: maskedAccessToken, selfSigned: globalConfig.getSelfSigned(), projectId: project.projectId ?? "", projectName: project.projectName ?? "", @@ -675,18 +287,19 @@ export const client = new Command("client") } if (reset !== undefined) { - const sessions = globalConfig.getSessions(); - - for (const sessionId of sessions.map((session) => session.id)) { - globalConfig.setCurrentSession(sessionId); - await deleteServerSession(sessionId); - } - - for (const sessionId of globalConfig.getSessionIds()) { - deleteLocalSession(sessionId); + const originalCurrent = globalConfig.getCurrentSession(); + const { failed, failedIds } = await logoutSessions( + globalConfig.getSessionIds(), + ); + + if (failed > 0) { + restoreCurrentSessionFallback(originalCurrent, failedIds); + hint( + "Could not reach server for all sessions; kept local session data", + ); + } else { + globalConfig.setCurrentSession(""); } - - globalConfig.setCurrentSession(""); } if (!debug) { diff --git a/templates/cli/lib/commands/init.ts b/templates/cli/lib/commands/init.ts index 19117e5348..5c174b1e11 100644 --- a/templates/cli/lib/commands/init.ts +++ b/templates/cli/lib/commands/init.ts @@ -290,9 +290,12 @@ const initProject = async ({ } try { - if (globalConfig.getEndpoint() === "" || globalConfig.getCookie() === "") { + if ( + globalConfig.getEndpoint() === "" || + (globalConfig.getAccessToken() === "" && globalConfig.getCookie() === "") + ) { throw new Error( - `Missing endpoint or cookie configuration. Please run '${EXECUTABLE_NAME} login' first.`, + `Missing endpoint or session configuration. Please run '${EXECUTABLE_NAME} login' first.`, ); } const client = await sdkForConsole(); diff --git a/templates/cli/lib/commands/push.ts b/templates/cli/lib/commands/push.ts index 8cdcdde4b2..36dec6045a 100644 --- a/templates/cli/lib/commands/push.ts +++ b/templates/cli/lib/commands/push.ts @@ -34,6 +34,7 @@ import { arrayEqualsUnordered, getFunctionDeploymentConsoleUrl, getSiteDeploymentConsoleUrl, + isCloudHostname, siteRequiresBuildCommand, } from "../utils.js"; import { Spinner, SPINNER_DOTS } from "../spinner.js"; @@ -699,6 +700,7 @@ export class Push { private projectClient: Client; private consoleClient: Client; private silent: boolean; + private projectRegionCache = new Map>(); constructor(projectClient: Client, consoleClient: Client, silent = false) { this.projectClient = projectClient; @@ -742,6 +744,43 @@ export class Push { } } + private async getConsoleUrlProjectRegion( + endpoint: string, + projectId: string, + ): Promise { + try { + if (isCloudHostname(new URL(endpoint).hostname)) { + return undefined; + } + } catch { + return undefined; + } + + const cached = this.projectRegionCache.get(projectId); + if (cached) { + return cached; + } + + const region = (async () => { + try { + const consoleClient = await sdkForConsole({ + requiresAuth: true, + organizationId: localConfig.getProject().organizationId, + }); + const organizationService = await getOrganizationService(consoleClient); + const project = await organizationService.getProject({ + projectId, + }); + return project.region || undefined; + } catch { + return undefined; + } + })(); + this.projectRegionCache.set(projectId, region); + + return region; + } + public async pushResources( config: ConfigType, options: PushOptions = { all: true, skipDeprecated: true }, @@ -1651,11 +1690,16 @@ export class Push { const endpoint = localConfig.getEndpoint() || globalConfig.getEndpoint(); const projectId = localConfig.getProject().projectId; + const projectRegion = await this.getConsoleUrlProjectRegion( + endpoint, + projectId, + ); const consoleUrl = getFunctionDeploymentConsoleUrl( endpoint, projectId, func["$id"], deploymentId, + projectRegion, ); let waitingSince: number | null = null; const deploymentTimeoutTracker = @@ -2159,11 +2203,16 @@ export class Push { const endpoint = localConfig.getEndpoint() || globalConfig.getEndpoint(); const projectId = localConfig.getProject().projectId; + const projectRegion = await this.getConsoleUrlProjectRegion( + endpoint, + projectId, + ); const consoleUrl = getSiteDeploymentConsoleUrl( endpoint, projectId, site["$id"], deploymentId, + projectRegion, ); let waitingSince: number | null = null; let readyWithoutScreenshotsSince: number | null = null; @@ -2981,8 +3030,7 @@ const pushSettings = async (): Promise => { config: project, consoleClient, }); - resolvedOrganizationId = - consoleClient.headers["X-Appwrite-Organization"]; + resolvedOrganizationId = consoleClient.headers["X-Appwrite-Organization"]; const organizationService = await getOrganizationService(consoleClient); const projectService = await getProjectService(); const projectId = project.projectId; diff --git a/templates/cli/lib/commands/utils/deployment.ts b/templates/cli/lib/commands/utils/deployment.ts index d97eb0f616..0b9c7b9254 100644 --- a/templates/cli/lib/commands/utils/deployment.ts +++ b/templates/cli/lib/commands/utils/deployment.ts @@ -8,6 +8,7 @@ import { Agent, WebSocket } from "undici"; import { Client, AppwriteException } from "@appwrite.io/console"; import { error } from "../../parser.js"; import { globalConfig } from "../../config.js"; +import { getValidAccessToken } from "../../auth/oauth.js"; import { Spinner } from "../../spinner.js"; const ignore: typeof ignoreModule = @@ -273,7 +274,8 @@ export async function watchDeploymentUpdates( params: WatchDeploymentUpdatesParams, ): Promise { const cookieHeader = getCookieHeader(globalConfig.getCookie()); - if (!cookieHeader) { + const hasAccessToken = globalConfig.getAccessToken() !== ""; + if (!cookieHeader && !hasAccessToken) { return null; } @@ -287,10 +289,17 @@ export async function watchDeploymentUpdates( let socket: WebSocket; try { + const headers: Record = {}; + if (cookieHeader) { + headers.cookie = cookieHeader; + } + if (hasAccessToken) { + const accessToken = await getValidAccessToken(params.endpoint); + headers.authorization = `Bearer ${accessToken}`; + } + socket = new WebSocket(getRealtimeUrl(params.endpoint), { - headers: { - cookie: cookieHeader, - }, + headers, dispatcher, }); } catch { diff --git a/templates/cli/lib/config.ts b/templates/cli/lib/config.ts index 3fcbaf5a5c..bd2933ec7f 100644 --- a/templates/cli/lib/config.ts +++ b/templates/cli/lib/config.ts @@ -1206,6 +1206,10 @@ class Global extends Config { static PREFERENCE_KEY = "key" as const; static PREFERENCE_LOCALE = "locale" as const; static PREFERENCE_MODE = "mode" as const; + static PREFERENCE_ACCESS_TOKEN = "accessToken" as const; + static PREFERENCE_REFRESH_TOKEN = "refreshToken" as const; + static PREFERENCE_TOKEN_EXPIRY = "tokenExpiry" as const; + static PREFERENCE_CLIENT_ID = "clientId" as const; static IGNORE_ATTRIBUTES: readonly string[] = [ Global.PREFERENCE_CURRENT, @@ -1216,6 +1220,10 @@ class Global extends Config { Global.PREFERENCE_KEY, Global.PREFERENCE_LOCALE, Global.PREFERENCE_MODE, + Global.PREFERENCE_ACCESS_TOKEN, + Global.PREFERENCE_REFRESH_TOKEN, + Global.PREFERENCE_TOKEN_EXPIRY, + Global.PREFERENCE_CLIENT_ID, ]; static MODE_ADMIN = "admin"; @@ -1359,6 +1367,50 @@ class Global extends Config { this.setTo(Global.PREFERENCE_KEY, key); } + getAccessToken(): string { + if (!this.hasFrom(Global.PREFERENCE_ACCESS_TOKEN)) { + return ""; + } + return this.getFrom(Global.PREFERENCE_ACCESS_TOKEN); + } + + setAccessToken(accessToken: string): void { + this.setTo(Global.PREFERENCE_ACCESS_TOKEN, accessToken); + } + + getRefreshToken(): string { + if (!this.hasFrom(Global.PREFERENCE_REFRESH_TOKEN)) { + return ""; + } + return this.getFrom(Global.PREFERENCE_REFRESH_TOKEN); + } + + setRefreshToken(refreshToken: string): void { + this.setTo(Global.PREFERENCE_REFRESH_TOKEN, refreshToken); + } + + getTokenExpiry(): number { + if (!this.hasFrom(Global.PREFERENCE_TOKEN_EXPIRY)) { + return 0; + } + return this.getFrom(Global.PREFERENCE_TOKEN_EXPIRY); + } + + setTokenExpiry(tokenExpiry: number): void { + this.setTo(Global.PREFERENCE_TOKEN_EXPIRY, tokenExpiry); + } + + getClientId(): string { + if (!this.hasFrom(Global.PREFERENCE_CLIENT_ID)) { + return ""; + } + return this.getFrom(Global.PREFERENCE_CLIENT_ID); + } + + setClientId(clientId: string): void { + this.setTo(Global.PREFERENCE_CLIENT_ID, clientId); + } + hasFrom(key: string): boolean { const current = this.getCurrentSession(); diff --git a/templates/cli/lib/constants.ts b/templates/cli/lib/constants.ts deleted file mode 100644 index 7be0885e50..0000000000 --- a/templates/cli/lib/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -// SDK -export const SDK_TITLE = "SDK for CLI"; -export const SDK_TITLE_LOWER = "sdk for cli"; -export const SDK_VERSION = "1.0.0"; -export const SDK_NAME = "SDK for CLI"; -export const SDK_PLATFORM = "CLI"; -export const SDK_LANGUAGE = "cli"; -export const SDK_LOGO = ""; - -// CLI -export const EXECUTABLE_NAME = "cli"; - -// NPM -export const NPM_PACKAGE_NAME = "sdk-for-cli"; -export const NPM_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`; - -// GitHub -export const GITHUB_REPO = "appwrite/sdk-for-cli"; -export const GITHUB_RELEASES_URL = `https://github.com/${GITHUB_REPO}/releases`; - -// API -export const DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"; diff --git a/templates/cli/lib/flags.ts b/templates/cli/lib/flags.ts new file mode 100644 index 0000000000..44217f25a8 --- /dev/null +++ b/templates/cli/lib/flags.ts @@ -0,0 +1,21 @@ +/** + * Central registry of CLI feature flags. + * + * Each flag is an opt-in environment variable, disabled unless set to a truthy + * value ("1", "true", or "yes"). To add a flag, add one entry to FLAG_ENV_VARS + * and read it anywhere with isFlagEnabled(""). + */ +const FLAG_ENV_VARS = { + // Browser-based OAuth2 device login for Cloud (otherwise email/password). + oauthLogin: "APPWRITE_CLI_OAUTH_LOGIN", + // Treat localhost/loopback endpoints as Cloud for OAuth login testing. + devCloudLogin: "APPWRITE_CLI_DEV_CLOUD_LOGIN", +} as const; + +export type FlagName = keyof typeof FLAG_ENV_VARS; + +const isTruthy = (value: string | undefined): boolean => + ["1", "true", "yes"].includes((value ?? "").toLowerCase()); + +export const isFlagEnabled = (flag: FlagName): boolean => + isTruthy(process.env[FLAG_ENV_VARS[flag]]); diff --git a/templates/cli/lib/questions.ts b/templates/cli/lib/questions.ts index c036e1ed1c..4bfc202a0d 100644 --- a/templates/cli/lib/questions.ts +++ b/templates/cli/lib/questions.ts @@ -822,32 +822,6 @@ export const questionsPullCollection: Question[] = [ }, ]; -export const questionsLogin: Question[] = [ - { - type: "input", - name: "email", - message: "Enter your email", - validate(value: string) { - if (!value) { - return "Please enter your email"; - } - return true; - }, - }, - { - type: "password", - name: "password", - message: "Enter your password", - mask: "*", - validate(value: string) { - if (!value) { - return "Please enter your password"; - } - return true; - }, - }, -]; - export const questionsSwitchAccount: Question[] = [ { type: "list", @@ -906,6 +880,32 @@ export const questionGetEndpoint: Question[] = [ }, ]; +export const questionsLogin: Question[] = [ + { + type: "input", + name: "email", + message: "Enter your email", + validate(value: string) { + if (!value) { + return "Please enter your email"; + } + return true; + }, + }, + { + type: "password", + name: "password", + message: "Enter your password", + mask: "*", + validate(value: string) { + if (!value) { + return "Please enter your password"; + } + return true; + }, + }, +]; + export const questionsLogout: Question[] = [ { type: "checkbox", @@ -1199,7 +1199,7 @@ export const questionsListFactors: Question[] = [ message: "Your account is protected by multi-factor authentication. Please choose one for verification.", choices: async () => { - const client = await sdkForConsole({ requiresAuth: false }); + const client = await sdkForConsole(); const accountClient = new Account(client); const factors = await accountClient.listMfaFactors(); diff --git a/templates/cli/lib/sdks.ts b/templates/cli/lib/sdks.ts index 393cd6a7bd..b5e7baba8d 100644 --- a/templates/cli/lib/sdks.ts +++ b/templates/cli/lib/sdks.ts @@ -1,4 +1,8 @@ -import { globalConfig, localConfig } from "./config.js"; +import { + globalConfig, + localConfig, + normalizeCloudConsoleEndpoint, +} from "./config.js"; import { Client } from "@appwrite.io/console"; import os from "os"; import { @@ -7,6 +11,24 @@ import { SDK_TITLE, SDK_VERSION, } from "./constants.js"; +import { warn } from "./parser.js"; +import { isCloudHostname } from "./utils.js"; +import { isFlagEnabled } from "./flags.js"; +import { getValidAccessToken } from "./auth/oauth.js"; + +let legacySessionWarningShown = false; + +const warnLegacySession = (): void => { + // Only nudge toward OAuth login when the feature is enabled. + if (legacySessionWarningShown || !isFlagEnabled("oauthLogin")) { + return; + } + + legacySessionWarningShown = true; + warn( + `This CLI is using a legacy cookie session. Run \`${EXECUTABLE_NAME} login --new\` to switch to the new browser-based login flow.`, + ); +}; export const sdkForConsole = async ({ requiresAuth = true, @@ -18,12 +40,16 @@ export const sdkForConsole = async ({ organizationId?: string; } = {}): Promise => { const client = new Client(); - const endpoint = - endpointOverride || globalConfig.getEndpoint() || DEFAULT_ENDPOINT; - const cookie = globalConfig.getCookie(); + const endpoint = normalizeCloudConsoleEndpoint( + endpointOverride || globalConfig.getEndpoint() || DEFAULT_ENDPOINT, + ); + const isCloudEndpoint = isCloudHostname(new URL(endpoint).hostname); const selfSigned = globalConfig.getSelfSigned(); - if (requiresAuth && cookie === "") { + const accessToken = globalConfig.getAccessToken(); + const cookie = globalConfig.getCookie(); + + if (requiresAuth && !accessToken && !cookie) { throw new Error( `Session not found. Please run \`${EXECUTABLE_NAME} login\` to create a session`, ); @@ -41,10 +67,21 @@ export const sdkForConsole = async ({ client .setEndpoint(endpoint) .setProject("console") - .setCookie(cookie) .setSelfSigned(selfSigned) .setLocale("en-US"); + if (requiresAuth) { + if (accessToken) { + const validAccessToken = await getValidAccessToken(endpoint); + client.headers["Authorization"] = `Bearer ${validAccessToken}`; + } else if (cookie) { + if (isCloudEndpoint) { + warnLegacySession(); + } + client.setCookie(cookie); + } + } + if (organizationId) { client.headers["X-Appwrite-Organization"] = organizationId; } @@ -57,12 +94,14 @@ export const sdkForProject = async (): Promise => { const endpoint = localConfig.getEndpoint() || globalConfig.getEndpoint() || DEFAULT_ENDPOINT; + const isCloudEndpoint = isCloudHostname(new URL(endpoint).hostname); const project = localConfig.getProject().projectId ? localConfig.getProject().projectId : globalConfig.getProject(); const key = globalConfig.getKey(); + const accessToken = globalConfig.getAccessToken(); const cookie = globalConfig.getCookie(); const selfSigned = globalConfig.getSelfSigned(); @@ -87,8 +126,18 @@ export const sdkForProject = async (): Promise => { .setSelfSigned(selfSigned) .setLocale("en-US"); + if (accessToken) { + const validAccessToken = await getValidAccessToken(endpoint); + client.headers["Authorization"] = `Bearer ${validAccessToken}`; + return client.setMode("admin"); + } + if (cookie) { - return client.setCookie(cookie).setMode("admin"); + if (isCloudEndpoint) { + warnLegacySession(); + } + client.setCookie(cookie); + return client.setMode("admin"); } if (key) { diff --git a/templates/cli/lib/types.ts b/templates/cli/lib/types.ts index 3e1e9144d7..0407261da7 100644 --- a/templates/cli/lib/types.ts +++ b/templates/cli/lib/types.ts @@ -67,6 +67,11 @@ export interface SessionData { email?: string; phone?: string; cookie?: string; + accessToken?: string; + refreshToken?: string; + tokenExpiry?: number; + clientId?: string; + selfSigned?: boolean; } export interface GlobalConfigData extends ConfigData { diff --git a/templates/cli/lib/utils.ts b/templates/cli/lib/utils.ts index 86929f483a..20a4d55b38 100644 --- a/templates/cli/lib/utils.ts +++ b/templates/cli/lib/utils.ts @@ -7,6 +7,7 @@ import type { Models } from "@appwrite.io/console"; import { ProjectPolicyId } from "@appwrite.io/console"; import { z } from "zod"; import { globalConfig } from "./config.js"; +import { isFlagEnabled } from "./flags.js"; import type { SettingsType } from "./commands/config.js"; import { NPM_REGISTRY_URL, @@ -376,6 +377,30 @@ export const isCloudHostname = (hostname: string): boolean => { return CLOUD_REGION_CODES.has(hostname.split(".")[0]); }; +export const isRegionalCloudEndpoint = (endpoint: string): boolean => { + try { + const hostname = new URL(endpoint).hostname; + return isCloudHostname(hostname) && hostname !== "cloud.appwrite.io"; + } catch (_error) { + return false; + } +}; + +export const isLocalhostHostname = (hostname: string): boolean => + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + +export const isCloudLoginEndpoint = (endpoint: string): boolean => { + try { + const hostname = new URL(endpoint).hostname; + return ( + isCloudHostname(hostname) || + (isFlagEnabled("devCloudLogin") && isLocalhostHostname(hostname)) + ); + } catch (_error) { + return false; + } +}; + export const getConsoleBaseUrl = (endpoint: string): string => { try { const url = new URL(endpoint); @@ -397,11 +422,16 @@ export const getConsoleBaseUrl = (endpoint: string): string => { export const getConsoleProjectSlug = ( endpoint: string, projectId: string, + projectRegion?: string, ): string => { try { const hostname = new URL(endpoint).hostname; if (!isCloudHostname(hostname)) { + if (projectRegion) { + return `project-${projectRegion}-${projectId}`; + } + return `project-${projectId}`; } @@ -419,8 +449,9 @@ export const getFunctionDeploymentConsoleUrl = ( projectId: string, functionId: string, deploymentId: string, + projectRegion?: string, ): string => { - const projectSlug = getConsoleProjectSlug(endpoint, projectId); + const projectSlug = getConsoleProjectSlug(endpoint, projectId, projectRegion); return `${getConsoleBaseUrl(endpoint)}/console/${projectSlug}/functions/function-${functionId}/deployment-${deploymentId}`; }; @@ -429,8 +460,9 @@ export const getSiteDeploymentConsoleUrl = ( projectId: string, siteId: string, deploymentId: string, + projectRegion?: string, ): string => { - const projectSlug = getConsoleProjectSlug(endpoint, projectId); + const projectSlug = getConsoleProjectSlug(endpoint, projectId, projectRegion); return `${getConsoleBaseUrl(endpoint)}/console/${projectSlug}/sites/site-${siteId}/deployments/deployment-${deploymentId}`; }; diff --git a/templates/cli/package-lock.json.twig b/templates/cli/package-lock.json.twig index 3a82e212b6..6cb3614363 100644 --- a/templates/cli/package-lock.json.twig +++ b/templates/cli/package-lock.json.twig @@ -9,7 +9,7 @@ "version": "{{ sdk.version }}", "license": "{{ sdk.license }}", "dependencies": { - "@appwrite.io/console": "^14.0.0", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@54cebb6", "chalk": "4.1.2", "chokidar": "^3.6.0", "cli-progress": "^3.12.0", @@ -58,8 +58,8 @@ }, "node_modules/@appwrite.io/console": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-14.0.0.tgz", - "integrity": "sha512-OazmwL0CGA/a3KrbKx4ljEOlYd07/MBYVgJ6GlBMKv2O2etI1RLqLvXF29LV9QGwrOgN5EwYHmOotfw8r0PG8Q==", + "resolved": "https://pkg.vc/-/@appwrite/@appwrite.io/console@54cebb6", + "integrity": "sha512-b11lYORsbyvueN/R66GyTUbwgDjJbEfcv7+HPTyAg6NANaCZSk0UQZ+TrXFFR/uunNubCuQXEK/cB9757BlZbg==", "license": "BSD-3-Clause", "dependencies": { "json-bigint": "1.0.0" diff --git a/templates/cli/package.json b/templates/cli/package.json index 076e3d3e90..eab19e079b 100644 --- a/templates/cli/package.json +++ b/templates/cli/package.json @@ -48,7 +48,7 @@ "windows-arm64": "esbuild cli.ts --bundle --loader:.hbs=text --platform=node --target=node18 --format=esm --external:fsevents --external:terminal-image --outfile=dist/bundle-win-arm64.mjs && pkg dist/bundle-win-arm64.mjs --fallback-to-source -t node18-win-arm64 -o build/appwrite-cli-win-arm64.exe" }, "dependencies": { - "@appwrite.io/console": "^14.0.0", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@54cebb6", "chalk": "4.1.2", "chokidar": "^3.6.0", "cli-progress": "^3.12.0", diff --git a/templates/cli/package.json.twig b/templates/cli/package.json.twig index f150e26eb4..cfafcd95a2 100644 --- a/templates/cli/package.json.twig +++ b/templates/cli/package.json.twig @@ -51,7 +51,7 @@ "windows-arm64": "bun build cli.ts --compile --sourcemap=inline --target=bun-windows-arm64 --outfile build/appwrite-cli-win-arm64.exe" }, "dependencies": { - "@appwrite.io/console": "^14.0.0", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@54cebb6", "chalk": "4.1.2", "chokidar": "^3.6.0", "cli-progress": "^3.12.0", diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index ee35ee4750..07f61ccb89 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -241,6 +241,33 @@ abstract class Base extends TestCase 'CLI_TYPEGEN:passed', ]; + protected const AUTH_LOGIC_RESPONSES = [ + 'auth:endpoint-cloud-hostname:passed', + 'auth:endpoint-regional:passed', + 'auth:endpoint-localhost:passed', + 'auth:endpoint-cloud-login:passed', + 'auth:endpoint-dev-override:passed', + 'auth:endpoint-normalize:passed', + 'auth:console-slug-region:passed', + 'auth:decode-id-token:passed', + 'auth:authorization-pending-error:passed', + 'auth:session-account-key:passed', + 'auth:session-local-only:passed', + 'auth:session-legacy:passed', + 'auth:session-has-auth:passed', + 'auth:plan-session-logout:passed', + 'auth:restore-current-session-fallback:passed', + 'auth:poll-device-token-success:passed', + 'auth:poll-device-token-retry:passed', + 'auth:poll-device-token-error:passed', + 'auth:poll-device-token-timeout:passed', + 'auth:poll-device-token-slow-down:passed', + 'auth:poll-device-token-empty-error:passed', + 'auth:poll-device-token-default-interval:passed', + 'auth:valid-access-token-cached:passed', + 'auth:oauth-login-flag:passed', + ]; + protected const CLI_LOCAL_FUNCTION_EMULATION_RESPONSES = [ 'CLI_LOCAL_FUNCTION_RUNNER_CONFIG:passed', 'CLI_LOCAL_SOURCE_PREFLIGHT:passed', diff --git a/tests/e2e/CLIBun10Test.php b/tests/e2e/CLIBun10Test.php index 3a8f15d853..7ac86b5c9d 100644 --- a/tests/e2e/CLIBun10Test.php +++ b/tests/e2e/CLIBun10Test.php @@ -49,6 +49,7 @@ final class CLIBun10Test extends Base ...Base::CLI_RUNTIME_RENDERING_RESPONSES, ...Base::CLI_QUERY_HELPER_RESPONSES, ...Base::CLI_TYPEGEN_RESPONSES, + ...Base::AUTH_LOGIC_RESPONSES, ]; #[Override] diff --git a/tests/e2e/CLIBun11Test.php b/tests/e2e/CLIBun11Test.php index bc8dcb74cd..285dcf30ab 100644 --- a/tests/e2e/CLIBun11Test.php +++ b/tests/e2e/CLIBun11Test.php @@ -49,6 +49,7 @@ final class CLIBun11Test extends Base ...Base::CLI_RUNTIME_RENDERING_RESPONSES, ...Base::CLI_QUERY_HELPER_RESPONSES, ...Base::CLI_TYPEGEN_RESPONSES, + ...Base::AUTH_LOGIC_RESPONSES, ]; #[Override] diff --git a/tests/e2e/CLIBun13Test.php b/tests/e2e/CLIBun13Test.php index f7616c0c24..e1a8ba0f46 100644 --- a/tests/e2e/CLIBun13Test.php +++ b/tests/e2e/CLIBun13Test.php @@ -49,6 +49,7 @@ final class CLIBun13Test extends Base ...Base::CLI_RUNTIME_RENDERING_RESPONSES, ...Base::CLI_QUERY_HELPER_RESPONSES, ...Base::CLI_TYPEGEN_RESPONSES, + ...Base::AUTH_LOGIC_RESPONSES, ]; #[Override] diff --git a/tests/e2e/languages/cli/test.js b/tests/e2e/languages/cli/test.js index 08f2624c76..d7bb6b11f1 100644 --- a/tests/e2e/languages/cli/test.js +++ b/tests/e2e/languages/cli/test.js @@ -21,6 +21,32 @@ const { } = require("./lib/utils.ts"); const { EXECUTABLE_NAME } = require("./lib/constants.ts"); const { isCompletionInvocation } = require("./lib/completions.ts"); +const { + decodeIdToken, + isAuthorizationPendingError, + pollForDeviceToken, + getValidAccessToken, +} = require("./lib/auth/oauth.ts"); +const { + planSessionLogout, + isLocalOnlySession, + isLegacySession, + getSessionAccountKey, + hasAuthSession, + restoreCurrentSessionFallback, +} = require("./lib/auth/session.ts"); +const { + isCloudHostname, + isRegionalCloudEndpoint, + isLocalhostHostname, + isCloudLoginEndpoint, + getConsoleProjectSlug, +} = require("./lib/utils.ts"); +const { isFlagEnabled } = require("./lib/flags.ts"); +const { + normalizeCloudConsoleEndpoint, + globalConfig, +} = require("./lib/config.ts"); const extractFirstValue = (output) => { const firstLine = @@ -687,6 +713,315 @@ void (async () => { assert.doesNotMatch(kotlinTypes, /val purchaseTime: Int\?/); console.log("CLI_TYPEGEN:passed"); -})().catch((error) => { - throw error; -}); +})() + .then(runAuthChecks) + .catch((error) => { + throw error; + }); + +async function runAuthChecks() { + // ESM import so AppwriteException matches the instance lib/auth/oauth.ts uses + // (the package ships dual ESM/CJS builds; a CJS require breaks instanceof). + const { AppwriteException } = await import("@appwrite.io/console"); + + const authCheck = async (name, fn) => { + try { + await fn(); + console.log(`auth:${name}:passed`); + } catch (error) { + console.log(`auth:${name}:failed`); + console.error(`auth:${name}`, error && error.message ? error.message : error); + } + }; + + const deviceAuth = (overrides = {}) => ({ + expires_in: 5, + interval: 0, + device_code: "dc", + ...overrides, + }); + + await authCheck("endpoint-cloud-hostname", () => { + assert.equal(isCloudHostname("cloud.appwrite.io"), true); + assert.equal(isCloudHostname("fra.cloud.appwrite.io"), true); + assert.equal(isCloudHostname("evil.cloud.appwrite.io"), false); + assert.equal(isCloudHostname("localhost"), false); + }); + + await authCheck("endpoint-regional", () => { + assert.equal(isRegionalCloudEndpoint("https://fra.cloud.appwrite.io/v1"), true); + assert.equal(isRegionalCloudEndpoint("https://cloud.appwrite.io/v1"), false); + assert.equal(isRegionalCloudEndpoint("http://localhost/v1"), false); + assert.equal(isRegionalCloudEndpoint("nonsense"), false); + }); + + await authCheck("endpoint-localhost", () => { + assert.equal(isLocalhostHostname("localhost"), true); + assert.equal(isLocalhostHostname("127.0.0.1"), true); + assert.equal(isLocalhostHostname("[::1]"), true); + assert.equal(isLocalhostHostname("example.com"), false); + }); + + await authCheck("endpoint-cloud-login", () => { + const prev = process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN; + delete process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN; + try { + assert.equal(isFlagEnabled("devCloudLogin"), false); + assert.equal(isCloudLoginEndpoint("https://cloud.appwrite.io/v1"), true); + assert.equal(isCloudLoginEndpoint("http://localhost/v1"), false); + } finally { + if (prev === undefined) delete process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN; + else process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN = prev; + } + }); + + await authCheck("endpoint-dev-override", () => { + const prev = process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN; + process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN = "1"; + try { + assert.equal(isFlagEnabled("devCloudLogin"), true); + assert.equal(isCloudLoginEndpoint("http://localhost/v1"), true); + } finally { + if (prev === undefined) delete process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN; + else process.env.APPWRITE_CLI_DEV_CLOUD_LOGIN = prev; + } + }); + + await authCheck("endpoint-normalize", () => { + assert.equal( + normalizeCloudConsoleEndpoint("https://fra.cloud.appwrite.io/v1"), + "https://cloud.appwrite.io/v1", + ); + assert.equal( + normalizeCloudConsoleEndpoint("https://cloud.appwrite.io/v1"), + "https://cloud.appwrite.io/v1", + ); + assert.equal(normalizeCloudConsoleEndpoint("http://localhost/v1"), "http://localhost/v1"); + assert.equal(normalizeCloudConsoleEndpoint("not a url"), "not a url"); + }); + + await authCheck("console-slug-region", () => { + assert.equal(getConsoleProjectSlug("http://localhost/v1", "proj1"), "project-proj1"); + assert.equal(getConsoleProjectSlug("http://localhost/v1", "proj1", "fra"), "project-fra-proj1"); + assert.equal(getConsoleProjectSlug("https://fra.cloud.appwrite.io/v1", "proj1"), "project-fra-proj1"); + assert.equal(getConsoleProjectSlug("https://cloud.appwrite.io/v1", "proj1"), "project-proj1"); + }); + + await authCheck("decode-id-token", () => { + const payload = Buffer.from( + JSON.stringify({ email: "u@e.com", name: "U", sub: "123" }), + ).toString("base64url"); + const decoded = decodeIdToken(`header.${payload}.sig`); + assert.equal(decoded.email, "u@e.com"); + assert.equal(decoded.name, "U"); + assert.equal(decoded.sub, "123"); + assert.deepEqual(decodeIdToken("garbage"), {}); + assert.deepEqual(decodeIdToken("a.b.c"), {}); + }); + + await authCheck("authorization-pending-error", () => { + assert.equal( + isAuthorizationPendingError( + new AppwriteException("authorization_pending", 428, "authorization_pending"), + ), + true, + ); + assert.equal( + isAuthorizationPendingError(new AppwriteException("slow_down", 429, "slow_down")), + true, + ); + assert.equal(isAuthorizationPendingError(new AppwriteException("authorization_pending")), true); + assert.equal( + isAuthorizationPendingError(new AppwriteException("x", 400, "", "authorization_pending")), + true, + ); + assert.equal( + isAuthorizationPendingError(new AppwriteException("other", 500, "general_server_error")), + false, + ); + assert.equal(isAuthorizationPendingError({ type: "authorization_pending" }), false); + }); + + await authCheck("session-account-key", () => { + globalConfig.clear(); + globalConfig.addSession("s1", { + endpoint: "https://fra.cloud.appwrite.io/v1", + email: "a@b.com", + }); + assert.equal(getSessionAccountKey("s1"), "a@b.com|https://cloud.appwrite.io/v1"); + globalConfig.addSession("s2", { endpoint: "http://localhost/v1", email: "x@y.com" }); + assert.equal(getSessionAccountKey("s2"), "x@y.com|http://localhost/v1"); + }); + + await authCheck("session-local-only", () => { + globalConfig.clear(); + globalConfig.addSession("local1", { endpoint: "http://localhost/v1" }); + assert.equal(isLocalOnlySession("local1"), true); + globalConfig.addSession("oauth1", { endpoint: "http://localhost/v1", refreshToken: "r" }); + assert.equal(isLocalOnlySession("oauth1"), false); + globalConfig.addSession("legacy1", { endpoint: "http://localhost/v1", cookie: "c" }); + assert.equal(isLocalOnlySession("legacy1"), false); + }); + + await authCheck("session-legacy", () => { + globalConfig.clear(); + globalConfig.addSession("legacy1", { endpoint: "http://localhost/v1", cookie: "c" }); + assert.equal(isLegacySession("legacy1"), true); + globalConfig.addSession("mixed", { + endpoint: "http://localhost/v1", + cookie: "c", + accessToken: "a", + }); + assert.equal(isLegacySession("mixed"), false); + globalConfig.addSession("nocookie", { endpoint: "http://localhost/v1", accessToken: "a" }); + assert.equal(isLegacySession("nocookie"), false); + }); + + await authCheck("session-has-auth", () => { + globalConfig.clear(); + globalConfig.addSession("s1", { endpoint: "http://localhost/v1", accessToken: "a" }); + globalConfig.setCurrentSession("s1"); + assert.equal(hasAuthSession(), true); + globalConfig.clear(); + globalConfig.addSession("s2", { endpoint: "http://localhost/v1", cookie: "c" }); + globalConfig.setCurrentSession("s2"); + assert.equal(hasAuthSession(), true); + globalConfig.clear(); + globalConfig.addSession("s3", { endpoint: "http://localhost/v1" }); + globalConfig.setCurrentSession("s3"); + assert.equal(hasAuthSession(), false); + }); + + await authCheck("plan-session-logout", () => { + globalConfig.clear(); + globalConfig.addSession("a1", { endpoint: "https://cloud.appwrite.io/v1", email: "a@b.com" }); + globalConfig.addSession("a2", { endpoint: "https://cloud.appwrite.io/v1", email: "a@b.com" }); + globalConfig.addSession("b1", { endpoint: "http://localhost/v1", email: "b@c.com" }); + assert.deepEqual([...planSessionLogout(["a1"])].sort(), ["a1", "a2"]); + assert.deepEqual(planSessionLogout(["b1"]), ["b1"]); + }); + + await authCheck("restore-current-session-fallback", () => { + globalConfig.clear(); + globalConfig.addSession("s1", { endpoint: "http://localhost/v1" }); + globalConfig.addSession("s2", { endpoint: "http://localhost/v1" }); + restoreCurrentSessionFallback("s1", ["s2"]); + assert.equal(globalConfig.getCurrentSession(), "s1"); + restoreCurrentSessionFallback("missing", ["nope", "s2"]); + assert.equal(globalConfig.getCurrentSession(), "s2"); + restoreCurrentSessionFallback("missing", ["alsoMissing"]); + assert.equal(globalConfig.getCurrentSession(), ""); + }); + + await authCheck("poll-device-token-success", async () => { + const oauth2 = { createToken: async () => ({ access_token: "tok", expires_in: 3600 }) }; + const token = await pollForDeviceToken(oauth2, deviceAuth(), "cli"); + assert.equal(token.access_token, "tok"); + }); + + await authCheck("poll-device-token-retry", async () => { + let calls = 0; + const oauth2 = { + createToken: async () => { + calls += 1; + if (calls === 1) { + throw new AppwriteException("authorization_pending", 428, "authorization_pending"); + } + return { access_token: "tok2", expires_in: 3600 }; + }, + }; + const token = await pollForDeviceToken(oauth2, deviceAuth(), "cli"); + assert.equal(token.access_token, "tok2"); + assert.equal(calls, 2); + }); + + await authCheck("poll-device-token-error", async () => { + const oauth2 = { + createToken: async () => { + throw new AppwriteException("boom", 500, "general_server_error"); + }, + }; + await assert.rejects(() => pollForDeviceToken(oauth2, deviceAuth(), "cli")); + }); + + await authCheck("poll-device-token-timeout", async () => { + const oauth2 = { + createToken: async () => { + throw new AppwriteException("authorization_pending", 428, "authorization_pending"); + }, + }; + const token = await pollForDeviceToken(oauth2, deviceAuth({ expires_in: 0.05 }), "cli"); + assert.equal(token, null); + }); + + await authCheck("poll-device-token-slow-down", async () => { + let calls = 0; + const oauth2 = { + createToken: async () => { + calls += 1; + if (calls === 1) throw new AppwriteException("slow_down", 400, "slow_down"); + return { access_token: "tok3", expires_in: 3600 }; + }, + }; + const token = await pollForDeviceToken(oauth2, deviceAuth(), "cli"); + assert.equal(token.access_token, "tok3"); + assert.equal(calls, 2); + }); + + await authCheck("poll-device-token-empty-error", async () => { + let calls = 0; + const oauth2 = { + createToken: async () => { + calls += 1; + if (calls === 1) throw new AppwriteException("", 400, "", ""); + return { access_token: "tok4", expires_in: 3600 }; + }, + }; + const token = await pollForDeviceToken(oauth2, deviceAuth(), "cli"); + assert.equal(token.access_token, "tok4"); + assert.equal(calls, 2); + }); + + await authCheck("poll-device-token-default-interval", async () => { + // interval omitted: must fall back to a real 5s interval (not NaN, which + // would resolve immediately and busy-poll the endpoint). + const oauth2 = { + createToken: async () => ({ access_token: "tok5", expires_in: 3600 }), + }; + const startedAt = Date.now(); + const token = await pollForDeviceToken( + oauth2, + { expires_in: 30, device_code: "dc" }, + "cli", + ); + assert.equal(token.access_token, "tok5"); + assert.ok(Date.now() - startedAt >= 4000); + }); + + await authCheck("valid-access-token-cached", async () => { + globalConfig.clear(); + globalConfig.addSession("tok1", { + endpoint: "http://localhost/v1", + accessToken: "cached-token", + tokenExpiry: Date.now() + 3600000, + }); + globalConfig.setCurrentSession("tok1"); + const token = await getValidAccessToken("http://localhost/v1"); + assert.equal(token, "cached-token"); + }); + + await authCheck("oauth-login-flag", () => { + const prev = process.env.APPWRITE_CLI_OAUTH_LOGIN; + delete process.env.APPWRITE_CLI_OAUTH_LOGIN; + try { + assert.equal(isFlagEnabled("oauthLogin"), false); + process.env.APPWRITE_CLI_OAUTH_LOGIN = "1"; + assert.equal(isFlagEnabled("oauthLogin"), true); + } finally { + if (prev === undefined) delete process.env.APPWRITE_CLI_OAUTH_LOGIN; + else process.env.APPWRITE_CLI_OAUTH_LOGIN = prev; + } + }); + + globalConfig.clear(); +}