Skip to content

Commit 08447ca

Browse files
fix(cli): clarify login method selection
1 parent 83544b1 commit 08447ca

10 files changed

Lines changed: 197 additions & 108 deletions

File tree

apps/docs/content/docs/cli/authentication.mdx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,32 @@ description: Sign in from the terminal, authenticate CI with an API key, and kee
55

66
import { Callout } from 'fumadocs-ui/components/callout'
77

8-
`sim login` signs you in through your browser and stores a short-lived login
9-
that renews itself and can be revoked at any time. In CI you supply an API key
10-
through the environment instead.
8+
`sim login` signs you in through your browser. It prefers OAuth, which stores a
9+
short-lived login that renews itself, and selects API-key pairing for remote
10+
terminals or servers without OAuth support. In CI you supply an existing API
11+
key through the environment instead.
1112

1213
## Signing in
1314

1415
```bash
1516
sim login
1617
```
1718

18-
The CLI opens your browser on Sim's sign-in page, then on a consent page that
19+
Choose a method explicitly when the credential type matters:
20+
21+
```bash
22+
sim login --method oauth
23+
sim login --method api-key
24+
```
25+
26+
`--method oauth` requires OAuth support and never falls back to an API key. The
27+
server must enable `OAUTH_PROVIDER_ENABLED=true`. Explicit OAuth selection also
28+
overrides SSH/headless detection; your browser still needs to reach the CLI's
29+
local callback. `--method api-key` uses pairing-code approval to create a new
30+
permanent API key. To supply an existing key, use the global `--api-key <key>`
31+
option or `SIM_API_KEY` instead.
32+
33+
OAuth login opens your browser on Sim's sign-in page, then on a consent page that
1934
names the Sim CLI and what it will be able to do. Approve, and the browser hands
2035
control back to the terminal:
2136

@@ -46,21 +61,21 @@ your login.
4661

4762
| Option | What it does |
4863
| --- | --- |
49-
| `--no-browser` | Print the URL instead of opening a browser |
50-
| `--browserless` | Use the pairing-code handoff instead (see below) |
64+
| `--method <method>` | `oauth` requires OAuth login; `api-key` creates a permanent key through pairing. Auto-selects when omitted |
65+
| `--no-browser` | Print the approval URL without opening it; works with either method |
5166
| `--read-only` | Ask only for permission to read, never to change anything |
5267
| `--callback-port <port>` | Pin the loopback callback port, primarily for an SSH session that forwards the same fixed port |
53-
| `--scope <scope>` | Key space for the pairing-code handoff. Only `copilot` changes anything, and it forces that flow |
5468
| `-y, --yes` | Overwrite an existing API-key profile without prompting |
5569

5670
### Over SSH or in a container
5771

58-
The browser login needs your browser to reach a listener on the machine running
72+
OAuth login needs your browser to reach a listener on the machine running
5973
`sim`. When it cannot — an SSH session, a dev container, a remote box — use the
60-
pairing-code handoff, which the CLI selects automatically in an SSH session:
74+
API-key pairing flow. The CLI selects it automatically in an SSH session when
75+
no method or callback port is specified:
6176

6277
```bash
63-
sim login --browserless
78+
sim login --method api-key --no-browser
6479
```
6580

6681
The terminal prints a pairing code and a URL you can open on any device:
@@ -86,15 +101,16 @@ approving. That check is what binds the approval to your terminal.
86101
The handoff issues a permanent personal API key rather than a renewing login,
87102
so revoke it under **Settings → API keys** when you are done with that machine.
88103
It is also the path for a deployment that predates OAuth sign-in, or one with
89-
the provider switched off; the CLI detects that and falls back on its own.
104+
the provider switched off; the CLI detects that and selects API-key login when
105+
`--method` is omitted. An explicit `--method oauth` fails in that case.
90106

91-
`--read-only` and `--callback-port` belong to the browser login and have no
107+
`--read-only` and `--callback-port` belong to OAuth login and have no
92108
meaning here, so combining either with the handoff stops the login rather than
93109
storing a credential you did not ask for. If your SSH session forwards a port
94-
from the remote loopback interface to the browser's machine, pass that same
95-
`--callback-port <port>` on its own. An ordinary container port publication
110+
from the remote loopback interface to the browser's machine, use
111+
`--method oauth --callback-port <port>` with that port. An ordinary container port publication
96112
cannot reach a listener bound to the container's own loopback interface; use
97-
`--browserless` there.
113+
`--method api-key` there.
98114

99115
### Picking a workspace
100116

@@ -160,7 +176,7 @@ authentication profile entirely is refused until its workspace profiles are
160176
removed, so it cannot leave dangling references.
161177

162178
<Callout type="warn">
163-
For a login created with `--browserless`, `sim logout` removes the API key from
179+
For a login created with `--method api-key`, `sim logout` removes the API key from
164180
disk but does **not** revoke it. Revoke keys under **Settings → API keys**.
165181
</Callout>
166182

@@ -279,5 +295,5 @@ a copied token from surviving an ambiguous refresh.
279295
## Organization audit logs
280296

281297
`sim audit-logs` requires a **personal** credential — an OAuth login, or the
282-
personal API key `sim login --browserless` issues. A workspace-scoped key cannot
298+
personal API key `sim login --method api-key` issues. A workspace-scoped key cannot
283299
read organization-level audit logs.

apps/docs/content/docs/cli/commands.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,8 @@ sim login [options]
6464

6565
| Option | Required | Description |
6666
| --- | --- | --- |
67-
| `--scope <scope>` | No | Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow. Defaults to `platform`. |
68-
| `--no-browser` | No | Print the URL instead of opening a browser. |
69-
| `--browserless` | No | Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers). |
67+
| `--method <method>` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. |
68+
| `--no-browser` | No | Print the approval URL without opening it (either login method). |
7069
| `--read-only` | No | Ask only for permission to read, never to change anything. |
7170
| `--callback-port <port>` | No | Pin the local port the browser returns to. |
7271
| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. |

apps/docs/content/docs/cli/reference.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,8 @@ sim login [options]
3838

3939
| Option | Required | Description |
4040
| --- | --- | --- |
41-
| `--scope <scope>` | No | Key space for the pairing-code handoff; only "copilot" changes anything, and it forces that flow. Defaults to `platform`. |
42-
| `--no-browser` | No | Print the URL instead of opening a browser. |
43-
| `--browserless` | No | Use the pairing-code handoff for a terminal whose browser cannot reach it (SSH, containers). |
41+
| `--method <method>` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. |
42+
| `--no-browser` | No | Print the approval URL without opening it (either login method). |
4443
| `--read-only` | No | Ask only for permission to read, never to change anything. |
4544
| `--callback-port <port>` | No | Pin the local port the browser returns to. |
4645
| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. |

packages/sim-cli/README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,23 @@ Sign in to the default profile:
3333
sim login
3434
```
3535

36-
The CLI opens Sim in your browser, asks you to approve the requested access,
36+
With no `--method`, the CLI prefers OAuth when the server offers it and a local
37+
browser callback is possible. It selects API-key pairing for remote terminals
38+
or servers without OAuth. Use `sim login --method oauth` to require OAuth;
39+
if the server does not offer it, login fails without creating an API key.
40+
41+
OAuth login opens Sim in your browser, asks you to approve the requested access,
3742
and receives the one-time authorization code on a loopback callback. It stores
3843
a short-lived OAuth login that renews automatically and can be revoked under
3944
**Settings → General → Authorized apps**. Choose a default workspace afterward with
4045
`sim configure --set-workspace <id>`.
4146

42-
Use `sim login --no-browser` to print the OAuth URL without opening it. The
43-
browser must still be able to reach the CLI's loopback callback. Over SSH or in
44-
a container without port forwarding, use `sim login --browserless`; that
45-
pairing-code fallback creates a permanent personal API key instead.
47+
Use `--no-browser` with either method to print the approval URL without opening
48+
it. OAuth still needs the browser to reach the CLI's loopback callback. Over SSH
49+
or in a container without port forwarding, use
50+
`sim login --method api-key --no-browser` to approve from another device and
51+
create a permanent personal API key. `--method api-key` creates a new key;
52+
the global `--api-key <key>` option supplies an existing one.
4653

4754
Check the active profile and verify that its endpoint, credential, and workspace
4855
work together:

packages/sim-cli/src/auth/device-flow.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,7 @@ describe('createAuthRequest', () => {
190190
const prefixed = 'https://host.test/sim'
191191
const auth = createAuthRequest()
192192

193-
expect(buildApprovalUrl(prefixed, auth, 'platform')).toMatch(
194-
/^https:\/\/host\.test\/sim\/cli\/auth\?/
195-
)
193+
expect(buildApprovalUrl(prefixed, auth)).toMatch(/^https:\/\/host\.test\/sim\/cli\/auth\?/)
196194

197195
// `spyOn`, like the rest of this file: `restoreAllMocks` in teardown undoes
198196
// it, whereas a `stubGlobal` would outlive the test and leak this
@@ -210,13 +208,18 @@ describe('createAuthRequest', () => {
210208

211209
it('omits an absent workspace rather than sending it blank', () => {
212210
const auth = createAuthRequest()
213-
expect(buildApprovalUrl(ENDPOINT, auth, 'platform')).not.toContain('workspace=')
214-
expect(buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1')).toContain('workspace=ws_1')
211+
expect(buildApprovalUrl(ENDPOINT, auth)).not.toContain('workspace=')
212+
expect(buildApprovalUrl(ENDPOINT, auth, 'ws_1')).toContain('workspace=ws_1')
213+
})
214+
215+
it('always requests a platform API key', () => {
216+
const url = new URL(buildApprovalUrl(ENDPOINT, createAuthRequest()))
217+
expect(url.searchParams.get('scope')).toBe('platform')
215218
})
216219

217220
it('never puts the poll secret in the browser URL', () => {
218221
const auth = createAuthRequest()
219-
const url = buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1')
222+
const url = buildApprovalUrl(ENDPOINT, auth, 'ws_1')
220223
expect(url).toContain(encodeURIComponent(auth.challenge))
221224
expect(url).not.toContain(auth.pollSecret)
222225
})

packages/sim-cli/src/auth/device-flow.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504])
5454
*/
5555
const TRANSPORT_FAILURES_BEFORE_WARNING = 3
5656

57-
export type CliAuthScope = 'copilot' | 'platform'
57+
type CliAuthScope = 'copilot' | 'platform'
5858

5959
export interface AuthRequest {
6060
/** Semi-public rendezvous handle; travels in the browser URL. */
@@ -103,14 +103,13 @@ export function createAuthRequest(): AuthRequest {
103103
export function buildApprovalUrl(
104104
endpoint: string,
105105
auth: AuthRequest,
106-
scope: CliAuthScope,
107106
workspaceId?: string
108107
): string {
109108
return buildUrl(endpoint, APPROVAL_PATH, {
110109
request: auth.request,
111110
challenge: auth.challenge,
112111
pairing: auth.pairing,
113-
scope,
112+
scope: 'platform',
114113
workspace: workspaceId,
115114
})
116115
}

packages/sim-cli/src/auth/oauth-flow.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,15 +300,15 @@ describe('loginWithBrowser', () => {
300300
expect(response.headers['cache-control']).toBe('no-store')
301301
})
302302

303-
it('gives up after the timeout with the browserless fallback named', async () => {
303+
it('gives up after the timeout with an explicit API-key login alternative', async () => {
304304
vi.stubGlobal('fetch', vi.fn())
305305
await expect(
306306
loginWithBrowser(ENDPOINT, {
307307
scopes: ['offline_access', 'api:read'],
308308
onAuthorizeUrl: () => {},
309309
timeoutMs: 20,
310310
})
311-
).rejects.toThrow('--browserless')
311+
).rejects.toThrow('--method api-key')
312312
})
313313
})
314314

packages/sim-cli/src/auth/oauth-flow.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ function listenForCallback(
432432
finish({
433433
ok: false,
434434
error: new SimApiError(
435-
`Timed out after ${Math.round(timeoutMs / 60000)} minutes waiting for the browser. Run sim login again, or use --browserless if this terminal's browser cannot reach it.`,
435+
`Timed out after ${Math.round(timeoutMs / 60000)} minutes waiting for the browser. Run sim login again, or use --method api-key if this terminal's browser cannot reach it.`,
436436
0
437437
),
438438
}),
@@ -567,9 +567,9 @@ export async function loginWithBrowser(
567567

568568
/**
569569
* Whether this terminal's browser is unlikely to reach a loopback listener on
570-
* this machine: an SSH session, or a Linux box with no display. The signals
571-
* Railway and Stripe use to auto-select their pairing flows; `--browserless`
572-
* forces it and `--callback-port` overrides the guess.
570+
* this machine: an SSH session, or a Linux box with no display. An explicit
571+
* `--method` selects the flow without this guess; `--callback-port` opts into
572+
* OAuth when a forwarded port makes the loopback listener reachable.
573573
*/
574574
export function isLikelyRemoteSession(
575575
env: NodeJS.ProcessEnv = process.env,

packages/sim-cli/src/commands/auth.test.ts

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,45 +1375,120 @@ describe('login command — OAuth', () => {
13751375
await expect(login('--callback-port', '70000')).rejects.toThrow('Invalid --callback-port')
13761376
})
13771377

1378-
it('falls back to the pairing code under --browserless', async () => {
1378+
it('creates an API key without OAuth discovery when that method is selected', async () => {
13791379
setInteractive(false)
1380-
await login('--browserless')
1380+
await login('--method', 'api-key')
13811381

13821382
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1383+
expect(mocks.discoverOAuthProvider).not.toHaveBeenCalled()
13831384
expect(mocks.pollForKey).toHaveBeenCalledOnce()
1385+
expect(mocks.writeCredentialsProfile).toHaveBeenCalledWith('default', {
1386+
kind: 'api_key',
1387+
apiKey: 'sim-key',
1388+
})
13841389
})
13851390

1386-
it('falls back to the pairing code in a remote session', async () => {
1391+
it.each([false, true])('honors explicit OAuth with remote detection %s', async (remote) => {
13871392
setInteractive(false)
1388-
mocks.isLikelyRemoteSession.mockReturnValue(true)
1389-
await login()
1393+
mocks.isLikelyRemoteSession.mockReturnValue(remote)
13901394

1391-
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1392-
expect(mocks.pollForKey).toHaveBeenCalledOnce()
1395+
await login('--method', 'oauth')
1396+
1397+
expect(mocks.loginWithBrowser).toHaveBeenCalledOnce()
1398+
expect(mocks.pollForKey).not.toHaveBeenCalled()
13931399
})
13941400

1395-
it('uses the pairing code for a copilot-scope key, which only the handoff mints', async () => {
1401+
it.each([false, true])(
1402+
'refuses unavailable explicit OAuth with remote detection %s without minting a key',
1403+
async (remote) => {
1404+
setInteractive(false)
1405+
mocks.isLikelyRemoteSession.mockReturnValue(remote)
1406+
mocks.discoverOAuthProvider.mockResolvedValue('unavailable')
1407+
1408+
await expect(login('--method', 'oauth')).rejects.toThrow('does not offer OAuth sign-in')
1409+
1410+
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1411+
expect(mocks.createAuthRequest).not.toHaveBeenCalled()
1412+
expect(mocks.pollForKey).not.toHaveBeenCalled()
1413+
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
1414+
}
1415+
)
1416+
1417+
it.each([
1418+
{ args: ['--read-only'], message: 'API-key login cannot issue a read-only login' },
1419+
{
1420+
args: ['--callback-port', '8976'],
1421+
message: 'API-key login has no local callback',
1422+
},
1423+
])('rejects OAuth-only options for API-key login: $args', async ({ args, message }) => {
13961424
setInteractive(false)
1397-
mocks.pollForKey.mockResolvedValue({
1398-
apiKey: 'sim-key',
1399-
scope: 'copilot',
1400-
workspaceBound: false,
1401-
workspaceId: undefined,
1402-
})
1403-
await login('--scope', 'copilot')
14041425

1405-
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1426+
await expect(login('--method', 'api-key', ...args)).rejects.toThrow(message)
1427+
14061428
expect(mocks.discoverOAuthProvider).not.toHaveBeenCalled()
1429+
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1430+
expect(mocks.pollForKey).not.toHaveBeenCalled()
1431+
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
14071432
})
14081433

1409-
it('refuses an unreachable endpoint rather than guessing it lacks the provider', async () => {
1410-
setInteractive(false)
1411-
mocks.discoverOAuthProvider.mockResolvedValue('unreachable')
1434+
it.each([
1435+
{ args: ['--method', 'password'], code: 'commander.invalidArgument' },
1436+
{ args: ['--method'], code: 'commander.optionMissingArgument' },
1437+
{ args: ['--browserless'], code: 'commander.unknownOption' },
1438+
{ args: ['--scope', 'copilot'], code: 'commander.unknownOption' },
1439+
{ args: ['--scope', 'platform'], code: 'commander.unknownOption' },
1440+
])('rejects invalid login options before authentication: $args', async ({ args, code }) => {
1441+
const command = loginCommand()
1442+
.exitOverride()
1443+
.configureOutput({ writeErr: () => {} })
1444+
1445+
await expect(command.parseAsync(args, { from: 'user' })).rejects.toMatchObject({ code })
14121446

1413-
await expect(login()).rejects.toThrow('Could not reach https://sim.ai')
1447+
expect(mocks.profileFrom).not.toHaveBeenCalled()
1448+
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
14141449
expect(mocks.pollForKey).not.toHaveBeenCalled()
14151450
})
14161451

1452+
it('falls back to the pairing code in a remote session', async () => {
1453+
setInteractive(false)
1454+
mocks.isLikelyRemoteSession.mockReturnValue(true)
1455+
await login()
1456+
1457+
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1458+
expect(mocks.pollForKey).toHaveBeenCalledOnce()
1459+
})
1460+
1461+
it.each([{ args: [] }, { args: ['--method', 'api-key'] }])(
1462+
'refuses to store a copilot key returned by the server with method args $args',
1463+
async ({ args }) => {
1464+
setInteractive(false)
1465+
mocks.discoverOAuthProvider.mockResolvedValue('unavailable')
1466+
mocks.pollForKey.mockResolvedValue({
1467+
apiKey: 'sim-key',
1468+
scope: 'copilot',
1469+
workspaceBound: false,
1470+
workspaceId: undefined,
1471+
})
1472+
await expect(login(...args)).rejects.toThrow('the CLI requires a platform API key')
1473+
1474+
expect(mocks.loginWithBrowser).not.toHaveBeenCalled()
1475+
expect(mocks.pollForKey).toHaveBeenCalledOnce()
1476+
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
1477+
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
1478+
}
1479+
)
1480+
1481+
it.each([{ args: [] }, { args: ['--method', 'oauth'] }])(
1482+
'refuses an unreachable endpoint with method args $args without minting a key',
1483+
async ({ args }) => {
1484+
setInteractive(false)
1485+
mocks.discoverOAuthProvider.mockResolvedValue('unreachable')
1486+
1487+
await expect(login(...args)).rejects.toThrow('Could not reach https://sim.ai')
1488+
expect(mocks.pollForKey).not.toHaveBeenCalled()
1489+
}
1490+
)
1491+
14171492
it('requires logout before replacing a stored OAuth login', async () => {
14181493
setInteractive(false)
14191494
mocks.readCredentialsProfile.mockReturnValue({

0 commit comments

Comments
 (0)