Private betaRequest early access →

Sign in

Signing a person in from a CLI or headless session

A command-line tool, an IDE plugin or a coding agent often has to sign a person in. Zynth Auth (Perihelium's authentication layer) supports the two standard shapes, both with PKCE and both ending in the same tokens an ordinary browser login produces:

UseWhen
Loopback redirect (RFC 8252 §7.3)The tool runs on a machine with a browser. It listens on a loopback port, opens the browser, and receives the code.
Device grant (RFC 8628)No browser can reach the tool — an SSH session, a container, a remote dev box. The person approves on another device.

Both need a public client (no secret; PKCE is the proof). Register it under Organization → OAuth Clients or with the OAuth clients API.

Not the same as agent onboarding. Agent-driven onboarding enrols an agent and ends in an agent credential. The flows on this page sign a human in to your application and end in an OAuth grant. They use different codes, different screens and different stores.

Loopback redirect on any port

A CLI cannot know which port is free until it starts, so it cannot register a fixed redirect URI. RFC 8252 §7.3 lets a native app register a loopback address without a port and present any port at run time. Zynth Auth supports exactly that:

  • Register a redirect URI of the form http://127.0.0.1/callback or http://[::1]/callback — no port — on a public client.
  • At /authorize, send redirect_uri=http://127.0.0.1:<port>/callback with whatever port you bound. It is accepted when it equals the registered string with :<port> inserted after the host.
  • The scheme, the address, the path and the query are still compared exactly. Userinfo, a fragment, a leading-zero port (:080) and a port outside 1–65535 are refused.
  • At /token, send the identical redirect_uri, port included. The code is bound to what you presented at /authorize.

What does not get the rule: a confidential client (it is a server with a fixed address, so it keeps exact matching), localhost (a name can be re-pointed; use 127.0.0.1 or [::1]), and any registration that names a port — that matches only itself.

Shell
# 1. Open the browser at (port 53127 was just bound by the CLI):
#    https://<issuer>/api/v1/oauth/authorize?response_type=code&client_id=<id>
#      &redirect_uri=http%3A%2F%2F127.0.0.1%3A53127%2Fcallback&scope=openid%20email
#      &code_challenge=<S256>&code_challenge_method=S256&state=<state>

# 2. The browser returns to http://127.0.0.1:53127/callback?code=…&state=…; then:
curl -s https://<issuer>/api/v1/oauth/token \
  -d grant_type=authorization_code -d client_id=<id> -d code=<code> \
  -d redirect_uri=http://127.0.0.1:53127/callback -d code_verifier=<verifier>

Because the port carries no authority, another local process listening on it could receive the code. PKCE is what stops it: that process holds the code but not your verifier.

Device authorization grant

For a session where no browser can reach the tool, the device grant has the person approve on a device that has one.

Register the client with the grant type urn:ietf:params:oauth:grant-type:device_code (add refresh_token for long-lived sessions) and the scopes it may request. A device-grant client needs no redirect URI.

1. Ask for a code. POST /api/v1/oauth/device_authorization, form-encoded: client_id (and client_secret for a confidential client) and an optional scope.

JSON
{
  "device_code": "zdc_…",
  "user_code": "BCDF-GHJK",
  "verification_uri": "https://<console>/oauth/device",
  "verification_uri_complete": "https://<console>/oauth/device?user_code=BCDF-GHJK",
  "expires_in": 600,
  "interval": 5
}

2. Show the person verification_uri and user_code (or verification_uri_complete, which only pre-fills the box). The device_code stays with your tool and is never shown.

3. Poll POST /api/v1/oauth/token every interval seconds with grant_type=urn:ietf:params:oauth:grant-type:device_code, device_code and client_id. Each answer is one of:

AnswerWhat to do
200 tokensDone. access_token, id_token (with openid; it carries no nonce), and refresh_token when the client has that grant.
authorization_pendingThe person has not decided. Keep polling at the interval.
slow_downYou polled too soon. Add 5 seconds to your interval.
access_deniedThe person declined. Stop.
expired_tokenThe code ran out (600 seconds). Start again from step 1.

A code is redeemed once, and only by the client it was issued to.

On the verification page the person signs in (if they are not already), types the code, sees what is asking — the application's name, its organization, the scopes and how long ago the request began — and chooses Approve or Decline. Case, dashes and spaces in the code do not matter. Only a person can use this page: an agent credential or an API key is refused, even one that holds identity:self:write, and the attempt is recorded as a security signal. Approving also records the consent, so the application appears under Account → Connected apps and disappears when the person disconnects it.

Why the page asks the person to be sure

The device grant has one real weakness: someone can start a flow and persuade a victim to type their code. So the page never approves by itself — not even for a pre-filled code — says to continue only if you started this on a device you are using right now, and codes are single-use and expire after 10 minutes. Build your tool's prompt the same way: show the code only to the person at the keyboard.

Limits

  • A person who types codes that match no live request is limited to 5 per 15 minutes (and 20 per IP); past that the page answers 429. A correct code never spends any of it.
  • /device_authorization shares the token endpoint's per-IP floor, so alternating endpoints does not buy a second budget.
  • The organization the person is signed in to must admit the application, exactly as for a browser login; a platform application also needs that organization's installation (see multiple organizations).

Tokens from either flow can be sender-constrained with DPoP by sending a proof with the token request. To end a session from the tool, call the revocation endpoint.