Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8cef7bb
feat(cli): add oauth pkce, callback server and token endpoint buildin…
maoberlehner Jul 20, 2026
8b06673
fix(cli): validate oauth token response and use typed errors in callb…
maoberlehner Jul 20, 2026
130dcf1
feat(cli): add oauth credentials store
maoberlehner Jul 20, 2026
7fdc76d
feat(cli): add oauth client credential resolution
maoberlehner Jul 20, 2026
eafc0b1
feat(cli): add oauth grant introspection
maoberlehner Jul 20, 2026
d27689f
feat(cli): add rotation-safe oauth token refresh
maoberlehner Jul 20, 2026
9dfff4a
fix(cli): key oauth refresh single-flight by region
maoberlehner Jul 20, 2026
81498f5
feat(cli): add oauth setup find-or-create client actions
maoberlehner Jul 20, 2026
3513b5f
feat(cli): add storyblok oauth setup command
maoberlehner Jul 20, 2026
2f76d6c
feat(cli): add oauth login flow orchestration
maoberlehner Jul 20, 2026
df7a70a
feat(cli): add oauth login strategy to storyblok login
maoberlehner Jul 20, 2026
68c2e9e
feat(cli): load oauth tokens into the CLI session
maoberlehner Jul 20, 2026
aa3b54e
feat(cli): refresh oauth tokens proactively and wire oauthToken into …
maoberlehner Jul 20, 2026
9e2e005
feat(cli): guard oauth sessions against out-of-grant spaces
maoberlehner Jul 20, 2026
498777e
fix(cli): surface preAction errors via parseAsync soft-exit instead o…
maoberlehner Jul 20, 2026
fca21f6
feat(cli): clear oauth credentials on logout
maoberlehner Jul 20, 2026
b489ab6
docs(cli): register oauth command and document oauth login
maoberlehner Jul 20, 2026
a87986b
docs(cli): clarify oauth grants are conditionally space-scoped
maoberlehner Jul 20, 2026
d04de68
fix(cli): surface oauth refresh failures to the user and cover the pr…
maoberlehner Jul 20, 2026
ae38af9
fix(cli): unwrap grant root key in oauth grant introspection
maoberlehner Jul 20, 2026
f9231ec
fix(cli): authenticate commands with oauth sessions
maoberlehner Jul 20, 2026
a88cb62
docs(cli): align oauth README with assets push structure
maoberlehner Jul 21, 2026
36951fa
chore(cli): drop redundant filename header comments
maoberlehner Jul 21, 2026
88c7cf4
fix(cli): address oauth login review findings
maoberlehner Jul 21, 2026
c6ee931
refactor(cli): rename OAuth identifiers to OAuth casing
maoberlehner Jul 22, 2026
6aa49b2
fix(cli): address oauth login review feedback
maoberlehner Jul 22, 2026
b2007b9
feat(cli): resolve oauth session by active region
maoberlehner Jul 23, 2026
58b7fa5
fix(cli): address oauth login review feedback
maoberlehner Jul 23, 2026
2f55675
fix(cli): harden oauth logout
maoberlehner Jul 23, 2026
ebdc3e5
fix(cli): stop oauth setup spinner on provisioning error
maoberlehner Jul 23, 2026
306f906
docs(cli): correct oauth region-resolution comment
maoberlehner Jul 23, 2026
2514695
fix(cli): route oauth output through the lib/ui module
maoberlehner Aug 10, 2026
f5b4f06
feat(cli): name the process blocking the oauth callback port
maoberlehner Aug 10, 2026
6e11364
refactor(cli)!: drop oauth setup for a baked-in oauth client
maoberlehner Aug 10, 2026
b9281c5
chore(cli): format oauth sources with oxfmt
maoberlehner Aug 11, 2026
6c12349
chore(agents): broaden the manual QA skill trigger
maoberlehner Aug 12, 2026
668ff70
docs(cli): document oauth login for manual testing
maoberlehner Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/qa-engineer-manual/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions .env.qa-engineer-manual.template
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 1 addition & 1 deletion packages/cli/src/commands/create/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
Expand Down
49 changes: 22 additions & 27 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<InteractiveLoginResult | null> {
const ui = getUI();
try {
ui.br();
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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");
}
Expand All @@ -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");
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/commands/login/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,6 +24,7 @@ Go to [https://app.storyblok.com/#/me/account?tab=token] and click on **Generate
| Option | Description | Default |
| ----------------------- | ------------------------------------------------------------ | ------- |
| `-t, --token <token>` | Login directly with a token (useful for CI environments) | - |
| `--oauth` | Login with OAuth (opens your browser for consent) | - |
| `-r, --region <region>` | Set the region to work with (must match your space's region) | `eu` |

## Examples
Expand All @@ -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
Expand Down
69 changes: 64 additions & 5 deletions packages/cli/src/commands/login/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InteractiveLoginResult | null> {
const { verbose = false, preSelectedRegion, showWelcomeMessage = true } = options || {};
const ui = getUI();
let activeSpinner: CLISpinner | null = null;
Expand All @@ -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",
Expand All @@ -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(
[
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -161,7 +192,7 @@ export async function performInteractiveLogin(options?: {
true,
);
}
return { token: userToken, region: userRegion };
return { authType: "pat", token: userToken, region: userRegion };
}
}

Expand All @@ -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<OAuthLoginResult | null> {
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;
}
}
12 changes: 10 additions & 2 deletions packages/cli/src/commands/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +24,8 @@ export const loginCommand = program
"-r, --region <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
Expand All @@ -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 {
Expand Down
Loading
Loading