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:
| Use | When |
|---|---|
| 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/callbackorhttp://[::1]/callback— no port — on a public client. - At
/authorize, sendredirect_uri=http://127.0.0.1:<port>/callbackwith 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 identicalredirect_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.
# 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.
{
"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:
| Answer | What to do |
|---|---|
200 tokens | Done. access_token, id_token (with openid; it carries no nonce), and refresh_token when the client has that grant. |
authorization_pending | The person has not decided. Keep polling at the interval. |
slow_down | You polled too soon. Add 5 seconds to your interval. |
access_denied | The person declined. Stop. |
expired_token | The 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_authorizationshares 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.