From a7fc9dfa0a808f5cbccd34ac1dad0cf668af0a46 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 3 Mar 2026 15:39:36 +0000 Subject: [PATCH 1/4] Add OAuth2 PKCE login flow as default authentication method --- src/commands/accounts/login.ts | 272 ++++++++------- src/commands/accounts/logout.ts | 16 + src/control-base-command.ts | 15 + src/services/config-manager.ts | 110 ++++++ src/services/control-api.ts | 10 + src/services/oauth-callback-page.ts | 60 ++++ src/services/oauth-client.ts | 319 ++++++++++++++++++ src/services/token-refresh-middleware.ts | 70 ++++ test/helpers/mock-config-manager.ts | 72 ++++ test/unit/commands/accounts/login.test.ts | 63 ++++ test/unit/commands/accounts/logout.test.ts | 109 +++++- test/unit/services/oauth-client.test.ts | 300 ++++++++++++++++ .../services/token-refresh-middleware.test.ts | 187 ++++++++++ 13 files changed, 1475 insertions(+), 128 deletions(-) create mode 100644 src/services/oauth-callback-page.ts create mode 100644 src/services/oauth-client.ts create mode 100644 src/services/token-refresh-middleware.ts create mode 100644 test/unit/services/oauth-client.test.ts create mode 100644 test/unit/services/token-refresh-middleware.test.ts diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index f1575dda..de356353 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -1,11 +1,14 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import * as readline from "node:readline"; -import open from "open"; +import ora from "ora"; import { ControlBaseCommand } from "../../control-base-command.js"; import { ControlApi } from "../../services/control-api.js"; +import { OAuthClient, type OAuthTokens } from "../../services/oauth-client.js"; +import { BaseFlags } from "../../types/cli.js"; import { displayLogo } from "../../utils/logo.js"; +import openUrl from "../../utils/open-url.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; // Moved function definition outside the class @@ -51,6 +54,7 @@ export default class AccountsLogin extends ControlBaseCommand { static override examples = [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --alias mycompany", + "<%= config.bin %> <%= command.id %> --legacy", "<%= config.bin %> <%= command.id %> --json", "<%= config.bin %> <%= command.id %> --pretty-json", ]; @@ -61,6 +65,10 @@ export default class AccountsLogin extends ControlBaseCommand { char: "a", description: "Alias for this account (default account if not specified)", }), + legacy: Flags.boolean({ + default: false, + description: "Use legacy token-based login instead of OAuth", + }), "no-browser": Flags.boolean({ default: false, description: "Do not open a browser", @@ -76,87 +84,34 @@ export default class AccountsLogin extends ControlBaseCommand { } let accessToken: string; + let oauthTokens: OAuthTokens | undefined; + if (args.token) { + // Direct token provided as argument accessToken = args.token; + } else if (flags.legacy || flags["no-browser"]) { + // Legacy flow: manual token paste + accessToken = await this.legacyTokenLogin(flags); } else { - let obtainTokenPath = "https://ably.com/users/access_tokens"; - if (flags["control-host"]) { - if (!this.shouldOutputJson(flags)) { - this.log("Using control host:", flags["control-host"]); - } - - obtainTokenPath = flags["control-host"].includes("local") - ? `http://${flags["control-host"]}/users/access_tokens` - : `https://${flags["control-host"]}/users/access_tokens`; - } - - // Prompt the user to get a token - if (!flags["no-browser"]) { + // OAuth flow (default) + try { + oauthTokens = await this.oauthLogin(flags); + accessToken = oauthTokens.accessToken; + } catch (error) { if (!this.shouldOutputJson(flags)) { - this.log("Opening browser to get an access token..."); + this.warn( + `OAuth login failed: ${error instanceof Error ? error.message : String(error)}`, + ); + this.log("Falling back to manual token login..."); } - - await this.openBrowser(obtainTokenPath); - } else if (!this.shouldOutputJson(flags)) { - this.log(`Please visit ${obtainTokenPath} to create an access token`); + accessToken = await this.legacyTokenLogin(flags); } - - accessToken = await this.promptForToken(); } - // If no alias flag provided, prompt the user if they want to provide one + // If no alias flag provided, prompt the user let { alias } = flags; if (!alias && !this.shouldOutputJson(flags)) { - // Check if the default account already exists - const accounts = this.configManager.listAccounts(); - const hasDefaultAccount = accounts.some( - (account) => account.alias === "default", - ); - - if (hasDefaultAccount) { - // Explain to the user the implications of not providing an alias - this.log("\nYou have not specified an alias for this account."); - this.log( - "If you continue without an alias, your existing default account configuration will be overwritten.", - ); - this.log( - "To maintain multiple account profiles, please provide an alias.", - ); - - // Ask if they want to provide an alias - const shouldProvideAlias = await promptForConfirmation( - "Would you like to provide an alias for this account?", - ); - - if (shouldProvideAlias) { - alias = await this.promptForAlias(); - } else { - alias = "default"; - this.log( - "No alias provided. The default account configuration will be overwritten.", - ); - } - } else { - // No default account exists yet, but still offer to set an alias - this.log("\nYou have not specified an alias for this account."); - this.log( - "Using an alias allows you to maintain multiple account profiles that you can switch between.", - ); - - // Ask if they want to provide an alias - const shouldProvideAlias = await promptForConfirmation( - "Would you like to provide an alias for this account?", - ); - - if (shouldProvideAlias) { - alias = await this.promptForAlias(); - } else { - alias = "default"; - this.log( - "No alias provided. This will be set as your default account.", - ); - } - } + alias = await this.resolveAlias(); } else if (!alias) { alias = "default"; } @@ -170,13 +125,20 @@ export default class AccountsLogin extends ControlBaseCommand { const { account, user } = await controlApi.getMe(); - // Store the account information - this.configManager.storeAccount(accessToken, alias, { - accountId: account.id, - accountName: account.name, - tokenId: "unknown", // Token ID is not returned by getMe(), would need additional API if needed - userEmail: user.email, - }); + // Store based on auth method + if (oauthTokens) { + this.configManager.storeOAuthTokens(alias, oauthTokens, { + accountId: account.id, + accountName: account.name, + }); + } else { + this.configManager.storeAccount(accessToken, alias, { + accountId: account.id, + accountName: account.name, + tokenId: "unknown", + userEmail: user.email, + }); + } // Switch to this account this.configManager.switchAccount(alias); @@ -223,28 +185,24 @@ export default class AccountsLogin extends ControlBaseCommand { const app = await controlApi.createApp({ name: appName, - tlsOnly: true, // Default to true for security + tlsOnly: true, }); selectedApp = app; - isAutoSelected = true; // Consider this auto-selected since it's the only one + isAutoSelected = true; - // Set as current app this.configManager.setCurrentApp(app.id); this.configManager.storeAppInfo(app.id, { appName: app.name }); - this.log(`${chalk.green("✓")} App created successfully!`); + this.log(`${chalk.green("\u2713")} App created successfully!`); } catch (createError) { this.warn( `Failed to create app: ${createError instanceof Error ? createError.message : String(createError)}`, ); - // Continue with login even if app creation fails } } } - // If apps.length === 0 and JSON mode, or user declined to create app, do nothing } catch (error) { - // Don't fail login if app fetching fails, just log for debugging if (!this.shouldOutputJson(flags)) { this.warn( `Could not fetch apps: ${error instanceof Error ? error.message : String(error)}`, @@ -260,7 +218,6 @@ export default class AccountsLogin extends ControlBaseCommand { const keys = await controlApi.listKeys(selectedApp.id); if (keys.length === 1) { - // Auto-select the only key selectedKey = keys[0]; isKeyAutoSelected = true; this.configManager.storeAppKey(selectedApp.id, selectedKey.key, { @@ -268,7 +225,6 @@ export default class AccountsLogin extends ControlBaseCommand { keyName: selectedKey.name || "Unnamed key", }); } else if (keys.length > 1) { - // Prompt user to select a key when multiple exist this.log("\nSelect an API key to use:"); selectedKey = await this.interactiveHelper.selectKey( @@ -283,9 +239,7 @@ export default class AccountsLogin extends ControlBaseCommand { }); } } - // If keys.length === 0, continue without key (should be rare for newly created apps) } catch (keyError) { - // Don't fail login if key fetching fails this.warn( `Could not fetch API keys: ${keyError instanceof Error ? keyError.message : String(keyError)}`, ); @@ -293,57 +247,38 @@ export default class AccountsLogin extends ControlBaseCommand { } if (this.shouldOutputJson(flags)) { - const response: { - account: { - alias: string; - id: string; - name: string; - user: { email: string }; - }; - success: boolean; - app?: { - id: string; - name: string; - autoSelected: boolean; - }; - key?: { - id: string; - name: string; - autoSelected: boolean; - }; - } = { + const response: Record = { account: { alias, id: account.id, name: account.name, - user: { - email: user.email, - }, + user: { email: user.email }, }, + authMethod: oauthTokens ? "oauth" : "token", success: true, }; - if (selectedApp) { response.app = { + autoSelected: isAutoSelected, id: selectedApp.id, name: selectedApp.name, - autoSelected: isAutoSelected, }; - if (selectedKey) { response.key = { + autoSelected: isKeyAutoSelected, id: selectedKey.id, name: selectedKey.name || "Unnamed key", - autoSelected: isKeyAutoSelected, }; } } - this.log(this.formatJsonOutput(response, flags)); } else { this.log( `Successfully logged in to ${chalk.cyan(account.name)} (account ID: ${chalk.greenBright(account.id)})`, ); + if (oauthTokens) { + this.log(`Authenticated via OAuth (token auto-refreshes)`); + } if (alias !== "default") { this.log(`Account stored with alias: ${alias}`); } @@ -352,15 +287,15 @@ export default class AccountsLogin extends ControlBaseCommand { if (selectedApp) { const message = isAutoSelected - ? `${chalk.green("✓")} Automatically selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})` - : `${chalk.green("✓")} Selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})`; + ? `${chalk.green("\u2713")} Automatically selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})` + : `${chalk.green("\u2713")} Selected app: ${chalk.cyan(selectedApp.name)} (${selectedApp.id})`; this.log(message); } if (selectedKey) { const keyMessage = isKeyAutoSelected - ? `${chalk.green("✓")} Automatically selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})` - : `${chalk.green("✓")} Selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})`; + ? `${chalk.green("\u2713")} Automatically selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})` + : `${chalk.green("\u2713")} Selected API key: ${chalk.cyan(selectedKey.name || "Unnamed key")} (${selectedKey.id})`; this.log(keyMessage); } } @@ -380,15 +315,98 @@ export default class AccountsLogin extends ControlBaseCommand { } } - private async openBrowser(url: string): Promise { + private async oauthLogin(flags: BaseFlags): Promise { + const oauthClient = new OAuthClient({ + controlHost: flags["control-host"], + }); + + if (!this.shouldOutputJson(flags)) { + this.log("Starting OAuth login..."); + } + + const spinner = this.shouldOutputJson(flags) + ? undefined + : ora("Waiting for browser authentication...").start(); + try { - // Use the 'open' package for cross-platform browser opening - // This handles platform differences safely and avoids shell injection - await open(url); + const tokens = await oauthClient.login(async (url) => { + await openUrl(url, this); + }); + + spinner?.succeed("Authentication successful!"); + return tokens; } catch (error) { - this.warn(`Failed to open browser: ${error}`); - this.log(`Please visit ${url} manually to create an access token`); + spinner?.fail("Authentication failed"); + throw error; + } + } + + private async legacyTokenLogin(flags: BaseFlags): Promise { + let obtainTokenPath = "https://ably.com/users/access_tokens"; + if (flags["control-host"]) { + if (!this.shouldOutputJson(flags)) { + this.log("Using control host:", flags["control-host"]); + } + const host = flags["control-host"]; + obtainTokenPath = host.includes("local") + ? `http://${host}/users/access_tokens` + : `https://${host}/users/access_tokens`; + } + + if (!flags["no-browser"]) { + if (!this.shouldOutputJson(flags)) { + this.log("Opening browser to get an access token..."); + } + await openUrl(obtainTokenPath, this); + } else if (!this.shouldOutputJson(flags)) { + this.log(`Please visit ${obtainTokenPath} to create an access token`); + } + + return this.promptForToken(); + } + + private async resolveAlias(): Promise { + const accounts = this.configManager.listAccounts(); + const hasDefaultAccount = accounts.some( + (account) => account.alias === "default", + ); + + if (hasDefaultAccount) { + this.log("\nYou have not specified an alias for this account."); + this.log( + "If you continue without an alias, your existing default account configuration will be overwritten.", + ); + this.log( + "To maintain multiple account profiles, please provide an alias.", + ); + + const shouldProvideAlias = await promptForConfirmation( + "Would you like to provide an alias for this account?", + ); + + if (shouldProvideAlias) { + return this.promptForAlias(); + } + this.log( + "No alias provided. The default account configuration will be overwritten.", + ); + return "default"; + } + + this.log("\nYou have not specified an alias for this account."); + this.log( + "Using an alias allows you to maintain multiple account profiles that you can switch between.", + ); + + const shouldProvideAlias = await promptForConfirmation( + "Would you like to provide an alias for this account?", + ); + + if (shouldProvideAlias) { + return this.promptForAlias(); } + this.log("No alias provided. This will be set as your default account."); + return "default"; } private promptForAlias(): Promise { diff --git a/src/commands/accounts/logout.ts b/src/commands/accounts/logout.ts index 776661e3..20628fe9 100644 --- a/src/commands/accounts/logout.ts +++ b/src/commands/accounts/logout.ts @@ -1,6 +1,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { OAuthClient } from "../../services/oauth-client.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class AccountsLogout extends ControlBaseCommand { @@ -88,6 +89,21 @@ export default class AccountsLogout extends ControlBaseCommand { } } + // Revoke OAuth tokens if this is an OAuth account + if (this.configManager.getAuthMethod(targetAlias) === "oauth") { + const oauthTokens = this.configManager.getOAuthTokens(targetAlias); + if (oauthTokens) { + const oauthClient = new OAuthClient({ + controlHost: flags["control-host"], + }); + // Best-effort revocation -- don't block on failure + await Promise.all([ + oauthClient.revokeToken(oauthTokens.accessToken), + oauthClient.revokeToken(oauthTokens.refreshToken), + ]).catch(() => {}); + } + } + // Remove the account const success = this.configManager.removeAccount(targetAlias); diff --git a/src/control-base-command.ts b/src/control-base-command.ts index 5141443c..ecbfe908 100644 --- a/src/control-base-command.ts +++ b/src/control-base-command.ts @@ -2,6 +2,8 @@ import chalk from "chalk"; import { AblyBaseCommand } from "./base-command.js"; import { ControlApi, App } from "./services/control-api.js"; +import { OAuthClient } from "./services/oauth-client.js"; +import { TokenRefreshMiddleware } from "./services/token-refresh-middleware.js"; import { BaseFlags, ErrorDetails } from "./types/cli.js"; export abstract class ControlBaseCommand extends AblyBaseCommand { @@ -16,6 +18,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { */ protected createControlApi(flags: BaseFlags): ControlApi { let accessToken = flags["access-token"] || process.env.ABLY_ACCESS_TOKEN; + let tokenRefreshMiddleware: TokenRefreshMiddleware | undefined; if (!accessToken) { const account = this.configManager.getCurrentAccount(); @@ -26,6 +29,17 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { } accessToken = account.accessToken; + + // Set up token refresh middleware for OAuth accounts + if (this.configManager.getAuthMethod() === "oauth") { + const oauthClient = new OAuthClient({ + controlHost: flags["control-host"], + }); + tokenRefreshMiddleware = new TokenRefreshMiddleware( + this.configManager, + oauthClient, + ); + } } if (!accessToken) { @@ -37,6 +51,7 @@ export abstract class ControlBaseCommand extends AblyBaseCommand { return new ControlApi({ accessToken, controlHost: flags["control-host"], + tokenRefreshMiddleware, }); } diff --git a/src/services/config-manager.ts b/src/services/config-manager.ts index c3bce075..45428a26 100644 --- a/src/services/config-manager.ts +++ b/src/services/config-manager.ts @@ -14,12 +14,16 @@ export interface AppConfig { export interface AccountConfig { accessToken: string; + accessTokenExpiresAt?: number; accountId?: string; accountName?: string; apps?: { [appId: string]: AppConfig; }; + authMethod?: "oauth" | "token"; currentAppId?: string; + oauthScope?: string; + refreshToken?: string; tokenId?: string; userEmail?: string; } @@ -58,6 +62,32 @@ export interface ConfigManager { switchAccount(alias: string): boolean; removeAccount(alias: string): boolean; + // OAuth management + storeOAuthTokens( + alias: string, + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; + userId?: string; + userEmail?: string; + }, + accountInfo?: { + accountId?: string; + accountName?: string; + }, + ): void; + getOAuthTokens(alias?: string): + | { + accessToken: string; + refreshToken: string; + expiresAt: number; + } + | undefined; + isAccessTokenExpired(alias?: string): boolean; + getAuthMethod(alias?: string): "oauth" | "token" | undefined; + // App management getApiKey(appId?: string): string | undefined; getAppName(appId: string): string | undefined; @@ -444,6 +474,86 @@ export class TomlConfigManager implements ConfigManager { this.saveConfig(); } + // Store OAuth tokens for an account + public storeOAuthTokens( + alias: string, + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; + userId?: string; + userEmail?: string; + }, + accountInfo?: { + accountId?: string; + accountName?: string; + }, + ): void { + this.config.accounts[alias] = { + ...this.config.accounts[alias], + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.expiresAt, + accountId: + accountInfo?.accountId ?? this.config.accounts[alias]?.accountId, + accountName: + accountInfo?.accountName ?? this.config.accounts[alias]?.accountName, + apps: this.config.accounts[alias]?.apps || {}, + authMethod: "oauth", + currentAppId: this.config.accounts[alias]?.currentAppId, + oauthScope: tokens.scope, + refreshToken: tokens.refreshToken, + userEmail: tokens.userEmail ?? this.config.accounts[alias]?.userEmail, + }; + + if (!this.config.current || !this.config.current.account) { + this.config.current = { account: alias }; + } + + this.saveConfig(); + } + + // Get OAuth tokens for the current account or specific alias + public getOAuthTokens(alias?: string): + | { + accessToken: string; + refreshToken: string; + expiresAt: number; + } + | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || account.authMethod !== "oauth") return undefined; + if (!account.refreshToken || !account.accessTokenExpiresAt) + return undefined; + + return { + accessToken: account.accessToken, + expiresAt: account.accessTokenExpiresAt, + refreshToken: account.refreshToken, + }; + } + + // Check if access token is expired (within 60 seconds of expiry) + public isAccessTokenExpired(alias?: string): boolean { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || !account.accessTokenExpiresAt) return false; + + // Consider expired if within 60 seconds of expiry + return Date.now() >= account.accessTokenExpiresAt - 60_000; + } + + // Get the auth method for the current account or specific alias + public getAuthMethod(alias?: string): "oauth" | "token" | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + return account?.authMethod; + } + // Switch to a different account public switchAccount(alias: string): boolean { if (!this.config.accounts[alias]) { diff --git a/src/services/control-api.ts b/src/services/control-api.ts index b4d22ecd..0cd7106f 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -1,11 +1,13 @@ import fetch, { type RequestInit } from "node-fetch"; import { getCliVersion } from "../utils/version.js"; import isTestMode from "../utils/test-mode.js"; +import type { TokenRefreshMiddleware } from "./token-refresh-middleware.js"; export interface ControlApiOptions { accessToken: string; controlHost?: string; logErrors?: boolean; + tokenRefreshMiddleware?: TokenRefreshMiddleware; } export interface App { @@ -167,10 +169,12 @@ export class ControlApi { private accessToken: string; private controlHost: string; private logErrors: boolean; + private tokenRefreshMiddleware?: TokenRefreshMiddleware; constructor(options: ControlApiOptions) { this.accessToken = options.accessToken; this.controlHost = options.controlHost || "control.ably.net"; + this.tokenRefreshMiddleware = options.tokenRefreshMiddleware; // Respect SUPPRESS_CONTROL_API_ERRORS env var for default behavior // Explicit options.logErrors will override the env var. // eslint-disable-next-line unicorn/no-negated-condition @@ -509,6 +513,12 @@ export class ControlApi { method = "GET", body?: unknown, ): Promise { + // If we have a token refresh middleware, get a valid token before each request + if (this.tokenRefreshMiddleware) { + this.accessToken = + await this.tokenRefreshMiddleware.getValidAccessToken(); + } + const url = this.controlHost.includes("local") ? `http://${this.controlHost}/api/v1${path}` : `https://${this.controlHost}/v1${path}`; diff --git a/src/services/oauth-callback-page.ts b/src/services/oauth-callback-page.ts new file mode 100644 index 00000000..ec88760c --- /dev/null +++ b/src/services/oauth-callback-page.ts @@ -0,0 +1,60 @@ +export function getSuccessPage(): string { + return ` + + + + + Ably CLI - Authentication Successful + + + +
+
+

Authentication Successful

+

You can close this tab and return to your terminal.

+
+ +`; +} + +function escapeHtml(unsafe: string): string { + return unsafe + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function getErrorPage(error: string): string { + const safeError = escapeHtml(error); + return ` + + + + + Ably CLI - Authentication Failed + + + +
+
+

Authentication Failed

+

${safeError}

+

Please return to your terminal and try again.

+
+ +`; +} diff --git a/src/services/oauth-client.ts b/src/services/oauth-client.ts new file mode 100644 index 00000000..151ad5e6 --- /dev/null +++ b/src/services/oauth-client.ts @@ -0,0 +1,319 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; + +import fetch from "node-fetch"; + +import { getErrorPage, getSuccessPage } from "./oauth-callback-page.js"; + +export interface OAuthTokens { + accessToken: string; + expiresAt: number; + refreshToken: string; + scope?: string; + tokenType: string; + userEmail?: string; + userId?: string; +} + +export interface OAuthConfig { + authorizationEndpoint: string; + clientId: string; + revocationEndpoint: string; + scopes: string[]; + tokenEndpoint: string; +} + +export interface OAuthClientOptions { + controlHost?: string; +} + +export class OAuthClient { + private config: OAuthConfig; + + constructor(options: OAuthClientOptions = {}) { + this.config = this.getOAuthConfig(options.controlHost); + } + + /** + * Perform the full OAuth login flow: + * 1. Generate PKCE verifier + challenge + * 2. Start localhost callback server + * 3. Return the authorization URL (caller opens browser) + * 4. Wait for callback with auth code + * 5. Exchange code for tokens + */ + async login( + openBrowser: (url: string) => Promise, + ): Promise { + const codeVerifier = this.generateCodeVerifier(); + const codeChallenge = this.generateCodeChallenge(codeVerifier); + const state = this.generateState(); + + const { authorizationCode, redirectUri } = await this.startCallbackServer( + state, + (port) => { + const redirectUri = `http://127.0.0.1:${port}/callback`; + const authUrl = this.buildAuthorizationUrl( + redirectUri, + codeChallenge, + state, + ); + return { authUrl, redirectUri }; + }, + openBrowser, + ); + + return this.exchangeCodeForTokens( + authorizationCode, + redirectUri, + codeVerifier, + ); + } + + /** + * Refresh an access token using a refresh token + */ + async refreshAccessToken(refreshToken: string): Promise { + return this.postForTokens( + { + client_id: this.config.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + }, + "Token refresh", + ); + } + + /** + * Revoke a token (access or refresh) + */ + async revokeToken(token: string): Promise { + const params = new URLSearchParams({ + client_id: this.config.clientId, + token, + }); + + try { + await fetch(this.config.revocationEndpoint, { + body: params.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + } catch { + // Best-effort revocation -- don't block on failure + } + } + + getClientId(): string { + return this.config.clientId; + } + + // --- Private helpers --- + + private getOAuthConfig(controlHost = "ably.com"): OAuthConfig { + const host = controlHost; + const scheme = host.includes("local") ? "http" : "https"; + return { + authorizationEndpoint: `${scheme}://${host}/oauth/authorize`, + clientId: "_YfP7jQzCscq8nAxvx0CKPx9zKNx3vcdp0QEDNAAdow", + revocationEndpoint: `${scheme}://${host}/oauth/revoke`, + scopes: ["full_access"], + tokenEndpoint: `${scheme}://${host}/oauth/token`, + }; + } + + generateCodeVerifier(): string { + return randomBytes(32).toString("base64url"); + } + + generateCodeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); + } + + generateState(): string { + return randomBytes(16).toString("base64url"); + } + + private buildAuthorizationUrl( + redirectUri: string, + codeChallenge: string, + state: string, + ): string { + const params = new URLSearchParams({ + client_id: this.config.clientId, + code_challenge: codeChallenge, + code_challenge_method: "S256", + redirect_uri: redirectUri, + response_type: "code", + scope: this.config.scopes.join(" "), + state, + }); + return `${this.config.authorizationEndpoint}?${params.toString()}`; + } + + private startCallbackServer( + expectedState: string, + buildUrls: (port: number) => { authUrl: string; redirectUri: string }, + openBrowser: (url: string) => Promise, + ): Promise<{ authorizationCode: string; redirectUri: string }> { + return new Promise((resolve, reject) => { + let resolved = false; + let redirectUri = ""; + + const server: Server = createServer( + (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || "/", `http://127.0.0.1`); + + if (url.pathname !== "/callback") { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + const description = + url.searchParams.get("error_description") || error; + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(getErrorPage(description)); + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error(`OAuth authorization failed: ${description}`)); + } + return; + } + + if (!code || state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(getErrorPage("Invalid callback parameters")); + if (!resolved) { + resolved = true; + cleanup(); + reject( + new Error( + "Invalid OAuth callback: missing code or state mismatch", + ), + ); + } + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(getSuccessPage()); + + if (!resolved) { + resolved = true; + cleanup(); + resolve({ authorizationCode: code, redirectUri }); + } + }, + ); + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error("OAuth login timed out after 120 seconds")); + } + }, 120_000); + + const cleanup = () => { + clearTimeout(timeout); + server.close(); + }; + + // Bind to loopback only, ephemeral port + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + resolved = true; + cleanup(); + reject(new Error("Failed to start callback server")); + return; + } + + const port = address.port; + const urls = buildUrls(port); + redirectUri = urls.redirectUri; + + openBrowser(urls.authUrl).catch((error) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error(`Failed to open browser: ${error}`)); + } + }); + }); + + server.on("error", (err) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error(`Callback server error: ${err.message}`)); + } + }); + }); + } + + private async exchangeCodeForTokens( + code: string, + redirectUri: string, + codeVerifier: string, + ): Promise { + return this.postForTokens( + { + client_id: this.config.clientId, + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }, + "Token exchange", + ); + } + + private async postForTokens( + params: Record, + operationName: string, + ): Promise { + const body = new URLSearchParams(params); + + const response = await fetch(this.config.tokenEndpoint, { + body: body.toString(), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `${operationName} failed (${response.status}): ${errorBody}`, + ); + } + + const data = (await response.json()) as Record; + return this.parseTokenResponse(data); + } + + private parseTokenResponse(data: Record): OAuthTokens { + const expiresIn = (data.expires_in as number) || 3600; + return { + accessToken: data.access_token as string, + expiresAt: Date.now() + expiresIn * 1000, + refreshToken: data.refresh_token as string, + scope: data.scope as string | undefined, + tokenType: (data.token_type as string) || "Bearer", + userEmail: data.user_email as string | undefined, + userId: data.user_id === undefined ? undefined : String(data.user_id), + }; + } +} diff --git a/src/services/token-refresh-middleware.ts b/src/services/token-refresh-middleware.ts new file mode 100644 index 00000000..ea36ad94 --- /dev/null +++ b/src/services/token-refresh-middleware.ts @@ -0,0 +1,70 @@ +import type { ConfigManager } from "./config-manager.js"; +import type { OAuthClient } from "./oauth-client.js"; + +export class TokenExpiredError extends Error { + constructor(message: string) { + super(message); + this.name = "TokenExpiredError"; + } +} + +export class TokenRefreshMiddleware { + private pendingRefresh: Promise | undefined; + + constructor( + private configManager: ConfigManager, + private oauthClient: OAuthClient, + private accountAlias?: string, + ) {} + + async getValidAccessToken(): Promise { + const authMethod = this.configManager.getAuthMethod(this.accountAlias); + + // Non-OAuth tokens: return as-is + if (authMethod !== "oauth") { + const token = this.configManager.getAccessToken(this.accountAlias); + if (!token) + throw new TokenExpiredError( + "No access token found. Please run 'ably login'.", + ); + return token; + } + + // Not expired: return current token + if (!this.configManager.isAccessTokenExpired(this.accountAlias)) { + const token = this.configManager.getAccessToken(this.accountAlias); + if (token) return token; + } + + // Expired: refresh using refresh token (deduplicate concurrent calls) + if (this.pendingRefresh) { + return this.pendingRefresh; + } + + this.pendingRefresh = this.refreshToken(); + try { + return await this.pendingRefresh; + } finally { + this.pendingRefresh = undefined; + } + } + + private async refreshToken(): Promise { + const tokens = this.configManager.getOAuthTokens(this.accountAlias); + if (!tokens?.refreshToken) { + throw new TokenExpiredError( + "OAuth session expired. Please run 'ably login' again.", + ); + } + + const newTokens = await this.oauthClient.refreshAccessToken( + tokens.refreshToken, + ); + const alias = + this.accountAlias || + this.configManager.getCurrentAccountAlias() || + "default"; + this.configManager.storeOAuthTokens(alias, newTokens); + return newTokens.accessToken; + } +} diff --git a/test/helpers/mock-config-manager.ts b/test/helpers/mock-config-manager.ts index b1011de0..8e8b4614 100644 --- a/test/helpers/mock-config-manager.ts +++ b/test/helpers/mock-config-manager.ts @@ -455,6 +455,78 @@ export class MockConfigManager implements ConfigManager { ); } + public storeOAuthTokens( + alias: string, + tokens: { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope?: string; + userId?: string; + userEmail?: string; + }, + accountInfo?: { + accountId?: string; + accountName?: string; + }, + ): void { + this.config.accounts[alias] = { + ...this.config.accounts[alias], + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.expiresAt, + accountId: + accountInfo?.accountId ?? this.config.accounts[alias]?.accountId, + accountName: + accountInfo?.accountName ?? this.config.accounts[alias]?.accountName, + apps: this.config.accounts[alias]?.apps || {}, + authMethod: "oauth", + currentAppId: this.config.accounts[alias]?.currentAppId, + oauthScope: tokens.scope, + refreshToken: tokens.refreshToken, + userEmail: tokens.userEmail ?? this.config.accounts[alias]?.userEmail, + }; + + if (!this.config.current || !this.config.current.account) { + this.config.current = { account: alias }; + } + } + + public getOAuthTokens(alias?: string): + | { + accessToken: string; + refreshToken: string; + expiresAt: number; + } + | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || account.authMethod !== "oauth") return undefined; + if (!account.refreshToken || !account.accessTokenExpiresAt) + return undefined; + + return { + accessToken: account.accessToken, + expiresAt: account.accessTokenExpiresAt, + refreshToken: account.refreshToken, + }; + } + + public isAccessTokenExpired(alias?: string): boolean { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + if (!account || !account.accessTokenExpiresAt) return false; + return Date.now() >= account.accessTokenExpiresAt - 60_000; + } + + public getAuthMethod(alias?: string): "oauth" | "token" | undefined { + const account = alias + ? this.config.accounts[alias] + : this.getCurrentAccount(); + return account?.authMethod; + } + public switchAccount(alias: string): boolean { if (!this.config.accounts[alias]) { return false; diff --git a/test/unit/commands/accounts/login.test.ts b/test/unit/commands/accounts/login.test.ts index 91701b20..757b2b10 100644 --- a/test/unit/commands/accounts/login.test.ts +++ b/test/unit/commands/accounts/login.test.ts @@ -295,4 +295,67 @@ describe("accounts:login command", () => { expect(config.accounts["default"].userEmail).toBe("test@example.com"); }); }); + + describe("legacy and OAuth login paths", () => { + it("should show --no-browser flag in help output for non-OAuth login", async () => { + const { stdout } = await runCommand( + ["accounts:login", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("--no-browser"); + expect(stdout).toContain("Do not open a browser"); + }); + + it("should store authMethod as non-oauth when logging in with direct token argument", async () => { + // Mock the /me endpoint + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the apps list endpoint + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, []); + + await runCommand( + ["accounts:login", mockAccessToken, "--json"], + import.meta.url, + ); + + // Verify config does not have authMethod set to "oauth" + const mock = getMockConfigManager(); + const config = mock.getConfig(); + expect(config.accounts["default"].authMethod).not.toBe("oauth"); + }); + + it("should not set refreshToken or accessTokenExpiresAt for direct token login", async () => { + // Mock the /me endpoint + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the apps list endpoint + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, []); + + await runCommand( + ["accounts:login", mockAccessToken, "--json"], + import.meta.url, + ); + + // Verify config does not have OAuth-specific fields + const mock = getMockConfigManager(); + const config = mock.getConfig(); + expect(config.accounts["default"].refreshToken).toBeUndefined(); + expect(config.accounts["default"].accessTokenExpiresAt).toBeUndefined(); + }); + }); }); diff --git a/test/unit/commands/accounts/logout.test.ts b/test/unit/commands/accounts/logout.test.ts index 307755e7..05c9ee11 100644 --- a/test/unit/commands/accounts/logout.test.ts +++ b/test/unit/commands/accounts/logout.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; +import nock from "nock"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; describe("accounts:logout command", () => { @@ -194,4 +195,110 @@ describe("accounts:logout command", () => { expect(result.error).toContain("not found"); }); }); + + describe("OAuth token revocation", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("should call revocation endpoint when logging out an OAuth account", async () => { + const mock = getMockConfigManager(); + mock.setConfig({ + current: { account: "testaccount" }, + accounts: { + testaccount: { + accessToken: "oauth_access_token", + accountId: "acc-123", + accountName: "Test Account", + userEmail: "test@example.com", + authMethod: "oauth", + refreshToken: "oauth_refresh_token", + accessTokenExpiresAt: Date.now() + 3600000, + }, + }, + }); + + // Expect two revocation calls (one for access token, one for refresh token) + const revokeScope = nock("https://ably.com") + .post("/oauth/revoke") + .twice() + .reply(200); + + const { stdout } = await runCommand( + ["accounts:logout", "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(revokeScope.isDone()).toBe(true); + }); + + it("should succeed even if revocation endpoint fails", async () => { + const mock = getMockConfigManager(); + mock.setConfig({ + current: { account: "testaccount" }, + accounts: { + testaccount: { + accessToken: "oauth_access_token", + accountId: "acc-123", + accountName: "Test Account", + userEmail: "test@example.com", + authMethod: "oauth", + refreshToken: "oauth_refresh_token", + accessTokenExpiresAt: Date.now() + 3600000, + }, + }, + }); + + // Revocation endpoint returns errors + nock("https://ably.com") + .post("/oauth/revoke") + .twice() + .replyWithError("Connection refused"); + + const { stdout } = await runCommand( + ["accounts:logout", "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + + // Verify the account was still removed + const config = mock.getConfig(); + expect(config.accounts["testaccount"]).toBeUndefined(); + }); + + it("should not call revocation endpoint for non-OAuth account logout", async () => { + const mock = getMockConfigManager(); + mock.setConfig({ + current: { account: "testaccount" }, + accounts: { + testaccount: { + accessToken: "regular_token", + accountId: "acc-123", + accountName: "Test Account", + userEmail: "test@example.com", + }, + }, + }); + + // Set up nock interceptor that should NOT be called + const revokeScope = nock("https://ably.com") + .post("/oauth/revoke") + .reply(200); + + const { stdout } = await runCommand( + ["accounts:logout", "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + + // The revocation endpoint should not have been called + expect(revokeScope.isDone()).toBe(false); + }); + }); }); diff --git a/test/unit/services/oauth-client.test.ts b/test/unit/services/oauth-client.test.ts new file mode 100644 index 00000000..2051aa6a --- /dev/null +++ b/test/unit/services/oauth-client.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, afterEach } from "vitest"; +import nock from "nock"; +import fetch from "node-fetch"; +import { OAuthClient } from "../../../src/services/oauth-client.js"; +import { + getErrorPage, + getSuccessPage, +} from "../../../src/services/oauth-callback-page.js"; + +describe("OAuthClient", () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe("PKCE generation", () => { + it("generateCodeVerifier() returns a base64url string of expected length", () => { + const client = new OAuthClient(); + const verifier = client.generateCodeVerifier(); + + // 32 random bytes encoded as base64url produces ~43 characters + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(verifier.length).toBeGreaterThanOrEqual(40); + expect(verifier.length).toBeLessThanOrEqual(50); + }); + + it("generateCodeChallenge() returns a base64url SHA256 hash", () => { + const client = new OAuthClient(); + const verifier = "test-verifier-value"; + const challenge = client.generateCodeChallenge(verifier); + + // SHA256 digest as base64url is 43 characters + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge.length).toBeGreaterThanOrEqual(40); + expect(challenge.length).toBeLessThanOrEqual(50); + }); + + it("generateCodeChallenge() is deterministic for the same verifier", () => { + const client = new OAuthClient(); + const verifier = "deterministic-test-verifier"; + const challenge1 = client.generateCodeChallenge(verifier); + const challenge2 = client.generateCodeChallenge(verifier); + + expect(challenge1).toBe(challenge2); + }); + + it("generateCodeChallenge() produces different challenges for different verifiers", () => { + const client = new OAuthClient(); + const challenge1 = client.generateCodeChallenge("verifier-one"); + const challenge2 = client.generateCodeChallenge("verifier-two"); + + expect(challenge1).not.toBe(challenge2); + }); + + it("generateState() returns a base64url string of expected length", () => { + const client = new OAuthClient(); + const state = client.generateState(); + + // 16 random bytes encoded as base64url produces ~22 characters + expect(state).toMatch(/^[A-Za-z0-9_-]+$/); + expect(state.length).toBeGreaterThanOrEqual(20); + expect(state.length).toBeLessThanOrEqual(25); + }); + }); + + describe("OAuth config derivation", () => { + it("default host uses https scheme", async () => { + const client = new OAuthClient(); + + const scope = nock("https://ably.com").post("/oauth/token").reply(200, { + access_token: "new_access", + expires_in: 3600, + refresh_token: "new_refresh", + scope: "full_access", + token_type: "Bearer", + }); + + await client.refreshAccessToken("some_refresh_token"); + + expect(scope.isDone()).toBe(true); + }); + + it("host containing 'local' uses http scheme", async () => { + const client = new OAuthClient({ controlHost: "localhost:3000" }); + + const scope = nock("http://localhost:3000") + .post("/oauth/token") + .reply(200, { + access_token: "new_access", + expires_in: 3600, + refresh_token: "new_refresh", + scope: "full_access", + token_type: "Bearer", + }); + + await client.refreshAccessToken("some_refresh_token"); + + expect(scope.isDone()).toBe(true); + }); + + it("client ID matches the expected value", () => { + const client = new OAuthClient(); + expect(client.getClientId()).toBe( + "_YfP7jQzCscq8nAxvx0CKPx9zKNx3vcdp0QEDNAAdow", + ); + }); + }); + + describe("refreshAccessToken", () => { + it("returns OAuthTokens on successful refresh", async () => { + const client = new OAuthClient(); + + nock("https://ably.com").post("/oauth/token").reply(200, { + access_token: "refreshed_access_token", + expires_in: 7200, + refresh_token: "refreshed_refresh_token", + scope: "full_access", + token_type: "Bearer", + }); + + const tokens = await client.refreshAccessToken("old_refresh_token"); + + expect(tokens.accessToken).toBe("refreshed_access_token"); + expect(tokens.refreshToken).toBe("refreshed_refresh_token"); + expect(tokens.tokenType).toBe("Bearer"); + expect(tokens.scope).toBe("full_access"); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("throws on non-200 response with status and body", async () => { + const client = new OAuthClient(); + + nock("https://ably.com").post("/oauth/token").reply(401, "invalid_grant"); + + await expect( + client.refreshAccessToken("bad_refresh_token"), + ).rejects.toThrow("Token refresh failed (401): invalid_grant"); + }); + + it("sends correct form-encoded body", async () => { + const client = new OAuthClient(); + const refreshToken = "my_refresh_token"; + + const scope = nock("https://ably.com") + .post("/oauth/token", (body: Record) => { + return ( + body.grant_type === "refresh_token" && + body.client_id === "_YfP7jQzCscq8nAxvx0CKPx9zKNx3vcdp0QEDNAAdow" && + body.refresh_token === refreshToken + ); + }) + .reply(200, { + access_token: "new_access", + expires_in: 3600, + refresh_token: "new_refresh", + token_type: "Bearer", + }); + + await client.refreshAccessToken(refreshToken); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe("revokeToken", () => { + it("sends POST to revocation endpoint", async () => { + const client = new OAuthClient(); + + const scope = nock("https://ably.com").post("/oauth/revoke").reply(200); + + await client.revokeToken("token_to_revoke"); + + expect(scope.isDone()).toBe(true); + }); + + it("does not throw on network error", async () => { + const client = new OAuthClient(); + + nock("https://ably.com") + .post("/oauth/revoke") + .replyWithError("Connection refused"); + + // Should not throw + await expect( + client.revokeToken("token_to_revoke"), + ).resolves.toBeUndefined(); + }); + }); + + describe("login flow (callback server)", () => { + it("completes login when callback receives valid code and state", async () => { + const client = new OAuthClient(); + + // Mock the token exchange endpoint + nock("https://ably.com").post("/oauth/token").reply(200, { + access_token: "login_access_token", + expires_in: 3600, + refresh_token: "login_refresh_token", + scope: "full_access", + token_type: "Bearer", + }); + + let capturedUrl = ""; + const tokens = await client.login(async (url) => { + capturedUrl = url; + + // Extract state and redirect_uri from the authorization URL + const parsed = new URL(url); + const state = parsed.searchParams.get("state")!; + const redirectUri = parsed.searchParams.get("redirect_uri")!; + + // Simulate browser callback with the correct state + await fetch(`${redirectUri}?code=test_auth_code&state=${state}`); + }); + + expect(capturedUrl).toContain("oauth/authorize"); + expect(capturedUrl).toContain("code_challenge_method=S256"); + expect(capturedUrl).toContain("response_type=code"); + expect(tokens.accessToken).toBe("login_access_token"); + expect(tokens.refreshToken).toBe("login_refresh_token"); + }); + + it("rejects when callback receives an error from the OAuth server", async () => { + const client = new OAuthClient(); + + await expect( + client.login(async (url) => { + const parsed = new URL(url); + const redirectUri = parsed.searchParams.get("redirect_uri")!; + + await fetch( + `${redirectUri}?error=access_denied&error_description=User+denied`, + ); + }), + ).rejects.toThrow("OAuth authorization failed: User denied"); + }); + + it("rejects when callback has mismatched state", async () => { + const client = new OAuthClient(); + + await expect( + client.login(async (url) => { + const parsed = new URL(url); + const redirectUri = parsed.searchParams.get("redirect_uri")!; + + await fetch(`${redirectUri}?code=test_code&state=wrong_state`); + }), + ).rejects.toThrow("Invalid OAuth callback"); + }); + + it("rejects when token exchange fails", async () => { + const client = new OAuthClient(); + + nock("https://ably.com").post("/oauth/token").reply(400, "invalid_grant"); + + await expect( + client.login(async (url) => { + const parsed = new URL(url); + const state = parsed.searchParams.get("state")!; + const redirectUri = parsed.searchParams.get("redirect_uri")!; + + await fetch(`${redirectUri}?code=bad_code&state=${state}`); + }), + ).rejects.toThrow("Token exchange failed (400)"); + }); + }); + + describe("callback page HTML", () => { + it("getSuccessPage returns HTML with success message", () => { + const html = getSuccessPage(); + expect(html).toContain("Authentication Successful"); + expect(html).toContain("close this tab"); + }); + + it("getErrorPage escapes HTML special characters to prevent XSS", () => { + const malicious = ''; + const html = getErrorPage(malicious); + + expect(html).not.toContain("'; - const html = getErrorPage(malicious); - - expect(html).not.toContain("