diff --git a/.agents/skills/qa-engineer-manual/SKILL.md b/.agents/skills/qa-engineer-manual/SKILL.md index eb5a3ae34..1a6b3de87 100644 --- a/.agents/skills/qa-engineer-manual/SKILL.md +++ b/.agents/skills/qa-engineer-manual/SKILL.md @@ -1,8 +1,9 @@ --- name: qa-engineer-manual description: - Use when the user requests manual testing, QA, or reproduction of a bug report for a package - against a real Storyblok space + Use for manual testing, QA, debugging, or reproducing a bug report. Also for verifying that a + feature works, seeding or setting up a space, calling an API with real credentials, or running a + built CLI or package for real --- # QA Engineer for Manual Testing diff --git a/.env.qa-engineer-manual.template b/.env.qa-engineer-manual.template index bde43778a..ca07bf415 100644 --- a/.env.qa-engineer-manual.template +++ b/.env.qa-engineer-manual.template @@ -5,3 +5,9 @@ STORYBLOK_ASSET_TOKEN= STORYBLOK_ASSET_TOKEN_TARGET= STORYBLOK_SPACE_ID= STORYBLOK_SPACE_ID_TARGET= + +# OAuth client for `storyblok login --oauth`, from an integration app +# (POST /v1/oauth_clients with redirect URI http://localhost:4900/oauth/callback). +# ID is the app's oauth_identifier, secret its oauth_secret. +STORYBLOK_OAUTH_CLIENT_ID= +STORYBLOK_OAUTH_CLIENT_SECRET= diff --git a/packages/cli/src/commands/create/index.test.ts b/packages/cli/src/commands/create/index.test.ts index 23f528fa9..c9ecf8ec9 100644 --- a/packages/cli/src/commands/create/index.test.ts +++ b/packages/cli/src/commands/create/index.test.ts @@ -1702,7 +1702,7 @@ describe("createCommand", () => { await createCommand.parseAsync(["node", "test", "my-project", "--blueprint", "react"]); - expect(getUser).toHaveBeenCalledWith("valid-token", "eu"); + expect(getUser).toHaveBeenCalledWith({ personalAccessToken: "valid-token" }, "eu"); }); }); }); diff --git a/packages/cli/src/commands/create/index.ts b/packages/cli/src/commands/create/index.ts index fd0e74626..0fa084dd5 100644 --- a/packages/cli/src/commands/create/index.ts +++ b/packages/cli/src/commands/create/index.ts @@ -3,10 +3,11 @@ import { handleError, isRegion, requireAuthentication, + sessionCredential, toHumanReadable, } from "../../utils"; import { colorPalette, commands, type RegionCode, regions } from "../../constants"; -import { performInteractiveLogin } from "../login/helpers"; +import { type InteractiveLoginResult, performInteractiveLogin } from "../login/helpers"; import { getProgram } from "../../program"; import type { CreateOptions } from "./constants"; import { session } from "../../session"; @@ -38,9 +39,7 @@ function showNextSteps(technologyTemplate: string, finalProjectPath: string) { } // Helper to handle interactive login prompt -async function promptForLogin( - verbose: boolean, -): Promise<{ token: string; region: RegionCode } | null> { +async function promptForLogin(verbose: boolean): Promise { const ui = getUI(); try { ui.br(); @@ -109,8 +108,10 @@ export const createCommand = program const { state, initializeSession } = session(); - // Declare these outside to be used throughout the function - let password: string | undefined; + // Declare region outside to be used throughout the function. + // The API credential (PAT or OAuth token) is resolved from the session via + // sessionCredential() at each call site; the shared MAPI client is already + // configured by the program preAction hook. let region: RegionCode | undefined; // Get region from session for fallback (even when using --token) @@ -130,16 +131,8 @@ export const createCommand = program await initializeSession(); } - // After authentication check, password and region are guaranteed to be defined - const authenticatedState = state as { - isLoggedIn: true; - password: string; - region: RegionCode; - login?: string; - envLogin?: boolean; - }; - password = authenticatedState.password; - region = authenticatedState.region; + // After authentication check, region is guaranteed to be defined. + region = state.region ?? region; // Validate that user-provided region matches their account region when creating a space // This check happens early before any project scaffolding @@ -151,12 +144,9 @@ export const createCommand = program ); return; } - } else if (state.isLoggedIn && state.password) { - // If using --token or --skip-space but user is logged in, still get their credentials for getMapiClient - password = state.password; - if (state.region) { - region = state.region; - } + } else if (state.isLoggedIn && state.region) { + // If using --token or --skip-space but user is logged in, keep their region. + region = state.region; } let activeSpinner: CLISpinner | null = null; @@ -272,10 +262,14 @@ export const createCommand = program } try { try { - // At this point, password and region are guaranteed to be defined because: + // At this point, a credential and region are guaranteed to be defined because: // 1. We're not in the token branch (which returns early) - // 2. Authentication was required and completed - const user = await getUser(password!, region!); + // 2. Authentication was required and completed (PAT password or OAuth token) + const credential = sessionCredential(state); + if (!credential) { + throw new Error("No credential found"); + } + const user = await getUser(credential, region!); if (!user) { throw new Error("User data is undefined"); } @@ -289,9 +283,10 @@ export const createCommand = program } // Re-initialize session and retry fetching user await initializeSession(); - const { password: newPassword, region: newRegion } = session().state; + const retryState = session().state; + const retryCredential = sessionCredential(retryState); try { - const user = await getUser(newPassword!, newRegion!); + const user = await getUser(retryCredential!, retryState.region!); if (!user) { throw new Error("User data is undefined"); } diff --git a/packages/cli/src/commands/login/README.md b/packages/cli/src/commands/login/README.md index 1c78317a3..9433717d6 100644 --- a/packages/cli/src/commands/login/README.md +++ b/packages/cli/src/commands/login/README.md @@ -12,7 +12,8 @@ storyblok login This will start an interactive login process where you can choose between: - Email and password login -- Token login (Personal Access Token โ€“ recommended for CI and required for SSO users) +- Token login (Personal Access Token, recommended for CI and required for SSO users) +- OAuth login (opens your browser for consent; no configuration needed) ### Get your personal access token @@ -23,6 +24,7 @@ Go to [https://app.storyblok.com/#/me/account?tab=token] and click on **Generate | Option | Description | Default | | ----------------------- | ------------------------------------------------------------ | ------- | | `-t, --token ` | Login directly with a token (useful for CI environments) | - | +| `--oauth` | Login with OAuth (opens your browser for consent) | - | | `-r, --region ` | Set the region to work with (must match your space's region) | `eu` | ## Examples @@ -45,8 +47,20 @@ storyblok login --token PERSONAL_ACCESS_TOKEN storyblok login --token PERSONAL_ACCESS_TOKEN --region us ``` +4. Login with OAuth: + +```bash +storyblok login --oauth +``` + +The CLI ships with its own OAuth client, so there is nothing to configure. To authorize against your +own OAuth app instead, for example while developing against a self-hosted instance, set +`STORYBLOK_OAUTH_CLIENT_ID` and `STORYBLOK_OAUTH_CLIENT_SECRET`. + ## Notes +- OAuth login needs port 4900 free while you authorize, because the OAuth app registers + `http://localhost:4900/oauth/callback` as its only redirect URI - Credentials are stored securely in `~/.storyblok/credentials.json` - The region setting will be used for all subsequent CLI commands - If you're already logged in, you'll need to logout first to switch accounts diff --git a/packages/cli/src/commands/login/helpers.ts b/packages/cli/src/commands/login/helpers.ts index 4a05c540a..e5c539339 100644 --- a/packages/cli/src/commands/login/helpers.ts +++ b/packages/cli/src/commands/login/helpers.ts @@ -6,20 +6,31 @@ import { handleError } from "../../utils"; import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from "./actions"; import { session } from "../../session"; import { type CLISpinner, getUI, stderrPromptContext } from "../../lib/ui"; +import { performOAuthLogin } from "../oauth/login-flow"; +import type { OAuthLoginResult } from "../oauth/login-flow"; /** - * Performs interactive login flow with email/password or token + * Result of an interactive login. OAuth logins carry no token here (the access + * token lives in the OAuth credential store); PAT and email logins carry the + * personal access token. + */ +export type InteractiveLoginResult = + | { authType: "oauth"; region: RegionCode } + | { authType: "pat"; token: string; region: RegionCode }; + +/** + * Performs interactive login flow with OAuth, email/password, or token * @param options - Options for the login flow * @param options.verbose - Whether to show verbose error output * @param options.preSelectedRegion - Pre-selected region to skip region selection * @param options.showWelcomeMessage - Whether to show welcome message after login - * @returns Object with token and region, or null if cancelled/failed + * @returns The login result, or null if cancelled/failed */ export async function performInteractiveLogin(options?: { verbose?: boolean; preSelectedRegion?: RegionCode; showWelcomeMessage?: boolean; -}): Promise<{ token: string; region: RegionCode } | null> { +}): Promise { const { verbose = false, preSelectedRegion, showWelcomeMessage = true } = options || {}; const ui = getUI(); let activeSpinner: CLISpinner | null = null; @@ -29,6 +40,11 @@ export async function performInteractiveLogin(options?: { { message: "How would you like to login?", choices: [ + { + name: "With OAuth (recommended โ€” opens your browser)", + value: "login-with-oauth", + short: "OAuth", + }, { name: "With email", value: "login-with-email", @@ -47,6 +63,21 @@ export async function performInteractiveLogin(options?: { let userToken: string; let userRegion: RegionCode; + if (strategy === "login-with-oauth") { + const region = + preSelectedRegion || + (await select({ + message: "Please select the region you would like to work in:", + choices: Object.values(regions).map((region: RegionCode) => ({ + name: regionNames[region], + value: region, + })), + default: regions.EU, + })); + const result = await performOAuthLoginStrategy({ region, verbose }); + return result ? { authType: "oauth", region } : null; + } + if (strategy === "login-with-token") { ui.info( [ @@ -94,7 +125,7 @@ export async function performInteractiveLogin(options?: { true, ); } - return { token: userToken, region: userRegion }; + return { authType: "pat", token: userToken, region: userRegion }; } } else { const userEmail = await input( @@ -161,7 +192,7 @@ export async function performInteractiveLogin(options?: { true, ); } - return { token: userToken, region: userRegion }; + return { authType: "pat", token: userToken, region: userRegion }; } } @@ -173,3 +204,31 @@ export async function performInteractiveLogin(options?: { return null; } } + +/** + * Runs the OAuth Authorization Code login flow and reports the granted scopes and spaces. + * @returns the login result, or null when the flow was cancelled or failed. + */ +export async function performOAuthLoginStrategy(options: { + region: RegionCode; + verbose?: boolean; +}): Promise { + const { region, verbose = false } = options; + const ui = getUI(); + try { + const result = await performOAuthLogin({ region }); + const spaceList = result.spaces.length + ? result.spaces.map((space) => `${space.id} (${space.region})`).join(", ") + : "none (grant is not space-scoped)"; + ui.ok( + `Successfully logged in with OAuth in region ${chalk.hex(colorPalette.PRIMARY)(`${regionNames[region]} (${region})`)}.\n` + + `Granted scopes: ${result.scopes.join(", ")}\n` + + `Authorized spaces: ${spaceList}`, + true, + ); + return result; + } catch (error) { + handleError(error as Error, verbose); + return null; + } +} diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index a6047f303..f46968da1 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -6,7 +6,7 @@ import { getProgram } from "../../program"; import { CommandError, handleError, isRegion } from "../../utils"; import { loginWithToken } from "./actions"; import { session } from "../../session"; -import { performInteractiveLogin } from "./helpers"; +import { performInteractiveLogin, performOAuthLoginStrategy } from "./helpers"; import { type CLISpinner, getUI, stderrPromptContext } from "../../lib/ui"; const program = getProgram(); // Get the shared singleton instance @@ -24,7 +24,8 @@ export const loginCommand = program "-r, --region ", `The region you would like to work in. Please keep in mind that the region must match the region of your space. This region flag will be used for the other cli's commands. You can use the values: ${allRegionsText}.`, ) - .action(async (options: { token: string; region: RegionCode }) => { + .option("--oauth", "Login with OAuth (opens your browser for consent)") + .action(async (options: { token: string; region: RegionCode; oauth?: boolean }) => { const ui = getUI(); ui.title(`${commands.LOGIN}`, colorPalette.LOGIN); // Global options @@ -50,6 +51,13 @@ export const loginCommand = program return; } + if (options.oauth) { + const userRegion = region || regions.EU; + await performOAuthLoginStrategy({ region: userRegion, verbose }); + ui.br(); + return; + } + if (token) { let spinner: CLISpinner | null = null; try { diff --git a/packages/cli/src/commands/login/oauth.test.ts b/packages/cli/src/commands/login/oauth.test.ts new file mode 100644 index 000000000..1d891842a --- /dev/null +++ b/packages/cli/src/commands/login/oauth.test.ts @@ -0,0 +1,79 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +import "../../index"; +import { loginCommand } from "./index"; +import { getOAuthEntry } from "../oauth/store"; +import { session } from "../../session"; +import { loggedOutSessionState } from "../../../test/setup"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); +// Avoid opening a real browser and a real socket in tests. +vi.mock("open", () => ({ default: vi.fn(async () => undefined) })); +vi.mock("../oauth/server", () => ({ + waitForCallback: vi.fn(async () => ({ code: "auth-code", state: "ignored" })), +})); +// Force the state check to pass by returning the same state generatePkce/generateState produced. +vi.mock("../oauth/pkce", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, generateState: () => "ignored" }; +}); + +const server = setupServer(); +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("login --oauth", () => { + beforeEach(async () => { + vol.reset(); + // The shared test session mock defaults to a logged-in state; reset it so the login + // command's "already logged in" guard does not short-circuit the oauth flow. + vi.mocked(session().initializeSession).mockImplementation(async () => { + session().state = loggedOutSessionState(); + }); + // The baked-in client is still a placeholder, so point the CLI at a test client + // through the env-var override that development and self-hosted setups use. + process.env.STORYBLOK_OAUTH_CLIENT_ID = "cid"; + process.env.STORYBLOK_OAUTH_CLIENT_SECRET = "secret"; + }); + + afterEach(() => { + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + }); + + it("should complete the oauth flow and persist tokens and spaces", async () => { + server.use( + http.post("https://mapi.storyblok.com/oauth/token", () => + HttpResponse.json({ + access_token: "sb_oat_x", + refresh_token: "sb_ort_x", + token_type: "bearer", + expires_in: 900, + scope: "stories:read offline_access", + }), + ), + // The grant introspection payload is nested under a `grant` root key (storyrails). + http.get("https://mapi.storyblok.com/v1/oauth/grant", () => + HttpResponse.json({ + grant: { + scopes: ["stories:read", "offline_access"], + expires_at: "2026-07-20T12:00:00.000Z", + app: { client_id: "cid", name: "Storyblok CLI" }, + spaces: [{ id: 99, region: "eu" }], + }, + }), + ), + ); + + await loginCommand.parseAsync(["node", "test", "--oauth", "--region", "eu"]); + + const entry = await getOAuthEntry("eu"); + expect(entry.tokens?.access_token).toBe("sb_oat_x"); + expect(entry.spaces).toEqual([{ id: 99, region: "eu" }]); + }); +}); diff --git a/packages/cli/src/commands/logout/index.test.ts b/packages/cli/src/commands/logout/index.test.ts index 7ed5c57ef..c116cc8de 100644 --- a/packages/cli/src/commands/logout/index.test.ts +++ b/packages/cli/src/commands/logout/index.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { logoutCommand } from "./"; import { session } from "../../session"; -import { removeAllCredentials } from "../../creds"; +import { removePatCredentials } from "../../creds"; import { loggedOutSessionState } from "../../../test/setup"; vi.mock("../../creds", () => ({ @@ -9,6 +9,7 @@ vi.mock("../../creds", () => ({ addCredentials: vi.fn(), removeCredentials: vi.fn(), removeAllCredentials: vi.fn(), + removePatCredentials: vi.fn(), })); const preconditions = { @@ -27,12 +28,12 @@ describe("logoutCommand", () => { it("should log out the user if has previously login", async () => { await logoutCommand.parseAsync(["node", "test"]); - expect(removeAllCredentials).toHaveBeenCalled(); + expect(removePatCredentials).toHaveBeenCalled(); }); it("should not log out the user if has not previously login", async () => { preconditions.loggedOut(); await logoutCommand.parseAsync(["node", "test"]); - expect(removeAllCredentials).not.toHaveBeenCalled(); + expect(removePatCredentials).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/commands/logout/index.ts b/packages/cli/src/commands/logout/index.ts index a50e4df48..68051a19d 100644 --- a/packages/cli/src/commands/logout/index.ts +++ b/packages/cli/src/commands/logout/index.ts @@ -1,9 +1,12 @@ -import { removeAllCredentials } from "../../creds"; +import { removePatCredentials } from "../../creds"; import { colorPalette, commands } from "../../constants"; import { getProgram } from "../../program"; import { handleError } from "../../utils"; import { session } from "../../session"; import { getUI } from "../../lib/ui"; +import { resolveOAuthClient } from "../oauth/client"; +import { getOAuthEntry } from "../oauth/store"; +import { revokeToken } from "../oauth/token-endpoint"; const program = getProgram(); // Get the shared singleton instance @@ -16,16 +19,35 @@ export const logoutCommand = program const verbose = program.opts().verbose; try { - const { state } = session(); - if (!state.isLoggedIn || !state.password || !state.region) { + const { state, initializeSession, clearOAuthSession } = session(); + await initializeSession(); + + if (!state.isLoggedIn) { ui.warn(`You are already logged out. If you want to login, please use the login command.`); ui.br(); return; } - await removeAllCredentials(); + + if (state.authType === "oauth" && state.region) { + // Revoke the grant server-side (best-effort) before clearing the local session, + // so the tokens can no longer mint new tokens after logout. A network/API failure + // must not block the local logout. + const { tokens } = await getOAuthEntry(state.region); + const tokenToRevoke = tokens?.refresh_token ?? tokens?.access_token; + if (tokenToRevoke) { + try { + const client = resolveOAuthClient(); + await revokeToken(state.region, tokenToRevoke, client); + } catch (error) { + ui.warn(`Could not revoke the OAuth session server-side: ${(error as Error).message}`); + } + } + await clearOAuthSession(state.region); + } else { + await removePatCredentials(); + } ui.ok(`Successfully logged out.`, true); - ui.br(); } catch (error) { handleError(error as Error, verbose); } diff --git a/packages/cli/src/commands/logout/oauth.test.ts b/packages/cli/src/commands/logout/oauth.test.ts new file mode 100644 index 000000000..1041f7378 --- /dev/null +++ b/packages/cli/src/commands/logout/oauth.test.ts @@ -0,0 +1,102 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +import "../../index"; +import { logoutCommand } from "./index"; +import { getOAuthEntry } from "../oauth/store"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); +// The shared test setup mocks `session()` (defaulting to a logged-in PAT session). Logout +// needs the real session logic here so `authType` becomes 'oauth' and `clearOAuthSession` +// genuinely clears the store, matching the technique used in `src/session.oauth.test.ts`. +vi.unmock("../../session"); + +const revokeRequests: string[] = []; +const server = setupServer( + // RFC 7009 revoke: always 200 with an empty body (storyrails `head :ok`). + http.post("https://mapi.storyblok.com/oauth/revoke", async ({ request }) => { + revokeRequests.push(await request.text()); + return new HttpResponse(null, { status: 200 }); + }), +); + +beforeAll(() => server.listen()); +afterAll(() => server.close()); + +describe("logout with an oauth session", () => { + beforeEach(() => { + vol.reset(); + vi.resetModules(); + server.resetHandlers(); + revokeRequests.length = 0; + delete process.env.STORYBLOK_LOGIN; + delete process.env.STORYBLOK_TOKEN; + delete process.env.STORYBLOK_REGION; + // The baked-in client is still a placeholder, so revocation resolves its + // credentials through the env-var override. + process.env.STORYBLOK_OAUTH_CLIENT_ID = "id"; + process.env.STORYBLOK_OAUTH_CLIENT_SECRET = "secret"; + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + oauth: { + eu: { + tokens: { + auth_type: "oauth", + access_token: "sb_oat_x", + refresh_token: "sb_ort_x", + expires_at: "2026-07-20T12:00:00.000Z", + }, + spaces: [{ id: 5, region: "eu" }], + }, + }, + }), + }); + }); + afterEach(() => { + vol.reset(); + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + }); + + it("should clear the stored oauth section", async () => { + await logoutCommand.parseAsync(["node", "test"]); + expect(await getOAuthEntry("eu")).toEqual({}); + }); + + it("should revoke the refresh token server-side before clearing the session", async () => { + await logoutCommand.parseAsync(["node", "test"]); + + expect(revokeRequests).toHaveLength(1); + const body = new URLSearchParams(revokeRequests[0]); + expect(body.get("token")).toBe("sb_ort_x"); + expect(body.get("client_id")).toBe("id"); + expect(body.get("client_secret")).toBe("secret"); + expect(await getOAuthEntry("eu")).toEqual({}); + }); + + it("should still clear the local session when revocation fails", async () => { + server.use( + http.post( + "https://mapi.storyblok.com/oauth/revoke", + () => new HttpResponse(null, { status: 500 }), + ), + ); + + await logoutCommand.parseAsync(["node", "test"]); + + expect(await getOAuthEntry("eu")).toEqual({}); + }); + + it("should still clear the local session when no client credentials are available to revoke with", async () => { + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + + await logoutCommand.parseAsync(["node", "test"]); + + expect(revokeRequests).toHaveLength(0); + expect(await getOAuthEntry("eu")).toEqual({}); + }); +}); diff --git a/packages/cli/src/commands/oauth/client.test.ts b/packages/cli/src/commands/oauth/client.test.ts new file mode 100644 index 000000000..2ee2f8db7 --- /dev/null +++ b/packages/cli/src/commands/oauth/client.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveOAuthClient } from "./client"; +import { OAUTH_CLIENT_ID, OAUTH_CLIENT_PLACEHOLDER_PREFIX } from "./constants"; + +describe("resolveOAuthClient", () => { + afterEach(() => { + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + }); + + it("should prefer env-var client credentials over the baked-in client", () => { + process.env.STORYBLOK_OAUTH_CLIENT_ID = "env-id"; + process.env.STORYBLOK_OAUTH_CLIENT_SECRET = "env-secret"; + expect(resolveOAuthClient()).toEqual({ client_id: "env-id", client_secret: "env-secret" }); + }); + + // Until the first-party app is registered, the baked-in values are placeholders. Once they + // are replaced this expectation flips to returning them, and the guard becomes unreachable. + it("should explain that the build ships without credentials while the client is a placeholder", () => { + expect(OAUTH_CLIENT_ID.startsWith(OAUTH_CLIENT_PLACEHOLDER_PREFIX)).toBe(true); + expect(() => resolveOAuthClient()).toThrow(/ships without OAuth client credentials/); + }); +}); diff --git a/packages/cli/src/commands/oauth/client.ts b/packages/cli/src/commands/oauth/client.ts new file mode 100644 index 000000000..1edc6f75a --- /dev/null +++ b/packages/cli/src/commands/oauth/client.ts @@ -0,0 +1,23 @@ +import { CommandError } from "../../utils"; +import { OAUTH_CLIENT_ID, OAUTH_CLIENT_PLACEHOLDER_PREFIX, OAUTH_CLIENT_SECRET } from "./constants"; +import type { OAuthClientCredentials } from "./store"; +import { getOAuthClientFromEnv } from "./store"; + +// Resolution order: env vars first (for development against another app or a self-hosted +// instance), then the first-party client baked into the CLI. Users never configure anything. +export const resolveOAuthClient = (): OAuthClientCredentials => { + const fromEnv = getOAuthClientFromEnv(); + if (fromEnv) { + return fromEnv; + } + + if (OAUTH_CLIENT_ID.startsWith(OAUTH_CLIENT_PLACEHOLDER_PREFIX)) { + throw new CommandError( + `This build of the CLI ships without OAuth client credentials, so \`--oauth\` cannot be used yet.\n` + + `Log in with a Personal Access Token (\`storyblok login --token \`), or set the ` + + `STORYBLOK_OAUTH_CLIENT_ID and STORYBLOK_OAUTH_CLIENT_SECRET environment variables to use your own OAuth app.`, + ); + } + + return { client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET }; +}; diff --git a/packages/cli/src/commands/oauth/constants.ts b/packages/cli/src/commands/oauth/constants.ts new file mode 100644 index 000000000..58ce751f8 --- /dev/null +++ b/packages/cli/src/commands/oauth/constants.ts @@ -0,0 +1,54 @@ +export const OAUTH_CALLBACK_PORT = 4900; +export const OAUTH_CALLBACK_PATH = "/oauth/callback"; +export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`; + +// First-party OAuth client baked into the CLI, the same model `gh` and `gcloud` use: the user +// supplies nothing. This is a public client, so the secret is not a security boundary; PKCE +// protects the code exchange. One integration app covers every region. +// TODO(DX-490): replace both placeholders with the registered "Storyblok CLI" app credentials. +export const OAUTH_CLIENT_ID = "REPLACE_WITH_STORYBLOK_CLI_OAUTH_CLIENT_ID"; +export const OAUTH_CLIENT_SECRET = "REPLACE_WITH_STORYBLOK_CLI_OAUTH_CLIENT_SECRET"; +// Marks the values above as not-yet-provisioned, so a build without real credentials fails +// with an explanation instead of sending the user to a broken authorization page. +export const OAUTH_CLIENT_PLACEHOLDER_PREFIX = "REPLACE_WITH_"; + +// Scopes requested at login. This mirrors the full catalog (storyrails token_scopeable.rb +// GROUPED_SCOPES plus OauthGrant::ADDITIONAL_SCOPES) so one consent covers every command, +// and it must stay a subset of the app's registered allowed_scopes or the authorization +// request is rejected as invalid_scope. +export const OAUTH_LOGIN_SCOPES = [ + "asset_folders:read", + "asset_folders:write", + "assets:read", + "assets:write", + "collaborators:read", + "collaborators:write", + "comments:read", + "comments:write", + "components:read", + "components:write", + "datasource_entries:read", + "datasource_entries:write", + "datasources:read", + "datasources:write", + "releases:read", + "releases:write", + "releases:publish", + "spaces:read", + "spaces:write", + "statistics:read", + "stories:read", + "stories:write", + "stories:publish", + "tags:read", + "tags:write", + "taxonomies:read", + "taxonomies:write", + "users:read", + "users:write", + "webhooks:read", + "webhooks:write", + "workflows:read", + "workflows:write", + "offline_access", +]; diff --git a/packages/cli/src/commands/oauth/expiry.test.ts b/packages/cli/src/commands/oauth/expiry.test.ts new file mode 100644 index 000000000..98b199aaa --- /dev/null +++ b/packages/cli/src/commands/oauth/expiry.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isExpiringSoon } from "./expiry"; + +describe("isExpiringSoon", () => { + const now = Date.parse("2026-07-20T00:00:00.000Z"); + + it("should be true when already expired", () => { + expect(isExpiringSoon("2026-07-19T23:59:00.000Z", 120_000, now)).toBe(true); + }); + + it("should be true within the skew window", () => { + expect(isExpiringSoon("2026-07-20T00:01:00.000Z", 120_000, now)).toBe(true); + }); + + it("should be false when comfortably valid", () => { + expect(isExpiringSoon("2026-07-20T00:10:00.000Z", 120_000, now)).toBe(false); + }); + + it("should be true when no expiry is known", () => { + expect(isExpiringSoon(undefined, 120_000, now)).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/oauth/expiry.ts b/packages/cli/src/commands/oauth/expiry.ts new file mode 100644 index 000000000..168e53397 --- /dev/null +++ b/packages/cli/src/commands/oauth/expiry.ts @@ -0,0 +1,15 @@ +// Refresh proactively when the access token has expired or will within the skew window. +export const isExpiringSoon = ( + expiresAt: string | undefined, + skewMs = 120_000, + nowMs: number = Date.now(), +): boolean => { + if (!expiresAt) { + return true; + } + const expiry = Date.parse(expiresAt); + if (Number.isNaN(expiry)) { + return true; + } + return expiry - nowMs <= skewMs; +}; diff --git a/packages/cli/src/commands/oauth/grant.test.ts b/packages/cli/src/commands/oauth/grant.test.ts new file mode 100644 index 000000000..1cc7916d9 --- /dev/null +++ b/packages/cli/src/commands/oauth/grant.test.ts @@ -0,0 +1,42 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { introspectGrant } from "./grant"; + +const server = setupServer(); +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("introspectGrant", () => { + // The API nests the payload under a `grant` root key + // (storyrails oauth_controller renders `root: "grant", adapter: :json`). + it("should unwrap the `grant` root and return scopes, expiry and granted spaces", async () => { + server.use( + http.get("https://mapi.storyblok.com/v1/oauth/grant", () => + HttpResponse.json({ + grant: { + scopes: ["stories:read", "offline_access"], + expires_at: "2026-07-20T12:00:00.000Z", + app: { client_id: "cid", name: "Storyblok CLI" }, + spaces: [{ id: 123, region: "eu" }], + }, + }), + ), + ); + + const grant = await introspectGrant("eu", "sb_oat_token"); + expect(grant.scopes).toContain("stories:read"); + expect(grant.spaces).toEqual([{ id: 123, region: "eu" }]); + expect(grant.app.client_id).toBe("cid"); + }); + + it("should throw a CommandError on a non-2xx response", async () => { + server.use( + http.get("https://mapi.storyblok.com/v1/oauth/grant", () => + HttpResponse.json({ error: "Unauthorized" }, { status: 401 }), + ), + ); + await expect(introspectGrant("eu", "bad")).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/oauth/grant.ts b/packages/cli/src/commands/oauth/grant.ts new file mode 100644 index 000000000..a2ef6c697 --- /dev/null +++ b/packages/cli/src/commands/oauth/grant.ts @@ -0,0 +1,46 @@ +import type { RegionCode } from "../../constants"; +import { CommandError } from "../../utils"; +import { customFetch, FetchError } from "../../utils/fetch"; +import { getStoryblokUrl } from "../../utils/api-routes"; +import type { OAuthGrantSpace } from "./store"; + +export interface GrantIntrospection { + scopes: string[]; + expires_at?: string; + app: { client_id: string; name: string }; + spaces: OAuthGrantSpace[]; +} + +// GET /v1/oauth/grant returns the scopes, expiry, client and granted spaces for the +// access token; the token response itself omits space ids (storyrails OauthGrantIntrospectionSerializer). +// The payload is nested under a `grant` root key (storyrails oauth_controller renders +// `root: "grant", adapter: :json`), so unwrap it before reading the fields. +export const introspectGrant = async ( + region: RegionCode, + accessToken: string, +): Promise => { + let body: { grant?: GrantIntrospection }; + try { + body = await customFetch<{ grant?: GrantIntrospection }>( + `${getStoryblokUrl(region)}/oauth/grant`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + } catch (error) { + if (error instanceof FetchError) { + throw new CommandError( + `Grant introspection failed (${error.response.status} ${error.response.statusText}).`, + ); + } + throw error; + } + + const data = body.grant ?? ({} as Partial); + return { + scopes: data.scopes ?? [], + expires_at: data.expires_at, + app: data.app ?? { client_id: "", name: "" }, + spaces: data.spaces ?? [], + }; +}; diff --git a/packages/cli/src/commands/oauth/login-flow.test.ts b/packages/cli/src/commands/oauth/login-flow.test.ts new file mode 100644 index 000000000..ec3ec0ee9 --- /dev/null +++ b/packages/cli/src/commands/oauth/login-flow.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { OAUTH_LOGIN_SCOPES } from "./constants"; +import { buildAuthorizeUrl, performOAuthLogin } from "./login-flow"; +import { getOAuthActiveRegion, getOAuthEntry } from "./store"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); +vi.mock("../../lib/ui", () => ({ getUI: () => ({ info: vi.fn(), warn: vi.fn() }) })); +vi.mock("./client", () => ({ + resolveOAuthClient: vi.fn(() => ({ client_id: "cid", client_secret: "sec" })), +})); +vi.mock("./pkce", () => ({ + generatePkce: () => ({ verifier: "verifier", challenge: "challenge" }), + generateState: () => "state-abc", +})); +vi.mock("./server", () => ({ + waitForCallback: vi.fn(async () => ({ code: "auth-code", state: "state-abc" })), +})); +vi.mock("./token-endpoint", () => ({ + exchangeToken: vi.fn(async () => ({ access_token: "at", refresh_token: "rt", expires_in: 900 })), +})); +vi.mock("./grant", () => ({ introspectGrant: vi.fn() })); + +const { introspectGrant } = await import("./grant"); +const { resolveOAuthClient } = await import("./client"); + +describe("buildAuthorizeUrl", () => { + it("should build an /oauth/init URL with PKCE and space-safe params", () => { + const url = new URL( + buildAuthorizeUrl({ + region: "eu", + clientId: "cid", + scopes: ["stories:read", "offline_access"], + state: "st", + challenge: "ch", + }), + ); + expect(url.host).toBe("mapi.storyblok.com"); + expect(url.pathname).toBe("/oauth/init"); + expect(url.searchParams.get("client_id")).toBe("cid"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("stories:read offline_access"); + expect(url.searchParams.get("code_challenge")).toBe("ch"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:4900/oauth/callback"); + expect(url.searchParams.get("state")).toBe("st"); + }); +}); + +describe("performOAuthLogin", () => { + beforeEach(() => vol.reset()); + afterEach(() => vol.reset()); + + it("should persist tokens and granted spaces after a successful introspection", async () => { + vi.mocked(introspectGrant).mockResolvedValueOnce({ + scopes: ["stories:read"], + spaces: [{ id: 5, region: "eu" }], + }); + + const result = await performOAuthLogin({ region: "eu", openBrowser: async () => {} }); + + expect(result.spaces).toEqual([{ id: 5, region: "eu" }]); + const entry = await getOAuthEntry("eu"); + expect(entry.tokens?.access_token).toBe("at"); + expect(entry.spaces).toEqual([{ id: 5, region: "eu" }]); + }); + + it("should mark the region as active after a successful login", async () => { + vi.mocked(introspectGrant).mockResolvedValueOnce({ scopes: ["stories:read"], spaces: [] }); + + await performOAuthLogin({ region: "us", openBrowser: async () => {} }); + + expect(await getOAuthActiveRegion()).toBe("us"); + }); + + it("should authorize with the resolved client id and the full CLI scope set", async () => { + vi.mocked(resolveOAuthClient).mockReturnValueOnce({ + client_id: "env-cid", + client_secret: "env-sec", + }); + vi.mocked(introspectGrant).mockResolvedValueOnce({ scopes: [], spaces: [] }); + + let authorizeUrl = ""; + await performOAuthLogin({ + region: "eu", + openBrowser: async (url) => { + authorizeUrl = url; + }, + }); + + const params = new URL(authorizeUrl).searchParams; + expect(params.get("client_id")).toBe("env-cid"); + expect(params.get("scope")).toBe(OAUTH_LOGIN_SCOPES.join(" ")); + }); + + it("should not persist tokens when introspection fails", async () => { + vi.mocked(introspectGrant).mockRejectedValueOnce(new Error("introspection failed")); + + await expect(performOAuthLogin({ region: "eu", openBrowser: async () => {} })).rejects.toThrow( + "introspection failed", + ); + + expect(await getOAuthEntry("eu")).toEqual({}); + expect(await getOAuthActiveRegion()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/oauth/login-flow.ts b/packages/cli/src/commands/oauth/login-flow.ts new file mode 100644 index 000000000..82772b0bb --- /dev/null +++ b/packages/cli/src/commands/oauth/login-flow.ts @@ -0,0 +1,106 @@ +import open from "open"; +import type { RegionCode } from "../../constants"; +import { managementApiRegions } from "../../constants"; +import { CommandError } from "../../utils"; +import { getUI } from "../../lib/ui"; +import { resolveOAuthClient } from "./client"; +import { + OAUTH_CALLBACK_PATH, + OAUTH_CALLBACK_PORT, + OAUTH_LOGIN_SCOPES, + OAUTH_REDIRECT_URI, +} from "./constants"; +import { introspectGrant } from "./grant"; +import { generatePkce, generateState } from "./pkce"; +import { computeExpiresAt } from "./refresh"; +import { waitForCallback } from "./server"; +import { setOAuthActiveRegion, updateOAuthEntry } from "./store"; +import type { OAuthGrantSpace, OAuthTokens } from "./store"; +import { exchangeToken } from "./token-endpoint"; + +export interface OAuthLoginResult { + region: RegionCode; + scopes: string[]; + spaces: OAuthGrantSpace[]; +} + +export const buildAuthorizeUrl = (params: { + region: RegionCode; + clientId: string; + scopes: string[]; + state: string; + challenge: string; +}): string => { + const query = new URLSearchParams({ + client_id: params.clientId, + redirect_uri: OAUTH_REDIRECT_URI, + response_type: "code", + scope: params.scopes.join(" "), + state: params.state, + code_challenge: params.challenge, + code_challenge_method: "S256", + }); + return `https://${managementApiRegions[params.region]}/oauth/init?${query.toString()}`; +}; + +export const performOAuthLogin = async (options: { + region: RegionCode; + openBrowser?: (url: string) => Promise; +}): Promise => { + const { region } = options; + const openBrowser = options.openBrowser ?? ((url) => open(url)); + const ui = getUI(); + + const client = resolveOAuthClient(); + const scopes = OAUTH_LOGIN_SCOPES; + const { verifier, challenge } = generatePkce(); + const state = generateState(); + + // Start listening before opening the browser so no callback is missed. + const callbackPromise = waitForCallback(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH); + + const authorizeUrl = buildAuthorizeUrl({ + region, + clientId: client.client_id, + scopes, + state, + challenge, + }); + ui.info( + `Opening your browser to authorize the Storyblok CLI.\nIf it does not open, visit:\n${authorizeUrl}`, + ); + await openBrowser(authorizeUrl); + + const { code, state: returnedState } = await callbackPromise; + if (returnedState !== state) { + throw new CommandError( + "OAuth state mismatch; aborting for your safety. Please try `storyblok login` again.", + ); + } + + const token = await exchangeToken(region, { + grant_type: "authorization_code", + code, + redirect_uri: OAUTH_REDIRECT_URI, + code_verifier: verifier, + client_id: client.client_id, + client_secret: client.client_secret, + }); + + // Introspect the grant before persisting anything: a failed introspection must not leave + // tokens on disk without a `spaces` list, which the space guard would treat as unrestricted. + const grant = await introspectGrant(region, token.access_token); + + const tokens: OAuthTokens = { + auth_type: "oauth", + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: computeExpiresAt(token.expires_in), + }; + await updateOAuthEntry(region, { tokens, spaces: grant.spaces }); + // Mark this region as active so the next session resolves here rather than by + // fixed region order when several regions are authenticated. + await setOAuthActiveRegion(region); + + return { region, scopes: grant.scopes, spaces: grant.spaces }; +}; diff --git a/packages/cli/src/commands/oauth/pkce.test.ts b/packages/cli/src/commands/oauth/pkce.test.ts new file mode 100644 index 000000000..586142a73 --- /dev/null +++ b/packages/cli/src/commands/oauth/pkce.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { generatePkce, generateState } from "./pkce"; + +describe("generatePkce", () => { + it("should produce a verifier within the RFC 7636 length range and matching charset", () => { + const { verifier, challenge } = generatePkce(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[\w\-.~]+$/); + expect(challenge).toMatch(/^[\w\-]+$/); + expect(challenge).not.toBe(verifier); + }); +}); + +describe("generateState", () => { + it("should produce a non-empty url-safe string", () => { + expect(generateState()).toMatch(/^[\w\-]+$/); + }); +}); diff --git a/packages/cli/src/commands/oauth/pkce.ts b/packages/cli/src/commands/oauth/pkce.ts new file mode 100644 index 000000000..93415b199 --- /dev/null +++ b/packages/cli/src/commands/oauth/pkce.ts @@ -0,0 +1,9 @@ +import { createHash, randomBytes } from "node:crypto"; + +export const generatePkce = (): { verifier: string; challenge: string } => { + const verifier = randomBytes(48).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +}; + +export const generateState = (): string => randomBytes(16).toString("base64url"); diff --git a/packages/cli/src/commands/oauth/port.ts b/packages/cli/src/commands/oauth/port.ts new file mode 100644 index 000000000..73804e985 --- /dev/null +++ b/packages/cli/src/commands/oauth/port.ts @@ -0,0 +1,103 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const EXEC_OPTIONS = { timeout: 2000, windowsHide: true } as const; + +export interface PortHolder { + pid: number; + name?: string; +} + +// `lsof -Fpc` prints one field per line, prefixed by its type: `p` then `c`. +const parseLsof = (stdout: string): PortHolder | undefined => { + let pid: number | undefined; + let name: string | undefined; + for (const line of stdout.split("\n")) { + if (line.startsWith("p") && pid === undefined) { + pid = Number.parseInt(line.slice(1), 10); + } else if (line.startsWith("c") && name === undefined) { + name = line.slice(1).trim(); + } + } + return pid && Number.isFinite(pid) ? { pid, name } : undefined; +}; + +const findHolderUnix = async (port: number): Promise => { + const { stdout } = await run( + "lsof", + ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], + EXEC_OPTIONS, + ); + return parseLsof(stdout); +}; + +const findHolderWindows = async (port: number): Promise => { + const { stdout } = await run("netstat", ["-ano", "-p", "TCP"], EXEC_OPTIONS); + const row = stdout.split("\n").find((line) => { + const columns = line.trim().split(/\s+/); + // Columns: Proto, Local Address, Foreign Address, State, PID. + return columns.length >= 5 && columns[3] === "LISTENING" && columns[1].endsWith(`:${port}`); + }); + const pid = row ? Number.parseInt(row.trim().split(/\s+/)[4], 10) : Number.NaN; + if (!Number.isFinite(pid)) { + return undefined; + } + + try { + const { stdout: tasks } = await run( + "tasklist", + ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], + EXEC_OPTIONS, + ); + const name = tasks.trim().split('","')[0]?.replace(/^"/, ""); + return { pid, name: name || undefined }; + } catch { + // The PID alone is still actionable. + return { pid }; + } +}; + +// Best-effort lookup of the process listening on a port. Shelling out to lsof/netstat can +// fail for any number of reasons (tool missing, permissions, timeout); the caller degrades +// to a generic message rather than turning a diagnostic into a second failure. +export const findPortHolder = async (port: number): Promise => { + try { + return process.platform === "win32" + ? await findHolderWindows(port) + : await findHolderUnix(port); + } catch { + return undefined; + } +}; + +const lookupHint = (port: number): string => { + return process.platform === "win32" + ? `netstat -ano -p TCP | findstr :${port}` + : `lsof -nP -iTCP:${port} -sTCP:LISTEN`; +}; + +const stopHint = (pid: number): string => { + return process.platform === "win32" ? `taskkill /PID ${pid} /F` : `kill ${pid}`; +}; + +// The OAuth app registers one exact redirect URI, so the CLI cannot retry on a free port. +// Name whatever holds the port instead of surfacing a bare EADDRINUSE. +export const describePortConflict = async (port: number): Promise => { + const holder = await findPortHolder(port); + const culprit = holder + ? holder.name + ? `by ${holder.name} (PID ${holder.pid})` + : `by PID ${holder.pid}` + : "by another process"; + + const resolution = holder + ? `Stop that process (\`${stopHint(holder.pid)}\`) and run \`storyblok login --oauth\` again.` + : `Find it with \`${lookupHint(port)}\`, stop it, and run \`storyblok login --oauth\` again.`; + + return ( + `Port ${port} is already in use ${culprit}, so the CLI cannot receive the OAuth callback.\n` + + `The redirect URI is registered for this exact port, so the CLI cannot switch to a free one.\n` + + `${resolution}` + ); +}; diff --git a/packages/cli/src/commands/oauth/refresh.test.ts b/packages/cli/src/commands/oauth/refresh.test.ts new file mode 100644 index 000000000..f3c9c455a --- /dev/null +++ b/packages/cli/src/commands/oauth/refresh.test.ts @@ -0,0 +1,126 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { computeExpiresAt, refreshOAuthTokens } from "./refresh"; +import { getOAuthEntry, updateOAuthEntry } from "./store"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); + +const server = setupServer(); +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("computeExpiresAt", () => { + it("should add the lifetime in seconds to now", () => { + expect(computeExpiresAt(900, Date.parse("2026-07-20T00:00:00.000Z"))).toBe( + "2026-07-20T00:15:00.000Z", + ); + }); +}); + +describe("refreshOAuthTokens", () => { + beforeEach(async () => { + vol.reset(); + // The baked-in client is still a placeholder, so the refresh resolves its + // credentials through the env-var override. + process.env.STORYBLOK_OAUTH_CLIENT_ID = "cid"; + process.env.STORYBLOK_OAUTH_CLIENT_SECRET = "secret"; + await updateOAuthEntry("eu", { + tokens: { + auth_type: "oauth", + access_token: "old-access", + refresh_token: "old-refresh", + expires_at: "2026-07-20T00:00:00.000Z", + }, + }); + }); + + afterEach(() => { + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + }); + + it("should key single-flight refresh by region so concurrent regions do not share a promise", async () => { + await updateOAuthEntry("us", { + tokens: { + auth_type: "oauth", + access_token: "us-old-access", + refresh_token: "us-old-refresh", + expires_at: "2026-07-20T00:00:00.000Z", + }, + }); + + server.use( + // eu and us resolve to distinct hosts (mapi.storyblok.com vs api-us.storyblok.com), + // so each handler only ever serves its own region's refresh request. + http.post("https://mapi.storyblok.com/oauth/token", () => + HttpResponse.json({ + access_token: "eu-new-access", + refresh_token: "eu-new-refresh", + token_type: "bearer", + expires_in: 900, + scope: "stories:read", + }), + ), + http.post("https://api-us.storyblok.com/oauth/token", () => + HttpResponse.json({ + access_token: "us-new-access", + refresh_token: "us-new-refresh", + token_type: "bearer", + expires_in: 900, + scope: "stories:read", + }), + ), + ); + + const [euTokens, usTokens] = await Promise.all([ + refreshOAuthTokens("eu"), + refreshOAuthTokens("us"), + ]); + + expect(euTokens.access_token).toBe("eu-new-access"); + expect(usTokens.access_token).toBe("us-new-access"); + }); + + it("should persist the rotated refresh token before returning the new access token", async () => { + let persistedRefreshAtRequestTime: string | undefined; + server.use( + http.post("https://mapi.storyblok.com/oauth/token", async () => { + persistedRefreshAtRequestTime = (await getOAuthEntry("eu")).tokens?.refresh_token; + return HttpResponse.json({ + access_token: "new-access", + refresh_token: "new-refresh", + token_type: "bearer", + expires_in: 900, + scope: "stories:read", + }); + }), + ); + + const tokens = await refreshOAuthTokens("eu"); + expect(tokens.access_token).toBe("new-access"); + // Before the exchange resolves, the store still had the old refresh token. + expect(persistedRefreshAtRequestTime).toBe("old-refresh"); + // After the call, the rotated refresh token is persisted. + expect((await getOAuthEntry("eu")).tokens?.refresh_token).toBe("new-refresh"); + }); + + it("should throw a re-login error when the refresh grant is invalid", async () => { + server.use( + http.post("https://mapi.storyblok.com/oauth/token", () => + HttpResponse.json({ error: "invalid_grant" }, { status: 400 }), + ), + ); + await expect(refreshOAuthTokens("eu")).rejects.toThrow(/storyblok login/); + }); + + it("should throw when there is no stored refresh token", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await expect(refreshOAuthTokens("eu")).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/oauth/refresh.ts b/packages/cli/src/commands/oauth/refresh.ts new file mode 100644 index 000000000..b7b48ae45 --- /dev/null +++ b/packages/cli/src/commands/oauth/refresh.ts @@ -0,0 +1,62 @@ +import type { RegionCode } from "../../constants"; +import { CommandError } from "../../utils"; +import { resolveOAuthClient } from "./client"; +import { getOAuthEntry, updateOAuthEntry } from "./store"; +import type { OAuthTokens } from "./store"; +import { exchangeToken } from "./token-endpoint"; + +export const computeExpiresAt = (expiresInSeconds: number, nowMs: number = Date.now()): string => { + return new Date(nowMs + expiresInSeconds * 1000).toISOString(); +}; + +// In-process single-flight, keyed by region: concurrent callers for the same +// region within one CLI process share one refresh, but different regions don't. +const inFlight = new Map>(); + +const doRefresh = async (region: RegionCode): Promise => { + const entry = await getOAuthEntry(region); + const refreshToken = entry.tokens?.refresh_token; + if (!refreshToken) { + throw new CommandError("No OAuth refresh token stored. Run `storyblok login` to authenticate."); + } + + const client = resolveOAuthClient(); + + let response; + try { + response = await exchangeToken(region, { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: client.client_id, + client_secret: client.client_secret, + }); + } catch (error) { + // The refresh token rotates and is single-use; an invalid grant means the session is dead. + if (error instanceof CommandError && /invalid_grant/.test(error.message)) { + throw new CommandError("Your OAuth session has expired. Please run `storyblok login` again."); + } + throw error; + } + + const tokens: OAuthTokens = { + auth_type: "oauth", + access_token: response.access_token, + refresh_token: response.refresh_token ?? refreshToken, + expires_at: computeExpiresAt(response.expires_in), + }; + + // Persist the rotated tokens BEFORE returning them for use. + await updateOAuthEntry(region, { tokens }); + return tokens; +}; + +export const refreshOAuthTokens = async (region: RegionCode): Promise => { + if (inFlight.has(region)) { + return inFlight.get(region)!; + } + const promise = doRefresh(region).finally(() => { + inFlight.delete(region); + }); + inFlight.set(region, promise); + return promise; +}; diff --git a/packages/cli/src/commands/oauth/server.test.ts b/packages/cli/src/commands/oauth/server.test.ts new file mode 100644 index 000000000..9ee176726 --- /dev/null +++ b/packages/cli/src/commands/oauth/server.test.ts @@ -0,0 +1,49 @@ +import { createServer } from "node:http"; +import { describe, expect, it } from "vitest"; +import { waitForCallback } from "./server"; + +const PATH = "/oauth/callback"; +const callback = (port: number, query: string) => fetch(`http://127.0.0.1:${port}${PATH}${query}`); + +describe("waitForCallback", () => { + it("should resolve with code and state and serve a 200 success page", async () => { + const pending = waitForCallback(4917, PATH); + const response = await callback(4917, "?code=auth-code&state=state-abc"); + + expect(response.status).toBe(200); + await expect(pending).resolves.toEqual({ code: "auth-code", state: "state-abc" }); + }); + + it("should serve a non-200 page and reject when the callback carries an error", async () => { + // Attach the rejection assertion before triggering the callback so the + // rejection is handled the moment it settles. + const rejected = expect(waitForCallback(4918, PATH)).rejects.toThrow(/access_denied/); + const response = await callback(4918, "?error=access_denied&error_description=denied"); + + expect(response.status).toBe(400); + await rejected; + }); + + it("should explain which process blocks the callback port when it is already in use", async () => { + const blocker = createServer(); + await new Promise((resolve) => blocker.listen(4920, "127.0.0.1", resolve)); + + try { + // The holder lookup shells out to lsof/netstat, which may be unavailable in CI, so the + // assertion covers the always-present parts: the port, the cause, and the way out. + await expect(waitForCallback(4920, PATH)).rejects.toThrow( + /Port 4920 is already in use .*run `storyblok login --oauth` again/s, + ); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it("should serve a non-200 page and reject when code or state is missing", async () => { + const rejected = expect(waitForCallback(4919, PATH)).rejects.toThrow(/code and state/); + const response = await callback(4919, "?code=only-code"); + + expect(response.status).toBe(400); + await rejected; + }); +}); diff --git a/packages/cli/src/commands/oauth/server.ts b/packages/cli/src/commands/oauth/server.ts new file mode 100644 index 000000000..45cd7b8cb --- /dev/null +++ b/packages/cli/src/commands/oauth/server.ts @@ -0,0 +1,85 @@ +import { createServer } from "node:http"; +import { CommandError } from "../../utils"; +import { describePortConflict } from "./port"; + +const page = (heading: string, message: string): string => + ` +

${heading}

${message}

+`; + +const SUCCESS_PAGE = page( + "Storyblok CLI", + "Authorization received. You can close this tab and return to the terminal.", +); +const ERROR_PAGE = page( + "Storyblok CLI", + "Authorization failed. You can close this tab and return to the terminal.", +); + +export const waitForCallback = ( + port: number, + path: string, + timeoutMs = 300_000, +): Promise<{ code: string; state: string }> => { + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout; + + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (url.pathname !== path) { + res.writeHead(404); + res.end(); + return; + } + + server.close(); + clearTimeout(timer); + + const fail = (error: CommandError): void => { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(ERROR_PAGE); + reject(error); + }; + + const error = url.searchParams.get("error"); + if (error) { + fail( + new CommandError( + `Authorization failed: ${error} โ€” ${url.searchParams.get("error_description") ?? "no description"}`, + ), + ); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + fail(new CommandError("Callback did not include code and state query params.")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(SUCCESS_PAGE); + resolve({ code, state }); + }); + + timer = setTimeout(() => { + server.close(); + reject(new CommandError("Timed out waiting for the browser authorization callback.")); + }, timeoutMs); + timer.unref?.(); + + server.on("error", (err) => { + clearTimeout(timer); + if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") { + describePortConflict(port).then( + (message) => reject(new CommandError(message)), + () => reject(err), + ); + return; + } + reject(err); + }); + // Bind to loopback only so the authorization code is never accepted from other hosts. + server.listen(port, "127.0.0.1"); + }); +}; diff --git a/packages/cli/src/commands/oauth/space-guard.test.ts b/packages/cli/src/commands/oauth/space-guard.test.ts new file mode 100644 index 000000000..5552927db --- /dev/null +++ b/packages/cli/src/commands/oauth/space-guard.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { assertSpaceAllowed } from "./space-guard"; + +describe("assertSpaceAllowed", () => { + it("should pass when the space is in the grant", () => { + expect(() => assertSpaceAllowed(123, [{ id: 123 }])).not.toThrow(); + }); + + it("should throw when the space is outside the grant", () => { + expect(() => assertSpaceAllowed(999, [{ id: 123 }])).toThrow(/not covered by your OAuth login/); + }); + + it("should pass when the grant has no space restriction", () => { + expect(() => assertSpaceAllowed(999, [])).not.toThrow(); + expect(() => assertSpaceAllowed(999, undefined)).not.toThrow(); + }); + + it("should pass when no space is targeted", () => { + expect(() => assertSpaceAllowed(undefined, [{ id: 123 }])).not.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/oauth/space-guard.ts b/packages/cli/src/commands/oauth/space-guard.ts new file mode 100644 index 000000000..c59225da0 --- /dev/null +++ b/packages/cli/src/commands/oauth/space-guard.ts @@ -0,0 +1,22 @@ +import { CommandError } from "../../utils"; + +// A grant with an empty/absent space list is not space-restricted (storyrails token_scopeable.rb). +export const assertSpaceAllowed = ( + space: string | number | undefined, + grantedSpaces: { id: number }[] | undefined, +): void => { + if (space === undefined || space === null || space === "") { + return; + } + if (!grantedSpaces || grantedSpaces.length === 0) { + return; + } + const target = Number(space); + if (!grantedSpaces.some((granted) => granted.id === target)) { + const allowed = grantedSpaces.map((granted) => granted.id).join(", "); + throw new CommandError( + `Space ${space} is not covered by your OAuth login (authorized spaces: ${allowed}).\n` + + `Re-run \`storyblok login\` and select this space at the consent screen.`, + ); + } +}; diff --git a/packages/cli/src/commands/oauth/store.test.ts b/packages/cli/src/commands/oauth/store.test.ts new file mode 100644 index 000000000..c6d2d9c46 --- /dev/null +++ b/packages/cli/src/commands/oauth/store.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { + clearOAuthTokens, + getOAuthActiveRegion, + getOAuthClientFromEnv, + getOAuthEntry, + setOAuthActiveRegion, + updateOAuthEntry, +} from "./store"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); + +describe("oauth store", () => { + beforeEach(() => vol.reset()); + afterEach(() => { + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + }); + + it("should round-trip an oauth entry per region", async () => { + await updateOAuthEntry("eu", { + tokens: { + auth_type: "oauth", + access_token: "a", + refresh_token: "r", + expires_at: "2026-07-20T00:00:00.000Z", + }, + }); + const entry = await getOAuthEntry("eu"); + expect(entry.tokens?.access_token).toBe("a"); + expect(await getOAuthEntry("us")).toEqual({}); + }); + + it("should merge patches without dropping sibling keys", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await updateOAuthEntry("eu", { spaces: [{ id: 1, region: "eu" }] }); + const entry = await getOAuthEntry("eu"); + expect(entry.tokens?.access_token).toBe("a"); + expect(entry.spaces).toEqual([{ id: 1, region: "eu" }]); + }); + + it("should clear tokens only for the requested region", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await updateOAuthEntry("us", { + tokens: { auth_type: "oauth", access_token: "b", expires_at: "y" }, + }); + await clearOAuthTokens("eu"); + expect(await getOAuthEntry("eu")).toEqual({}); + expect((await getOAuthEntry("us")).tokens?.access_token).toBe("b"); + }); + + it("should drop tokens and granted spaces when clearing a region", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + spaces: [{ id: 1, region: "eu" }], + }); + await clearOAuthTokens("eu"); + const entry = await getOAuthEntry("eu"); + expect(entry.tokens).toBeUndefined(); + expect(entry.spaces).toBeUndefined(); + }); + + it("should read client credentials from env vars when present", () => { + process.env.STORYBLOK_OAUTH_CLIENT_ID = "env-id"; + process.env.STORYBLOK_OAUTH_CLIENT_SECRET = "env-secret"; + expect(getOAuthClientFromEnv()).toEqual({ client_id: "env-id", client_secret: "env-secret" }); + }); + + it("should round-trip the active region pointer", async () => { + expect(await getOAuthActiveRegion()).toBeUndefined(); + await setOAuthActiveRegion("us"); + expect(await getOAuthActiveRegion()).toBe("us"); + }); + + it("should ignore an active region that is not a valid region", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await setOAuthActiveRegion("eu"); + // Corrupt the pointer directly, bypassing the setter. + const path = `${process.env.HOME}/.storyblok/credentials.json`; + const stored = JSON.parse(vol.readFileSync(path, "utf8") as string); + stored.oauth.activeRegion = "not-a-region"; + vol.fromJSON({ [path]: JSON.stringify(stored) }); + expect(await getOAuthActiveRegion()).toBeUndefined(); + }); + + it("should not drop sibling region entries when setting the active region", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await setOAuthActiveRegion("eu"); + expect((await getOAuthEntry("eu")).tokens?.access_token).toBe("a"); + expect(await getOAuthActiveRegion()).toBe("eu"); + }); + + it("should clear the active region pointer when its region is logged out", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await setOAuthActiveRegion("eu"); + await clearOAuthTokens("eu"); + expect(await getOAuthActiveRegion()).toBeUndefined(); + }); + + it("should keep the active region pointer when a different region is logged out", async () => { + await updateOAuthEntry("eu", { + tokens: { auth_type: "oauth", access_token: "a", expires_at: "x" }, + }); + await updateOAuthEntry("us", { + tokens: { auth_type: "oauth", access_token: "b", expires_at: "y" }, + }); + await setOAuthActiveRegion("us"); + await clearOAuthTokens("eu"); + expect(await getOAuthActiveRegion()).toBe("us"); + }); +}); diff --git a/packages/cli/src/commands/oauth/store.ts b/packages/cli/src/commands/oauth/store.ts new file mode 100644 index 000000000..c7f8fe648 --- /dev/null +++ b/packages/cli/src/commands/oauth/store.ts @@ -0,0 +1,96 @@ +import { join } from "pathe"; +import type { RegionCode } from "../../constants"; +import { getCredentials } from "../../creds"; +import { isRegion } from "../../utils"; +import { getStoryblokGlobalPath, saveToFile } from "../../utils/filesystem"; + +export interface OAuthClientCredentials { + client_id: string; + client_secret: string; +} + +export interface OAuthTokens { + auth_type: "oauth"; + access_token: string; + refresh_token?: string; + expires_at: string; +} + +export interface OAuthGrantSpace { + id: number; + region: RegionCode | "unknown"; +} + +export interface OAuthRegionEntry { + tokens?: OAuthTokens; + spaces?: OAuthGrantSpace[]; +} + +// The `oauth` credentials section holds one entry per region, plus an +// `activeRegion` pointer marking the region the user most recently logged into. +// The pointer disambiguates which session to load when several regions are +// authenticated at once. Its key never collides with a region code. +type OAuthStore = Partial> & { activeRegion?: RegionCode }; + +const credentialsPath = (): string => join(getStoryblokGlobalPath(), "credentials.json"); + +const readAll = async (): Promise> => { + return ((await getCredentials(credentialsPath())) as Record | null) ?? {}; +}; + +export const getOAuthEntry = async (region: RegionCode): Promise => { + const all = await readAll(); + const oauth = (all.oauth ?? {}) as OAuthStore; + return oauth[region] ?? {}; +}; + +// The region the user most recently logged into, or undefined when unset or +// pointing at a value that is not a valid region. +export const getOAuthActiveRegion = async (): Promise => { + const all = await readAll(); + const oauth = (all.oauth ?? {}) as OAuthStore; + const active = oauth.activeRegion; + return active && isRegion(active) ? active : undefined; +}; + +export const setOAuthActiveRegion = async (region: RegionCode): Promise => { + const all = await readAll(); + const oauth = (all.oauth ?? {}) as OAuthStore; + oauth.activeRegion = region; + await saveToFile(credentialsPath(), JSON.stringify({ ...all, oauth }, null, 2), { mode: 0o600 }); +}; + +export const getOAuthClientFromEnv = (): OAuthClientCredentials | null => { + const clientId = process.env.STORYBLOK_OAUTH_CLIENT_ID; + const clientSecret = process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + if (clientId && clientSecret) { + return { client_id: clientId, client_secret: clientSecret }; + } + return null; +}; + +export const updateOAuthEntry = async ( + region: RegionCode, + patch: OAuthRegionEntry, +): Promise => { + const all = await readAll(); + const oauth = (all.oauth ?? {}) as OAuthStore; + oauth[region] = { ...oauth[region], ...patch }; + await saveToFile(credentialsPath(), JSON.stringify({ ...all, oauth }, null, 2), { mode: 0o600 }); +}; + +// Clears the session (tokens and granted spaces) for one region. +export const clearOAuthTokens = async (region: RegionCode): Promise => { + const all = await readAll(); + const oauth = (all.oauth ?? {}) as OAuthStore; + if (!oauth[region]) { + return; + } + delete oauth[region]; + // Drop the pointer when the region it references is logged out, so the next + // session falls back to whatever other region still has tokens. + if (oauth.activeRegion === region) { + delete oauth.activeRegion; + } + await saveToFile(credentialsPath(), JSON.stringify({ ...all, oauth }, null, 2), { mode: 0o600 }); +}; diff --git a/packages/cli/src/commands/oauth/token-endpoint.test.ts b/packages/cli/src/commands/oauth/token-endpoint.test.ts new file mode 100644 index 000000000..2e34a76af --- /dev/null +++ b/packages/cli/src/commands/oauth/token-endpoint.test.ts @@ -0,0 +1,57 @@ +import { exchangeToken } from "./token-endpoint"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +const handlers = [ + http.post("https://mapi.storyblok.com/oauth/token", async () => { + return HttpResponse.json({ + access_token: "access-token-value", + refresh_token: "refresh-token-value", + expires_in: 3600, + scope: "read write", + }); + }), +]; + +const server = setupServer(...handlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("exchangeToken", () => { + it("should resolve with the access token and expires_in on a valid response", async () => { + const result = await exchangeToken("eu", { grant_type: "authorization_code", code: "abc" }); + expect(result.access_token).toBe("access-token-value"); + expect(typeof result.expires_in).toBe("number"); + expect(result.expires_in).toBe(3600); + }); + + it("should reject with a CommandError when the response is missing access_token", async () => { + server.use( + http.post("https://mapi.storyblok.com/oauth/token", async () => { + return HttpResponse.json({ + expires_in: 3600, + }); + }), + ); + + await expect( + exchangeToken("eu", { grant_type: "authorization_code", code: "abc" }), + ).rejects.toThrow(/unexpected shape/); + }); + + it("should reject with a CommandError including the error code on an error status", async () => { + server.use( + http.post("https://mapi.storyblok.com/oauth/token", async () => { + return HttpResponse.json({ error: "invalid_grant" }, { status: 400 }); + }), + ); + + await expect( + exchangeToken("eu", { grant_type: "authorization_code", code: "abc" }), + ).rejects.toThrow(/invalid_grant/); + }); +}); diff --git a/packages/cli/src/commands/oauth/token-endpoint.ts b/packages/cli/src/commands/oauth/token-endpoint.ts new file mode 100644 index 000000000..e26325a6a --- /dev/null +++ b/packages/cli/src/commands/oauth/token-endpoint.ts @@ -0,0 +1,79 @@ +import type { RegionCode } from "../../constants"; +import { managementApiRegions } from "../../constants"; +import { CommandError } from "../../utils"; +import { customFetch, FetchError } from "../../utils/fetch"; + +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; + raw: Record; +} + +export const exchangeToken = async ( + region: RegionCode, + params: Record, +): Promise => { + // The token endpoint lives at the API root, not under `/v1`, so build the URL + // from the region host directly rather than via `getStoryblokUrl`. + let raw: Record; + try { + const { perPage, total, ...data } = await customFetch>( + `https://${managementApiRegions[region]}/oauth/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params).toString(), + }, + ); + raw = data; + } catch (error) { + if (error instanceof FetchError) { + const data = error.response.data; + const errorCode = + data && typeof data.error === "string" ? data.error : `${error.response.status}`; + throw new CommandError(`Token endpoint error (${errorCode}): ${JSON.stringify(data ?? {})}`); + } + throw error; + } + + if (typeof raw.access_token !== "string" || typeof raw.expires_in !== "number") { + throw new CommandError(`Token endpoint returned an unexpected shape: ${JSON.stringify(raw)}`); + } + + return { + access_token: raw.access_token, + refresh_token: typeof raw.refresh_token === "string" ? raw.refresh_token : undefined, + expires_in: raw.expires_in, + scope: typeof raw.scope === "string" ? raw.scope : undefined, + raw, + }; +}; + +// Revokes a token server-side (RFC 7009). Revoking the refresh token invalidates the +// whole grant, so a logged-out session can no longer mint new tokens. Like the token +// endpoint, `/oauth/revoke` lives at the API root rather than under `/v1`. +// Uses a raw fetch rather than `customFetch`: a successful revocation returns `200` with +// an empty body (RFC 7009 ยง2.2 / storyrails `head :ok`), which `customFetch` would reject +// as a non-JSON response. +export const revokeToken = async ( + region: RegionCode, + token: string, + client: { client_id: string; client_secret: string }, +): Promise => { + const response = await fetch(`https://${managementApiRegions[region]}/oauth/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token, + client_id: client.client_id, + client_secret: client.client_secret, + }).toString(), + }); + if (!response.ok) { + throw new CommandError( + `Revocation endpoint error (${response.status} ${response.statusText}).`, + ); + } +}; diff --git a/packages/cli/src/commands/user/actions.ts b/packages/cli/src/commands/user/actions.ts index ab5ba6bcf..96e90cb22 100644 --- a/packages/cli/src/commands/user/actions.ts +++ b/packages/cli/src/commands/user/actions.ts @@ -1,14 +1,24 @@ import chalk from "chalk"; +import type { ApiCredential } from "../../utils"; import { getResponseStatus, handleAPIError, maskToken, toError } from "../../utils"; import { createMapiClient } from "../../api"; import type { RegionCode } from "../../constants"; export type { User } from "../../types"; -export const getUser = async (token: string, region: RegionCode) => { +/** + * Fetch the current user. + * @param credential - A PAT string (back-compat) or an {@link ApiCredential} (PAT or OAuth token). + * @param region - The region to authenticate against. + */ +export const getUser = async (credential: string | ApiCredential, region: RegionCode) => { + const config: ApiCredential = + typeof credential === "string" ? { personalAccessToken: credential } : credential; + const isOauth = "oauthToken" in config; + const token = "personalAccessToken" in config ? config.personalAccessToken : config.oauthToken; try { const client = createMapiClient({ - personalAccessToken: token, + ...config, region, }); @@ -22,7 +32,10 @@ export const getUser = async (token: string, region: RegionCode) => { const status = getResponseStatus(maybeError); const customMessage = status === 401 - ? `The token provided ${chalk.bold(maskToken(token))} is invalid. + ? isOauth + ? `Your OAuth session has expired or been revoked. + Please run \`storyblok login --oauth\` to authenticate again.` + : `The token provided ${chalk.bold(maskToken(token))} is invalid. Please make sure you are using the correct token and try again.` : undefined; handleAPIError("get_user", error, customMessage); diff --git a/packages/cli/src/commands/user/index.test.ts b/packages/cli/src/commands/user/index.test.ts index 0b1a922b0..0582ef293 100644 --- a/packages/cli/src/commands/user/index.test.ts +++ b/packages/cli/src/commands/user/index.test.ts @@ -39,12 +39,39 @@ describe("userCommand", () => { vi.mocked(getUser).mockResolvedValue(mockResponse); await userCommand.parseAsync(["node", "test"]); - expect(getUser).toHaveBeenCalledWith("valid-token", "eu"); + expect(getUser).toHaveBeenCalledWith({ personalAccessToken: "valid-token" }, "eu"); expect(console.error).toHaveBeenCalledWith( expect.stringContaining(`Hi ${chalk.bold("John Doe")}`), ); }); + it("should fetch the user with the OAuth access token for an OAuth session", async () => { + // Far-future expiry so the program preAction hook does not attempt a token refresh. + const oauthState = { + isLoggedIn: true, + region: "eu" as const, + authType: "oauth" as const, + oauthAccessToken: "oat-token", + oauthExpiresAt: "2099-01-01T00:00:00.000Z", + envLogin: false, + }; + vi.mocked(session().initializeSession).mockImplementation(async () => { + session().state = { ...oauthState }; + }); + session().state = { ...oauthState }; + vi.mocked(getUser).mockResolvedValue({ + id: 1, + friendly_name: "John Doe", + email: "john.doe@storyblok.com", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }); + + await userCommand.parseAsync(["node", "test"]); + + expect(getUser).toHaveBeenCalledWith({ oauthToken: "oat-token" }, "eu"); + }); + it("should show an error if the user is not logged in", async () => { preconditions.loggedOut(); diff --git a/packages/cli/src/commands/user/index.ts b/packages/cli/src/commands/user/index.ts index dfa9d83fb..e2042e30e 100644 --- a/packages/cli/src/commands/user/index.ts +++ b/packages/cli/src/commands/user/index.ts @@ -1,7 +1,7 @@ import chalk from "chalk"; import { colorPalette, commands } from "../../constants"; import { getProgram } from "../../program"; -import { handleError, requireAuthentication } from "../../utils"; +import { handleError, requireAuthentication, sessionCredential } from "../../utils"; import { getUser } from "./actions"; import { session } from "../../session"; import { getUI } from "../../lib/ui"; @@ -23,12 +23,13 @@ export const userCommand = program const spinner = ui.createSpinner(`Fetching user info`); try { - const { password, region } = state; - if (!password || !region) { - throw new Error("No password or region found"); + const { region } = state; + const credential = sessionCredential(state); + if (!credential || !region) { + throw new Error("No credential or region found"); } - const user = await getUser(password, region); + const user = await getUser(credential, region); if (user) { if (verbose) { diff --git a/packages/cli/src/creds.test.ts b/packages/cli/src/creds.test.ts index 25ac74832..3c7ffe60a 100644 --- a/packages/cli/src/creds.test.ts +++ b/packages/cli/src/creds.test.ts @@ -1,4 +1,9 @@ -import { addCredentials, getCredentials, removeAllCredentials } from "./creds"; +import { + addCredentials, + getCredentials, + removeAllCredentials, + removePatCredentials, +} from "./creds"; import { describe, expect, it } from "vitest"; import { vol } from "memfs"; import type { StoryblokCredentials } from "./types"; @@ -80,4 +85,66 @@ describe("creds", async () => { expect(content).toBe("{}"); }); }); + + describe("removePatCredentials", () => { + it("should remove PAT entries while preserving the oauth section", async () => { + vol.fromJSON( + { + "test/credentials.json": JSON.stringify({ + "api.storyblok.com": { + login: "julio.professional@storyblok.com", + password: "my_access_token", + region: "eu", + }, + oauth: { + eu: { + tokens: { + auth_type: "oauth", + access_token: "sb_oat_x", + expires_at: "2026-07-20T12:00:00.000Z", + }, + }, + activeRegion: "eu", + }, + }), + }, + "/temp", + ); + + await removePatCredentials("/temp/test"); + + const content = JSON.parse(vol.readFileSync("/temp/test/credentials.json", "utf8") as string); + expect(content["api.storyblok.com"]).toBeUndefined(); + expect(content.oauth).toEqual({ + eu: { + tokens: { + auth_type: "oauth", + access_token: "sb_oat_x", + expires_at: "2026-07-20T12:00:00.000Z", + }, + }, + activeRegion: "eu", + }); + }); + + it("should write an empty object when there is no oauth section", async () => { + vol.fromJSON( + { + "test/credentials.json": JSON.stringify({ + "api.storyblok.com": { + login: "julio.professional@storyblok.com", + password: "my_access_token", + region: "eu", + }, + }), + }, + "/temp", + ); + + await removePatCredentials("/temp/test"); + + const content = vol.readFileSync("/temp/test/credentials.json", "utf8"); + expect(content).toBe("{}"); + }); + }); }); diff --git a/packages/cli/src/creds.ts b/packages/cli/src/creds.ts index 3b57da0a7..efe2bbf2a 100644 --- a/packages/cli/src/creds.ts +++ b/packages/cli/src/creds.ts @@ -61,3 +61,13 @@ export const removeAllCredentials = async (filepath: string = getStoryblokGlobal const filePath = join(filepath, "credentials.json"); await saveToFile(filePath, JSON.stringify({}, null, 2), { mode: 0o600 }); }; + +// Removes the PAT machine entries while preserving the `oauth` section (OAuth sessions +// per region). Logging out of a PAT session must not end an OAuth session. +export const removePatCredentials = async (filepath: string = getStoryblokGlobalPath()) => { + const filePath = join(filepath, "credentials.json"); + const credentials = (await getCredentials(filePath)) as Record | null; + const oauth = credentials?.oauth; + const remaining = oauth ? { oauth } : {}; + await saveToFile(filePath, JSON.stringify(remaining, null, 2), { mode: 0o600 }); +}; diff --git a/packages/cli/src/program.oauth.test.ts b/packages/cli/src/program.oauth.test.ts new file mode 100644 index 000000000..325e98f9a --- /dev/null +++ b/packages/cli/src/program.oauth.test.ts @@ -0,0 +1,141 @@ +// Integration coverage for the proactive OAuth-refresh path inside the shared +// preAction hook (see program.ts). This only fires end-to-end when a real +// command is run through `getProgram()`, so it is exercised here rather than +// unit-tested in isolation. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); +// The shared test harness (test/setup.ts) mocks session() to a static logged-in PAT +// state. This suite needs the real session/oauth-store logic to load an expiring +// OAuth session from disk, so unmock it here, matching session.oauth.test.ts. +vi.unmock("./session"); +// Capture the credential the mapi client is initialized with, without hitting the +// network via the real management-api-client. +vi.mock("./api", () => ({ + getMapiClient: vi.fn(), +})); + +const server = setupServer(); +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const seedExpiringOAuthSession = () => { + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + oauth: { + eu: { + tokens: { + auth_type: "oauth", + access_token: "sb_oat_old", + refresh_token: "sb_ort_old", + // Well in the past, so isExpiringSoon() is true. + expires_at: "2020-01-01T00:00:00.000Z", + }, + spaces: [{ id: 5, region: "eu" }], + }, + }, + }), + }); +}; + +describe("program preAction OAuth refresh", () => { + beforeEach(() => { + vol.reset(); + vi.resetModules(); + // vi.resetModules() clears the dynamic-import cache, but the `./api` mock factory's + // vi.fn() call history is tracked separately and survives it; clear it explicitly so + // each test starts from zero calls. + vi.clearAllMocks(); + delete process.env.STORYBLOK_LOGIN; + delete process.env.STORYBLOK_TOKEN; + delete process.env.STORYBLOK_REGION; + // The baked-in client is still a placeholder, so the refresh path resolves its + // credentials through the env-var override. + process.env.STORYBLOK_OAUTH_CLIENT_ID = "cid"; + process.env.STORYBLOK_OAUTH_CLIENT_SECRET = "secret"; + }); + afterEach(() => { + vol.reset(); + delete process.env.STORYBLOK_OAUTH_CLIENT_ID; + delete process.env.STORYBLOK_OAUTH_CLIENT_SECRET; + }); + + it("should refresh an expiring OAuth token and initialize the mapi client with the new access token", async () => { + seedExpiringOAuthSession(); + server.use( + http.post("https://mapi.storyblok.com/oauth/token", () => + HttpResponse.json({ + access_token: "sb_oat_new", + refresh_token: "sb_ort_new", + token_type: "bearer", + expires_in: 900, + scope: "stories:read offline_access", + }), + ), + ); + + const { getMapiClient } = await import("./api"); + const { getProgram } = await import("./program"); + const program = getProgram(); + program.command("oauth-test-refresh").action(() => {}); + + await program.parseAsync(["node", "test", "oauth-test-refresh"]); + + expect(getMapiClient).toHaveBeenCalledWith( + expect.objectContaining({ oauthToken: "sb_oat_new", region: "eu" }), + ); + + // The rotated tokens are persisted before use. + const { getOAuthEntry } = await import("./commands/oauth/store"); + const entry = await getOAuthEntry("eu"); + expect(entry.tokens?.access_token).toBe("sb_oat_new"); + expect(entry.tokens?.refresh_token).toBe("sb_ort_new"); + }); + + it("should surface the re-login message and not throw when the refresh fails", async () => { + seedExpiringOAuthSession(); + server.use( + http.post("https://mapi.storyblok.com/oauth/token", () => + HttpResponse.json({ error: "invalid_grant" }, { status: 400 }), + ), + ); + + // Import the module fresh (post vi.resetModules()) so the spy targets the same + // UI instance the freshly-loaded program.ts module resolves to. + const { getUI } = await import("./lib/ui"); + const warnSpy = vi.spyOn(getUI(), "warn").mockImplementation(() => {}); + + const { getProgram } = await import("./program"); + const program = getProgram(); + let actionRan = false; + program.command("oauth-test-refresh-fail").action(() => { + actionRan = true; + }); + + // The hook must not reject the command run just because the proactive refresh failed; + // commands that don't need auth should still be able to run. + await expect( + program.parseAsync(["node", "test", "oauth-test-refresh-fail"]), + ).resolves.not.toThrow(); + expect(actionRan).toBe(true); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/Please run `storyblok login` again/), + ); + + // The mapi client still gets initialized with the stale token (not a refreshed one); + // any authed downstream call will fail on that dead token rather than silently succeed. + const { getMapiClient } = await import("./api"); + expect(getMapiClient).toHaveBeenCalledWith( + expect.objectContaining({ oauthToken: "sb_oat_old" }), + ); + expect(getMapiClient).not.toHaveBeenCalledWith( + expect.objectContaining({ oauthToken: "sb_oat_new" }), + ); + }); +}); diff --git a/packages/cli/src/program.test.ts b/packages/cli/src/program.test.ts index 1f12068db..3e760dabf 100644 --- a/packages/cli/src/program.test.ts +++ b/packages/cli/src/program.test.ts @@ -1,4 +1,3 @@ -// program.test.ts import { beforeAll, describe, expect, it } from "vitest"; // Import the function after setting up mocks diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 322bd3cea..5d50cab6b 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -12,6 +12,9 @@ import { ConsoleTransport } from "./lib/logger/logger-transport-console"; import { resolveCommandPath } from "./utils/filesystem"; import { session } from "./session"; import { getMapiClient } from "./api"; +import { isExpiringSoon } from "./commands/oauth/expiry"; +import { refreshOAuthTokens } from "./commands/oauth/refresh"; +import { assertSpaceAllowed } from "./commands/oauth/space-guard"; import { applyConfigToCommander, getCommandAncestry, @@ -70,16 +73,45 @@ export function getProgram(): Command { applyConfigToCommander(ancestry, resolvedConfig); setActiveConfig(resolvedConfig); - // Initialize mapiClient + // Initialize mapiClient with the active credential (PAT or OAuth access token). const { state, initializeSession } = session(); await initializeSession(); - if (state.password) { + if (state.authType === "oauth" && state.region) { + let accessToken = state.oauthAccessToken; + if (isExpiringSoon(state.oauthExpiresAt)) { + try { + const refreshed = await refreshOAuthTokens(state.region); + accessToken = refreshed.access_token; + state.oauthAccessToken = refreshed.access_token; + state.oauthExpiresAt = refreshed.expires_at; + } catch (error) { + // The UI isn't configured yet at this point in the hook (Step 2 below applies + // the resolved config), so surface the re-login guidance via the default UI + // instance. Do not throw: commands that don't need auth should still run; + // authed commands will fail downstream if the token is dead. + getUI().warn((error as Error).message); + } + } + if (accessToken) { + getMapiClient({ + oauthToken: accessToken, + region: state.region ?? resolvedConfig.region, + }); + } + } else if (state.password) { getMapiClient({ personalAccessToken: state.password, region: state.region ?? resolvedConfig.region, }); } + // Guard OAuth sessions against operating on spaces outside their consent grant. + // A thrown CommandError here propagates out of the preAction hook, rejecting + // `program.parseAsync()` in index.ts, which handles it once at the top level. + if (state.authType === "oauth") { + assertSpaceAllowed(targetCommand.optsWithGlobals().space, state.oauthSpaces); + } + // Step 2: Setup logging, UI, and reporting with resolved config const options = targetCommand.optsWithGlobals(); const commandPieces: string[] = []; diff --git a/packages/cli/src/session.oauth.test.ts b/packages/cli/src/session.oauth.test.ts new file mode 100644 index 000000000..e38f5d347 --- /dev/null +++ b/packages/cli/src/session.oauth.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; + +vi.mock("node:fs"); +vi.mock("node:fs/promises"); +vi.unmock("./session"); + +describe("session OAuth support", () => { + beforeEach(() => { + vol.reset(); + vi.resetModules(); + delete process.env.STORYBLOK_LOGIN; + delete process.env.STORYBLOK_TOKEN; + delete process.env.STORYBLOK_REGION; + }); + afterEach(() => vol.reset()); + + it("should initialize an oauth session from stored tokens when no PAT exists", async () => { + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + oauth: { + eu: { + tokens: { + auth_type: "oauth", + access_token: "sb_oat_x", + refresh_token: "sb_ort_x", + expires_at: "2026-07-20T12:00:00.000Z", + }, + spaces: [{ id: 5, region: "eu" }], + }, + }, + }), + }); + const { session } = await import("./session"); + const s = session(); + await s.initializeSession(); + expect(s.state.isLoggedIn).toBe(true); + expect(s.state.authType).toBe("oauth"); + expect(s.state.oauthAccessToken).toBe("sb_oat_x"); + expect(s.state.region).toBe("eu"); + }); + + it("should prefer a stored PAT over oauth tokens", async () => { + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + "api.storyblok.com": { login: "me@example.com", password: "pat-token", region: "eu" }, + oauth: { + eu: { tokens: { auth_type: "oauth", access_token: "sb_oat_x", expires_at: "x" } }, + }, + }), + }); + const { session } = await import("./session"); + const s = session(); + await s.initializeSession(); + expect(s.state.authType).toBe("pat"); + expect(s.state.password).toBe("pat-token"); + }); + + it("should not produce a broken PAT session when only an oauth section is stored", async () => { + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + oauth: { + eu: { tokens: { auth_type: "oauth", access_token: "sb_oat_only", expires_at: "x" } }, + }, + }), + }); + const { session } = await import("./session"); + const s = session(); + await s.initializeSession(); + expect(s.state.authType).toBe("oauth"); + expect(s.state.login).toBeUndefined(); + expect(s.state.password).toBeUndefined(); + expect(s.state.oauthAccessToken).toBe("sb_oat_only"); + }); + + it("should resolve the active region ahead of the fixed order when several regions are logged in", async () => { + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + oauth: { + activeRegion: "us", + eu: { tokens: { auth_type: "oauth", access_token: "sb_oat_eu", expires_at: "x" } }, + us: { tokens: { auth_type: "oauth", access_token: "sb_oat_us", expires_at: "x" } }, + }, + }), + }); + const { session } = await import("./session"); + const s = session(); + await s.initializeSession(); + expect(s.state.region).toBe("us"); + expect(s.state.oauthAccessToken).toBe("sb_oat_us"); + }); + + it("should fall back to the fixed order when the active region has no tokens", async () => { + vol.fromJSON({ + [`${process.env.HOME}/.storyblok/credentials.json`]: JSON.stringify({ + oauth: { + activeRegion: "us", + eu: { tokens: { auth_type: "oauth", access_token: "sb_oat_eu", expires_at: "x" } }, + }, + }), + }); + const { session } = await import("./session"); + const s = session(); + await s.initializeSession(); + expect(s.state.region).toBe("eu"); + expect(s.state.oauthAccessToken).toBe("sb_oat_eu"); + }); +}); diff --git a/packages/cli/src/session.ts b/packages/cli/src/session.ts index ca836d5ec..ca6cfe169 100644 --- a/packages/cli/src/session.ts +++ b/packages/cli/src/session.ts @@ -1,6 +1,6 @@ -// session.ts import { type RegionCode, regionsDomain } from "./constants"; import { addCredentials, getCredentials } from "./creds"; +import { clearOAuthTokens, getOAuthActiveRegion, getOAuthEntry } from "./commands/oauth/store"; export interface SessionState { isLoggedIn: boolean; @@ -8,6 +8,10 @@ export interface SessionState { password?: string; region?: RegionCode; envLogin?: boolean; + authType?: "pat" | "oauth"; + oauthAccessToken?: string; + oauthExpiresAt?: string; + oauthSpaces?: { id: number; region: string }[]; } let sessionInstance: ReturnType | null = null; @@ -26,28 +30,74 @@ function createSession() { state.password = envCredentials.password; state.region = envCredentials.region as RegionCode; state.envLogin = true; + state.authType = "pat"; return; } // If no environment variables, fall back to .storyblok/credentials.json const credentials = await getCredentials(); - if (credentials) { - // Todo: evaluate this in future when we want to support multiple regions - const creds = Object.values(credentials)[0]; + // The credentials file also stores an `oauth` top-level key; exclude it so it is + // never mistaken for a PAT entry (it has no login/password/region fields). + const patEntry = credentials + ? Object.entries(credentials).find(([machineName]) => machineName !== "oauth")?.[1] + : undefined; + if (patEntry) { state.isLoggedIn = true; - state.login = creds.login; - state.password = creds.password; - state.region = creds.region as RegionCode; + state.login = patEntry.login; + state.password = patEntry.password; + state.region = patEntry.region as RegionCode; + state.authType = "pat"; } else { - // No credentials found; set state to logged out - state.isLoggedIn = false; - state.login = undefined; - state.password = undefined; - state.region = undefined; + // No PAT credentials; try an OAuth session. + const oauthLoaded = await loadOAuthSession(); + if (!oauthLoaded) { + state.isLoggedIn = false; + state.login = undefined; + state.password = undefined; + state.region = undefined; + state.authType = undefined; + } } state.envLogin = false; } + async function loadOAuthSession(): Promise { + // Resolve the most recently used region first via the stored `activeRegion` + // pointer, then fall back to a fixed order for its siblings. The fallback + // also covers sessions created before the pointer existed and cases where + // the pointer is stale (its region has no tokens). This resolution is not + // affected by a command's `--region`; to switch the active OAuth region, + // log in again (which repoints `activeRegion`). + const fixedOrder: RegionCode[] = ["eu", "us", "cn", "ca", "ap"]; + const activeRegion = await getOAuthActiveRegion(); + const regionsToCheck = activeRegion + ? [activeRegion, ...fixedOrder.filter((region) => region !== activeRegion)] + : fixedOrder; + for (const region of regionsToCheck) { + const entry = await getOAuthEntry(region); + if (entry.tokens?.access_token) { + state.isLoggedIn = true; + state.authType = "oauth"; + state.region = region; + state.oauthAccessToken = entry.tokens.access_token; + state.oauthExpiresAt = entry.tokens.expires_at; + state.oauthSpaces = entry.spaces; + return true; + } + } + return false; + } + + async function clearOAuthSession(region: RegionCode): Promise { + await clearOAuthTokens(region); + state.oauthAccessToken = undefined; + state.oauthExpiresAt = undefined; + state.oauthSpaces = undefined; + if (state.authType === "oauth") { + logout(); + } + } + function getEnvCredentials() { const envLogin = process.env.STORYBLOK_LOGIN || process.env.TRAVIS_STORYBLOK_LOGIN; const envPassword = process.env.STORYBLOK_TOKEN || process.env.TRAVIS_STORYBLOK_TOKEN; @@ -88,6 +138,7 @@ function createSession() { state.login = undefined; state.password = undefined; state.region = undefined; + state.authType = undefined; } return { @@ -96,6 +147,7 @@ function createSession() { updateSession, persistCredentials, logout, + clearOAuthSession, }; } diff --git a/packages/cli/src/utils/auth.test.ts b/packages/cli/src/utils/auth.test.ts new file mode 100644 index 000000000..c9b5373ee --- /dev/null +++ b/packages/cli/src/utils/auth.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SessionState } from "../session"; +import { requireAuthentication, sessionCredential } from "./auth"; + +vi.mock("./error", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleError: vi.fn() }; +}); + +describe("requireAuthentication", () => { + it("should accept a PAT session", () => { + const state: SessionState = { + isLoggedIn: true, + password: "pat-token", + region: "eu", + authType: "pat", + }; + expect(requireAuthentication(state)).toBe(true); + }); + + it("should accept an OAuth session (no password, has access token)", () => { + const state: SessionState = { + isLoggedIn: true, + region: "eu", + authType: "oauth", + oauthAccessToken: "oat-token", + }; + expect(requireAuthentication(state)).toBe(true); + }); + + it("should reject when not logged in", () => { + expect(requireAuthentication({ isLoggedIn: false })).toBe(false); + }); + + it("should reject an OAuth session without an access token", () => { + expect(requireAuthentication({ isLoggedIn: true, region: "eu", authType: "oauth" })).toBe( + false, + ); + }); + + it("should reject a session without a region", () => { + expect(requireAuthentication({ isLoggedIn: true, password: "pat-token" })).toBe(false); + }); +}); + +describe("sessionCredential", () => { + it("should return an oauthToken credential for OAuth sessions", () => { + const state: SessionState = { + isLoggedIn: true, + region: "eu", + authType: "oauth", + oauthAccessToken: "oat-token", + }; + expect(sessionCredential(state)).toEqual({ oauthToken: "oat-token" }); + }); + + it("should return a personalAccessToken credential for PAT sessions", () => { + const state: SessionState = { + isLoggedIn: true, + region: "eu", + authType: "pat", + password: "pat-token", + }; + expect(sessionCredential(state)).toEqual({ personalAccessToken: "pat-token" }); + }); + + it("should return undefined when no credential is present", () => { + expect(sessionCredential({ isLoggedIn: false })).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 4500befc4..e56e5736f 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -5,12 +5,34 @@ import chalk from "chalk"; type AuthenticatedSessionState = SessionState & { isLoggedIn: true; - password: NonNullable; region: NonNullable; }; /** - * Check if user is authenticated and handle error if not + * A credential the management API client can authenticate with: a Personal Access + * Token (PAT session) or an OAuth access token (OAuth session). + */ +export type ApiCredential = { personalAccessToken: string } | { oauthToken: string }; + +/** + * Resolve the API credential for the current session, preferring an OAuth access + * token when the session is OAuth-based and falling back to the PAT password. + * @param state - Session state object + * @returns the credential, or undefined when the session has neither + */ +export function sessionCredential(state: SessionState): ApiCredential | undefined { + if (state.authType === "oauth" && state.oauthAccessToken) { + return { oauthToken: state.oauthAccessToken }; + } + if (state.password) { + return { personalAccessToken: state.password }; + } + return undefined; +} + +/** + * Check if user is authenticated and handle error if not. + * Accepts both PAT sessions (password) and OAuth sessions (access token). * @param state - Session state object * @param verbose - Whether to show verbose error output * @returns true if authenticated, false if not (and error is handled) @@ -19,7 +41,9 @@ export function requireAuthentication( state: SessionState, verbose = false, ): state is AuthenticatedSessionState { - if (!state.isLoggedIn || !state.password || !state.region) { + const hasCredential = + Boolean(state.password) || (state.authType === "oauth" && Boolean(state.oauthAccessToken)); + if (!state.isLoggedIn || !hasCredential || !state.region) { handleError( new CommandError( `You are currently not logged in. Please run ${chalk.hex(colorPalette.PRIMARY)("storyblok login")} to authenticate, or ${chalk.hex(colorPalette.PRIMARY)("storyblok signup")} to sign up.`, diff --git a/packages/cli/test/GUIDE.md b/packages/cli/test/GUIDE.md index 101eb9c68..004bf2d3f 100644 --- a/packages/cli/test/GUIDE.md +++ b/packages/cli/test/GUIDE.md @@ -39,6 +39,43 @@ bash .agents/skills/qa-engineer-manual/scripts/seed-scenario.sh \ --scenario-dir packages/cli/test/scenarios ``` +## OAuth login + +Local builds ship placeholder OAuth credentials, so `login --oauth` needs +`STORYBLOK_OAUTH_CLIENT_ID` and `STORYBLOK_OAUTH_CLIENT_SECRET`. `.env.qa-engineer-manual` holds +them for the "QA Manual" app (redirect URI `http://localhost:4900/oauth/callback`, all scopes, no +space restriction): + +```bash +set -a && source ./.env.qa-engineer-manual && set +a +node ./packages/cli/dist/index.mjs login --oauth # add -r us|ca|ap|cn for other regions +``` + +Consent runs in the browser, the callback lands on port 4900, and tokens go to +`~/.storyblok/credentials.json` under `oauth.`. Back that file up: login and logout rewrite +it, and `logout` revokes the grant server-side. + +- **Consent as the app's org.** The "QA Manual" app is a private client, so `authorizable_by?` + accepts only users of the owning org (see the app's `creator` in `GET /v1/oauth_clients/`). + Signing in to app.storyblok.com as any other account fails consent with + `unauthorized_client: this application is not available to your organization`. Use an incognito + window to keep your main session. +- **Log out first.** A PAT entry in `credentials.json` shadows the OAuth session + (`initializeSession` prefers PAT), so `login --oauth` reports "already logged in" and OAuth + commands never run. Run `storyblok logout` before testing. +- **Stub the browser** to test the flow without a real tab: the `open` package spawns the bare + command `open` (macOS) / `xdg-open` (Linux) through `PATH`, so a shim of that name earlier on + `PATH` swallows the launch. The authorize URL is also printed to stderr, so you can paste it into + a browser yourself to finish consent. +- **Occupy port 4900 with `nc -l 127.0.0.1 4900`, not `nc -l 4900`.** The wildcard bind does not + conflict with the CLI's loopback bind under `SO_REUSEADDR`, so the CLI starts normally and hangs + for the full 5 minute callback timeout instead of reporting the conflict. + +Worth checking manually: port 4900 occupied, denied consent, out-of-grant space (restrict +`permitted_space_ids` via `PUT /v1/oauth_clients/`), and refresh (set `expires_at` to the past, +then run any command). Manage apps through `/v1/oauth_clients` with a Personal Access Token, where +`client_id` is `oauth_identifier` and `client_secret` is `oauth_secret`. + ## Shared asset libraries A shared asset library is a top-level shared asset folder owned by the organization, with per-space diff --git a/packages/cli/test/setup.ts b/packages/cli/test/setup.ts index cc037e0e5..dbc6ecfa1 100644 --- a/packages/cli/test/setup.ts +++ b/packages/cli/test/setup.ts @@ -14,6 +14,7 @@ export const loggedInSessionState = (): SessionState => ({ password: "valid-token", region: "eu", envLogin: false, + authType: "pat", }); const sessionApi = { state: loggedInSessionState(), @@ -27,6 +28,7 @@ const sessionApi = { sessionApi.state.region = region; }), persistCredentials: vi.fn().mockResolvedValue(undefined), + clearOAuthSession: vi.fn().mockResolvedValue(undefined), }; vi.mock("../src/session.ts", async (importOriginal) => {