Skip to content

feat(profile): sign in through the OpenID Provider with loopback and device flows - #349

Merged
Bccorb merged 12 commits into
mainfrom
feat/provider-sign-in
Nov 6, 2026
Merged

Bccorb merged 12 commits into
mainfrom
feat/provider-sign-in

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

seamless profile login signs in through the OpenID Provider, with a loopback redirect or --device, and exchanges the sign-in for a first-party session (ADR 0003, fells-code/seamless-auth-api#544). It brings passkey sign-in to the CLI.

Closes #316

Draft until a types release with fells-code/seamless-auth-types#151 is out. On 0.36.0, 3 tests in src/core/adminOidc.test.ts fail and seamless clients refuses an application from any API without sessionExchangeEnabled. Once released: bump, let the hook regenerate the coverage badge, and add oidc_cli_client_id to WRITABLE_KEYS (an existing test forces this on the types bump). Commits here bypassed the hook because of those 3 tests and the failing-first test commits.

What it does

  • Finding the provider: flags first, then the saved profile, then oidcProvider on GET /system-config/public (feat(system-config): advertise the OpenID Provider on the public config seamless-auth-api#573). Nothing advertised and nothing saved means email code. A profile with a saved issuer and client never asks, so a missing advertisement cannot downgrade it. A known issuer whose discovery fails stops with an error naming --email-code, so whoever blocks discovery cannot choose the weaker sign-in.
  • Browser: PKCE S256 on a random 127.0.0.1 port, state and RFC 9207 iss checked, ID token verified with jose (new dependency). BROWSER overrides the opener.
  • --device: the device grant, with TOTP-only step-up.
  • Exchange: runs at once and is never retried, since a retry is a replay that revokes the session. First-party tokens go in the OS keychain; no provider token is kept.
  • Step-up: a loopback passkey step-up sends max_age=0. With no browser session the first run records a sign-in and not a step-up, so the CLI says to run the command again (ADR 0003, amended). A device session with no TOTP is told to enroll from a browser.
  • Keychain preflight: a real write, read and delete before any network call on every sign-in, so nobody approves a sign-in that cannot be stored.
  • seamless clients: --session-exchange and --no-session-exchange, and the field in the output. Turning it off revokes sessions exchanged through that client, including the one running the command if it signed in through that client.

What was run

The real CLI, the real macOS keychain, and Chromium with a virtual authenticator, against the local recipe:

  • Against the advertisement API (72f857f, with a types tarball): 24 of 24, including no-flags discovery, email code with nothing advertised, and no downgrade for a saved issuer.
  • Against merged #572 (52ff3c3, no such field): 22 passed, advertisement tests skipped, so an older API keeps working.
  • Real SSH to a headless Linux container: with gnome-keyring, --device then whoami, exit 0. With no keychain, exit 1 before any device code or edge and API request.
  • Unit: 1887 passed, 3 failed (above), 28 skipped. Coverage is above the thresholds.

The e2e skips unless SEAMLESS_PROVIDER_E2E_DIR is set, so CI stays green. A run uses most of the 15 minute OTP and TOTP limits.

Not tested

The real system browser (a capture script stood in; openers are unit tested), Windows and Secret Service beyond the one gnome-keyring run, a subject token past 60 seconds, a lost exchange response (unit only), an ordinary organization owner on a device session, the interactive TOTP prompt on clients writes, a malformed advertisement from a real server (unit only). There is no seamless org switch command, so "switch needs no step-up" is untested here, and no command hits /internal. /security-review has not been run on this branch.

Bccorb added 12 commits November 5, 2026 08:11
…device flows

seamless profile login now signs in at the OpenID Provider and exchanges the result for a
first-party session at POST /sessions/exchange, which is the only provider token the CLI
ever holds and which it spends once. --device uses the device grant. A profile with no
provider keeps the email code.

Step-up follows how the session signed in: a browser session can sign in again with a
passkey or use the authenticator app, and a device session uses the app only.

Refs #316
… stack

Skipped unless SEAMLESS_PROVIDER_E2E_DIR names a running provider recipe, so CI stays green
until an API with POST /sessions/exchange is released.

Refs #316
A browser that signed in during the last 300 seconds meets max_age=300, so the hosted page
records no step-up and the retried command is refused. Asks with max_age=0 instead, and says
so plainly when a browser with no session gives a sign-in rather than a step-up.

Refs #316
…e signing in, and set session exchange on a client

The profile reads oidcProvider from GET /system-config/public when neither flags nor the
profile name a provider. A saved issuer never asks, so a later response without the field
cannot downgrade it.

Every sign-in checks the keychain first with a real write, so nobody is sent through an
approval that could not end in a stored session.

seamless clients gains --session-exchange and --no-session-exchange.

Refs #316
An application from an older API now reads as having session exchange off. The patch
schema gains oidc_cli_client_id, which no released API accepts yet, so it stays out of
config set.

Refs #316
A target of // threw in the loopback listener and ended the CLI mid sign-in. An issuer
kept its control characters and could carry a username, which reads as another site once
printed.

Refs #316
…re one over http

Every provider sign-in names its issuer. An advertised issuer on another origin needs a
yes, once, and a run with no terminal needs the flags, because the instance alone decides
where the token is spent. Over plain http anyone on the network can write the
advertisement and a provider saved from it would outlast them, so it is read only over
https or on loopback.

Refs #316
@Bccorb
Bccorb marked this pull request as ready for review November 6, 2026 03:41
@Bccorb
Bccorb merged commit 5e62f4a into main Nov 6, 2026
6 checks passed
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.

feat: sign in through the OpenID Provider with loopback and device flows

1 participant