Skip to content

feat(auth): support multiple accounts via --profile#35

Open
0xble wants to merge 15 commits into
lox:mainfrom
0xble:feat/auth-profiles-upstream
Open

feat(auth): support multiple accounts via --profile#35
0xble wants to merge 15 commits into
lox:mainfrom
0xble:feat/auth-profiles-upstream

Conversation

@0xble
Copy link
Copy Markdown
Collaborator

@0xble 0xble commented Apr 18, 2026

Why

Using notion-cli currently across multiple Notion workspaces is not supported, or would involve swapping ~/.config/notion-cli/{token,config}.json by hand or re-running auth login every time. Adding a --profile flag and named profiles to config, with zero migration for existing single-account installs and full backwards compatibility.

Closes #30.

Examples

# Log in to the default profile (matches current behavior)
notion-cli auth login
# Opens browser for OAuth...

# Log in to a separate profile
notion-cli auth login --profile work
# Opens browser for OAuth...

# Use a profile for a single command
notion-cli page list --profile work
# (Lists pages from the "work" account)

# Pin a profile for every invocation (survives across shells)
cat > ~/.config/notion-cli/settings.json <<'JSON'
{"default_profile": "work"}
JSON
notion-cli auth status
# ✓ Authenticated
#
# Profile:     work (from settings.json)
# Config path: /Users/me/.config/notion-cli/work/token.json
# Token type:  bearer
# Expires:     18 Apr 2026 16:00

# Pin a profile for the shell session
export NOTION_CLI_PROFILE=work
notion-cli auth status
# ✓ Authenticated
#
# Profile:     work
# Config path: /Users/me/.config/notion-cli/work/token.json
# Token type:  bearer
# Expires:     18 Apr 2026 16:00

# No default credentials and no profile specified
notion-cli page list
# ✗ No profile specified. Pass --profile <name> or set NOTION_CLI_PROFILE.

Summary

  • --profile flag and NOTION_CLI_PROFILE env scope the OAuth token and official API config to a named account
  • Named profiles live in ~/.config/notion-cli/<profile>/{token,config}.json; the implicit default profile keeps using the existing top-level ~/.config/notion-cli/{token,config}.json, so current setups need no migration
  • New top-level ~/.config/notion-cli/settings.json accepts {"default_profile": "<name>"}; precedence is --profile > NOTION_CLI_PROFILE > settings.json > implicit top-level default
  • When none of those resolve (no top-level {token,config}.json, no settings.json, no flag, no env), fail up front with "No profile specified. Pass --profile or set NOTION_CLI_PROFILE." instead of silently treating the caller as unauthenticated

Notes

  • Profile names must match ^[a-z0-9][a-z0-9_-]*$ (lowercase ASCII; digit- or letter-led; _ and - allowed). Empty, whitespace, or path-unsafe names are rejected up front with a specific error.
  • auth status always shows the resolved profile on its own line, including where it came from (--profile flag, NOTION_CLI_PROFILE, settings.json, or implicit default), so users never have to guess which account the CLI is hitting.
  • If no root ~/.config/notion-cli/{token,config}.json exists, every command errors with ✗ No profile specified. Pass --profile <name> or set NOTION_CLI_PROFILE.. I actually find this useful, and personally I intentionally remove the root-level files to require --profile each time, which forces agents to be more intentional and not read or write the wrong workspace.
  • Out of scope for now (likely follow-ups after this merges): commands to list/rename/delete profiles, interactive profile switching, maybe per-profile API base URL overrides

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ffed0314c6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main.go Outdated
Comment thread internal/cli/context.go Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2142932ce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cmd/root.go Outdated
@0xble
Copy link
Copy Markdown
Collaborator Author

0xble commented Apr 18, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 295d45571a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main.go Outdated
Copy link
Copy Markdown
Owner

@lox lox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good, with the exception of the global vs state passed in via main and passed to where it's needed.

Comment thread internal/cli/profile.go Outdated
@0xble
Copy link
Copy Markdown
Collaborator Author

0xble commented Apr 30, 2026

Agreed. I removed the package-global active profile and pass the resolved profile through the Kong-injected command context instead. Token store, MCP client, and official API config paths now receive the profile explicitly. CI is green.

0xble added 8 commits May 5, 2026 11:25
New package resolves the active notion-cli profile from --profile flag,
NOTION_CLI_PROFILE env, settings.json default_profile, and legacy
top-level credentials, and exposes helpers for per-profile token/config
paths. Includes a resolver that returns ErrNoProfile when no profile is
selected and no legacy top-level files exist, so callers can fail up
front instead of silently running unauthenticated.

Profile names must match ^[a-z0-9][a-z0-9_-]*\$ so they stay safe to
embed in filesystem paths.
Accept a profile in NewFileTokenStore and config.Load/Save so named
profiles store credentials under ~/.config/notion-cli/<profile>/ while
the implicit default profile keeps using the legacy top-level paths.
Existing single-account installs read and write the same files as
before.

Adds round-trip tests that two profiles can hold their own tokens and
configs without colliding.
Add a persistent --profile flag (and NOTION_CLI_PROFILE env) on the
root command, resolve it once in main, and thread it through every
token store and config load via internal/cli.ActiveProfile. auth
status gains a Profile line that names the active profile and where
the selection came from (flag, env, settings, or implicit default) so
users can verify which account the CLI is hitting.

When no profile is selected and no legacy top-level credentials exist,
notion-cli now fails up front with "No profile specified. Pass
--profile <name> or set NOTION_CLI_PROFILE." instead of silently
treating the caller as unauthenticated.
Add a Profiles section to the README explaining --profile,
NOTION_CLI_PROFILE, settings.json default_profile, resolution
precedence, the no-profile error, name validation, and on-disk
layout. Mirror the short form in the bundled notion skill and add
NOTION_CLI_PROFILE to the environment variables table.
…file

Headless callers with only NOTION_ACCESS_TOKEN no longer trip the "No
profile specified" gate; profile resolution is only fatal when there is
no access token to authenticate with.

Also route the MCP client token store through the active profile. Before
this, every GetClient call constructed mcp.NewClient() without profile
context, so it always opened the default profile's store; a run with
--profile <name> could refresh one profile and then authenticate with
another, or fail as unauthenticated when only the named profile had
credentials.
Drop the env:"NOTION_CLI_PROFILE" tag from CLI.Profile. Kong was
merging the flag and env values into one field, which meant
profile.Resolve always saw a non-empty flagValue and classified
env-selected profiles as SourceFlag; auth status would then report
"from --profile flag" when the user had only set the environment
variable. profile.Resolve already handles NOTION_CLI_PROFILE in its
fallback chain, so main passes the bare flag value and the resolver
records SourceEnv correctly.
@0xble 0xble force-pushed the feat/auth-profiles-upstream branch from e938f4c to d3727b1 Compare May 5, 2026 15:50
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d3727b1d4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/mcp/oauth.go
Comment thread internal/mcp/token_store.go
@0xble
Copy link
Copy Markdown
Collaborator Author

0xble commented May 5, 2026

@lox I’ll be using and dogfooding this new feature locally on my fork for a few more days, may update PR along the way with improvements/fixes. If you want to be efficient might be best to re-review it then, I can post something to lyk when I think its ready

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 719e0816c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/config/config.go Outdated
0xble added 3 commits May 5, 2026 12:10
`auth status` and `auth list` previously reported `OAuth: expired`
whenever the access token was past its expiry, even though the README
already promises the CLI auto-refreshes on use when a refresh token is
on file. The "expired" line was cosmetic only; commands kept working,
but it caused agents and humans to react to a non-problem.

Collapse the recoverable case into `valid`, and surface the truly
broken case as `login_required` so the actionable distinction is
preserved. Update the bundled skill's "Auth preflight" tip and auth
command annotations accordingly.

JSON shape: `oauth_status` now reports `valid` | `login_required` |
`missing` instead of `valid` | `expired` | `missing`. The
`authenticated` boolean continues to track whether the next command
will succeed without re-login, which is now actually accurate.
@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

paths, err := PathsForProfile(profile)
if err != nil {
return "", err
}

P2 Badge Resolve config paths without requiring HOME for default profile

PathForProfile now delegates to PathsForProfile, which always computes the default profile’s legacy token path via os.UserHomeDir(). That makes config-only flows fail in environments where HOME/user lookup is unavailable (common in minimal CI containers), even though these code paths only need config.json and previously worked via os.UserConfigDir(). This regresses commands that read/write API config (for example auth api status/setup) under the default profile without any token access.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

`PathForProfile` only returns a profile's config-file path, but it
delegated to `PathsForProfile`, which always computed the default
profile's legacy token path via `os.UserHomeDir()`. That made
config-only flows (`auth api status/setup` and similar) fail in
environments where HOME is unavailable, even though they only need
`config.json` and previously resolved via `os.UserConfigDir()` alone.

Extract a `profileBaseDir` helper that depends only on `ConfigDir()`
so config-path resolution no longer transitively requires HOME. Token
path resolution still uses `legacyDefaultTokenPath` for the default
profile, since that path is genuinely under `~/.config/notion-cli`
and cannot be served from `os.UserConfigDir()` on every platform.

Reported by Codex review on PR lox#35.
0xble added a commit to 0xble/notion-cli that referenced this pull request May 6, 2026
`PathForProfile` only returns a profile's config-file path, but it
delegated to `PathsForProfile`, which always computed the default
profile's legacy token path via `os.UserHomeDir()`. That made
config-only flows (`auth api status/setup` and similar) fail in
environments where HOME is unavailable, even though they only need
`config.json` and previously resolved via `os.UserConfigDir()` alone.

Extract a `profileBaseDir` helper that depends only on `ConfigDir()`
so config-path resolution no longer transitively requires HOME. Token
path resolution still uses `legacyDefaultTokenPath` for the default
profile, since that path is genuinely under `~/.config/notion-cli`
and cannot be served from `os.UserConfigDir()` on every platform.

Reported by Codex review on PR lox#35.
@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

notion-cli/cmd/auth.go

Lines 524 to 525 in 5bdef6f

expiresAt := token.ExpiresAt
status.OAuthExpiresAt = &expiresAt

P2 Badge Skip zero expiry for profiles without an OAuth token

inspectProfileStatus always sets OAuthExpiresAt from token.ExpiresAt even when AccessToken is empty. An interrupted OAuth login can leave token.json containing only client_id (written by SaveClientID), so auth status/auth list will report oauth_status: "missing" but also emit an expires_at of the zero time (0001-01-01), which is misleading for humans and for scripts consuming the JSON output. Only populating expiry when a real access token exists avoids this false signal.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

`auth status --json` and `auth list --json` shipped with divergent key
names for the same fields: status used `expires_at`/`has_token` while
list used `oauth_expires_at`/`has_oauth_token`. Status also omitted
`active`, `has_api_token`, and `config_path` despite having the data.

Standardize on the list/struct key names and add the missing fields so
a single-profile inspection and a rows-of-the-list-format inspection
return the same shape (plus the existing `authenticated` synthetic at
the top level).
@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

func ResolveProfile(profile string) (string, error) {
normalized := strings.TrimSpace(profile)
if normalized == "" {
return defaultProfileName, nil
}
runes := []rune(normalized)
if !isProfileEndpoint(runes[0]) || !isProfileEndpoint(runes[len(runes)-1]) {
return "", fmt.Errorf("invalid profile %q: start and end with a lowercase letter or number", profile)
}
for _, r := range runes {
if isProfileChar(r) {
continue
}
return "", fmt.Errorf("invalid profile %q: use lowercase letters, numbers, at sign, dot, underscore, and hyphen", profile)
}
return normalized, nil

P2 Badge Reject Windows reserved profile names

ResolveProfile accepts values like con, prn, nul, or com1, which then flow into profileBaseDir and file operations for token/config/state paths. On Windows those are reserved device names, so commands such as auth login --profile con or auth use con will persist/select a profile that later fails on directory/file creation, breaking profile workflows on that platform. Add an explicit reserved-name check during profile validation to keep profile names portable across supported OSes.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Two follow-on fixes from Codex review of the multi-profile work:

- inspectProfileStatus always populated OAuthExpiresAt from the stored
  token, even when the access token field was empty. An interrupted
  OAuth login can leave token.json holding only a client_id, so status
  output would report `oauth_status: missing` alongside a misleading
  `oauth_expires_at: 0001-01-01T00:00:00Z`. Only set the expiry when a
  real access token is present.

- ResolveProfile accepted Windows reserved device basenames (con, prn,
  aux, nul, com1-9, lpt1-9, plus the same with extensions) which then
  flow into file paths under `~/.config/notion-cli/profiles/<name>/`.
  Allowing them lets a profile authenticate on macOS or Linux but fail
  the moment Windows tries to create the directory. Reject reserved
  basenames during validation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support multiple auth/config profiles for multiple Notion accounts

2 participants