Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .changeset/provider-sign-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"seamless-cli": minor
---

`seamless profile login` signs in through the OpenID Provider, which brings passkey sign-in to the CLI. It opens the system browser with a loopback redirect, and `--device` uses the device grant for a machine with no browser. Either way the CLI exchanges the sign-in for a first-party session of its own at `POST /sessions/exchange` and stores that in the OS keychain, keeping no token from the provider. Needs auth server v0.23 and the provider edge.

The CLI reads the provider from `oidcProvider` on `GET /system-config/public` when the instance advertises it. On one that does not, pass `--oidc-issuer` and `--client-id` once, to `seamless profile login` or `seamless profile add`. Flags win over what the profile saved, and that wins over the advertisement, so an instance that stops advertising cannot downgrade a profile. With no provider anywhere the email code stays, and `--email-code` asks for it.

Every provider sign-in prints the issuer it uses. The CLI asks before using an advertised issuer on another origin than the instance, and without a terminal it stops and asks for the flags. It reads the advertisement only over https or on loopback, so an instance on plain http needs the flags.

Every sign-in now checks the OS keychain before it starts, including `seamless login` and the email code, and fails early on a machine with none, rather than after a browser approval or an emailed code.

`seamless clients create` and `update` gain `--session-exchange` and `--no-session-exchange`, and `get` shows the setting.

A browser session can step up with a passkey as well as the authenticator app. A device session uses the authenticator app only, and needs a step-up before admin and organization commands, reads included.
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ Templates are not in this repo — they live in the `seamless-templates` monorep
authenticator app code on the same session and retries the request once, so a new admin call
needs nothing extra. Wrappers still map a 403 with `forbidden(res)`, which tells a missing
step-up apart from a missing admin role.
- **Step-up follows the sign-in.** `TokenBundle.signIn` records a session from the OpenID Provider
(`loopback` or `device`), and `sessionStepUp` ([src/core/providerStepUp.ts](src/core/providerStepUp.ts))
picks the step-up from it: a device session gets the authenticator app only, and never a passkey or
an enrollment. It only chooses what to offer; the instance decides what a session may do.
- **Never send a subject token twice.** `exchangeForSession`
([src/core/providerSignIn.ts](src/core/providerSignIn.ts)) makes one request, whatever happens. A
second presentation revokes the session the first one created, so a lost response means signing in
again. The request body is strict: an extra field answers `invalid_request`.
- **The provider e2e is gated.** [src/commands/providerLogin.e2e.test.ts](src/commands/providerLogin.e2e.test.ts)
drives the real CLI, the real keychain and Chromium against a running provider recipe, and skips
unless `SEAMLESS_PROVIDER_E2E_DIR` is set, so CI skips it. Its header has the command.
- **Shared test fixtures** live in `src/**/*.fixtures.ts` (for example
[src/core/admin.fixtures.ts](src/core/admin.fixtures.ts)). That suffix is excluded from the build
and from coverage; a `*.test.ts` helper would instead run as an empty test file.
Expand Down
90 changes: 88 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,9 @@ does.

### Logging in

Both logins use email OTP: you paste the code from your inbox, so nothing needs to be delivered to
the CLI and no service token is required.
`seamless login` and `seamless profile login` on a profile with no OpenID Provider use email OTP:
you paste the code from your inbox, so nothing needs to be delivered to the CLI and no service token
is required.

```bash
# The portal, for managed work. Needs no profile.
Expand All @@ -564,6 +565,68 @@ seamless profile login prod # defaults to the active profile
seamless profile login prod --identifier you@example.com
```

### Signing in through the OpenID Provider

On an instance running the OpenID Provider (auth server v0.23 and the provider edge), `seamless
profile login` signs in through your browser instead, which brings passkey sign-in to the CLI. An
instance that advertises its provider in `GET /system-config/public` needs nothing more, apart from
a question the first time if the provider is on another origin. For one that does not, tell the
profile where the provider is and which client to use. Either way they are saved after the first
successful sign-in. Flags win over what the profile saved, and that wins over
what the instance advertises:

```bash
seamless profile login prod # the advertised provider, or what the profile saved
seamless profile login prod --device # from a machine with no browser

seamless profile login prod \
--oidc-issuer https://auth.example.com \
--client-id <client id>
seamless profile login prod --email-code # the email code, on purpose
```

- **In a browser,** the CLI listens on `127.0.0.1` on a random port for one redirect, for up to five
minutes, and opens your system browser (`BROWSER` overrides which). The page asks "Continue to
\<app\>?" and names your account, every time, because any program on your machine could have
started the sign-in. Continue only if you did.
- **With `--device`,** the CLI prints a URL and a code. Open the URL on any device with a browser and
enter the code. Nothing has to reach back to the machine you are on, so this works over SSH. The
machine still needs a keychain to store the session in (see
[Token storage](#token-storage-and-the-keychain)).
- **Either way,** the CLI exchanges the sign-in for a session of its own at `POST
/sessions/exchange`, stores that session in the OS keychain, and keeps no token from the provider.
`seamless logout` ends that session and leaves your browser signed in. When you sign out at the
provider, or the session the browser holds ends, the CLI's session ends too, and its `refreshTtl`
never outlasts it.
- **If an exchange fails, sign in again.** The CLI never retries one: the token works once, and
presenting it a second time revokes the session the first one created. If you are told the token
had already been used, your sign-in may have been intercepted, so check `seamless sessions`.
- **Check the issuer it names.** A sign-in prints the provider it uses. The first time an instance
advertises a provider on another origin, the CLI asks before using it, and without a terminal it
stops and asks for `--oidc-issuer` and `--client-id`. The sign-in pages are the provider's own, so
they look right even when the instance you pointed the CLI at is not the one you meant.
- **An advertisement is read only over https or on this machine.** On plain http, such as a LAN
address or a `.local` name, anyone on the network could write it, so pass `--oidc-issuer` and
`--client-id` there.
- **With no provider anywhere,** on the profile or advertised, you get the email code, with or
without `--device`. Once an issuer is known, one that cannot be read stops and says so, rather than
quietly using the weaker sign-in, and an instance that stops advertising does not change a profile
that saved one. Pass `--email-code` if you want it.
- **Every sign-in checks the keychain first,** before a browser opens or a code is sent, so you are
never sent through an approval that could not end in a stored session. See
[Token storage](#token-storage-and-the-keychain).

The client has to exist first. An admin registers a first-party public client with a loopback
redirect, even if you will only use `--device`, with the device grant on for `--device`:

```bash
seamless clients create "Seamless CLI" --public --first-party --auth-method none \
--redirect-uri http://127.0.0.1/callback --device-flow --session-exchange

seamless clients update <id> --session-exchange # on an existing client
seamless clients update <id> --no-session-exchange # revokes the sessions it exchanged for
```

`seamless profile login` does not change which profile is active, so a one-off command against
another instance leaves your default alone.

Expand Down Expand Up @@ -760,6 +823,25 @@ This action needs a fresh step-up on your session.
Without a terminal, set `SEAMLESS_TOTP_CODE` to a current code. It is used for the first step-up of
the run only: a code is single use, so a second one needs a terminal.

A session from a browser sign-in can also step up with a passkey. The CLI asks which you want, signs
in again in the browser, replaces its session with the new one, and ends the old one. An account with
no authenticator app goes straight to the passkey. If your browser had no session at that moment, the
instance counts that as a sign-in rather than a step-up, and the CLI says so: run the command again,
and the browser asks you to confirm with your passkey.

A session from `--device` has its own rules, because whoever approved the code in a browser may not
be whoever holds this terminal:

- It needs a step-up before reads too: everything under `users`, `config`, `clients`, `resources` and
`migrate`, every `org` command, and any change to how the account
signs in or recovers. `seamless whoami`, `seamless logout` and `seamless sessions`, including
`sessions revoke`, need none.
- It steps up with the authenticator app only. No passkey is offered, since a terminal cannot run the
ceremony.
- An account with no authenticator app cannot get one from here. Enroll one from a browser, then run
`seamless profile login --device` again.
- A refresh clears the step-up, so it can ask again sooner than five minutes.

### Token storage and the keychain

The refresh token is the durable secret, so sessions are stored in the operating system keychain,
Expand All @@ -769,6 +851,10 @@ never in a plaintext file:
- Windows: Credential Manager
- Linux: Secret Service (libsecret, for example GNOME Keyring or KWallet)

Signing in needs one of these, and the CLI checks before it starts. On a headless machine, install
and unlock one, for example gnome-keyring in a D-Bus session, or sign in from a machine with a
keychain. Tokens never go in a file instead.

Tokens are keyed per profile (by name and instance URL) so multiple instances never collide, and
they are removed when you log out or remove the profile. Access tokens are refreshed transparently:
on a `401` the CLI refreshes via `/refresh`, stores what came back, and retries once. Concurrent
Expand Down
22 changes: 16 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@
"dependencies": {
"@clack/prompts": "^1.0.1",
"@napi-rs/keyring": "^1.3.0",
"@seamless-auth/types": "^0.35.0",
"@seamless-auth/types": "^0.37.0",
"adm-zip": "^0.6.1",
"jose": "^6.2.15",
"kleur": "^4.1.5",
"zod": "^4.6.5"
},
Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 81 additions & 0 deletions src/commands/clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../core/admin.js";
import { apiApplication, apiResource, RESOURCE_ID } from "../core/admin.fixtures.js";
import { runClients } from "./clients.js";
import { COMMAND_HELP } from "./helpTopics.js";

vi.mock("@clack/prompts", () => ({
confirm: vi.fn(),
Expand Down Expand Up @@ -644,3 +645,83 @@ describe("clients delete", () => {
expect(errors()).toContain("Usage: seamless clients delete <id>");
});
});

describe("clients: session exchange", () => {
const nativePublic = [
"--public",
"--first-party",
"--auth-method",
"none",
"--redirect-uri",
"http://127.0.0.1/callback",
];

beforeEach(() => {
vi.mocked(createApplication).mockReset();
vi.mocked(updateApplication).mockReset();
});

it("update --session-exchange turns it on", async () => {
vi.mocked(updateApplication).mockResolvedValue(
apiApplication({ sessionExchangeEnabled: true }),
);
await runClients(["update", "client_docs", "--session-exchange"]);
expect(vi.mocked(updateApplication).mock.calls[0][2]).toEqual({
sessionExchangeEnabled: true,
});
});

it("update --no-session-exchange turns it off", async () => {
vi.mocked(updateApplication).mockResolvedValue(
apiApplication({ sessionExchangeEnabled: false }),
);
await runClients(["update", "client_docs", "--no-session-exchange"]);
expect(vi.mocked(updateApplication).mock.calls[0][2]).toEqual({
sessionExchangeEnabled: false,
});
});

it("an update without the flag does not send it", async () => {
vi.mocked(updateApplication).mockResolvedValue(apiApplication());
await runClients(["update", "client_docs", "--name", "Docs"]);
expect(vi.mocked(updateApplication).mock.calls[0][2]).toEqual({ name: "Docs" });
});

it("create --session-exchange sends it for a first-party public native client", async () => {
vi.mocked(createApplication).mockResolvedValue({
application: apiApplication({ sessionExchangeEnabled: true }),
});
await runClients(["create", "CLI", ...nativePublic, "--session-exchange"]);
expect(vi.mocked(createApplication).mock.calls[0][1]).toMatchObject({
clientType: "public",
sessionExchangeEnabled: true,
});
});

it("create refuses it on a client that is not eligible, before asking the instance", async () => {
await expect(
runClients(["create", "Web", "--redirect-uri", "https://a.example.com/cb", "--session-exchange"]),
).rejects.toBeInstanceOf(ExitError);
expect(errorSpy.mock.calls.join()).toContain("sessionExchangeEnabled");
expect(createApplication).not.toHaveBeenCalled();
});

it("shows the setting", async () => {
vi.mocked(getApplication).mockResolvedValue(apiApplication({ sessionExchangeEnabled: true }));
await runClients(["get", "client_docs"]);
const out = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(out).toMatch(/session exchange:\s+on/);
});

it("shows off for an API that does not send it", async () => {
vi.mocked(getApplication).mockResolvedValue(apiApplication());
await runClients(["get", "client_docs"]);
const out = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(out).toMatch(/session exchange:\s+off/);
});

it("is documented in the help", () => {
expect(JSON.stringify(COMMAND_HELP.find((topic) => topic.name === "clients"))).toContain("--session-exchange | --no-session-exchange");
});
});

4 changes: 4 additions & 0 deletions src/commands/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ function readSettings(args: string[]): SettingsFlags {
const notices = takeSwitch("notices", "no-notices");
if (notices !== undefined) settings.noticesEnabled = notices;

const exchange = takeSwitch("session-exchange", "no-session-exchange");
if (exchange !== undefined) settings.sessionExchangeEnabled = exchange;

const pkce = takeSwitch("pkce", "no-pkce");
if (pkce !== undefined) settings.pkceRequired = pkce;

Expand Down Expand Up @@ -255,6 +258,7 @@ function printApplication(app: OidcApplication): void {
const grants = [...app.grantTypes, ...(app.deviceFlowEnabled ? ["device_code"] : [])];
console.log(` grants: ${grants.join(", ") || "none"}`);
console.log(` notices: ${app.noticesEnabled ? "on" : "off"}`);
console.log(` session exchange: ${app.sessionExchangeEnabled ? "on" : "off"}`);
console.log(` PKCE: ${app.pkceRequired ? "required" : kleur.yellow("not required")}`);
console.log(` nonce: ${app.nonceRequired ? "required" : kleur.yellow("not required")}`);
if (app.backchannelLogoutUri) {
Expand Down
Loading
Loading