Parent
ProJedi1234/stele-pages#25 — Epic: GitHub sign-in replaces the shared bootstrap token. The server side of this (POST /auth/github/exchange) is a separate slice in that repo and must be deployed before this one can be tested end to end.
What to build
stele auth login runs the GitHub device flow (RFC 8628) instead of prompting for a pasted token.
No callback URL, no inbound connection, no localhost redirect. The CLI only ever makes outbound HTTPS requests, which is why this works from an SSH session on a headless machine. The short code the human carries to their browser is the pairing channel a redirect would otherwise provide, and the browser doing the approving need not be on the same machine — or the same network — as the CLI.
Three interactions, all initiated by the CLI:
POST https://github.com/login/device/code with the client ID and Accept: application/json → device_code (secret, held by the CLI), user_code (shown to the human), verification_uri, expires_in, interval.
- Print
enter ABCD-1234 at github.com/login/device and wait.
- Poll
POST https://github.com/login/oauth/access_token with the device_code and grant_type=urn:ietf:params:oauth:grant-type:device_code, sleeping interval seconds between attempts, until GitHub answers with a token.
Then trade that token at the server's exchange endpoint and discard it. The GitHub token is never written to disk; the only thing that reaches the credential file is the minted stele credential.
Structure
A GitHubDeviceFlow type in SteleKit, beside SteleClient rather than inside it. This is the CLI's first HTTP conversation with a host other than the configured stele deployment, and keeping "SteleClient talks only to your stele host" true is worth a separate type. It also gives the tests a clean seam to fake GitHub's responses through the existing transport fakes.
Nothing exotic is needed: Transport.swift is already plain URLSession with the FoundationNetworking import handled for Linux, so this is two form-encoded POSTs, a JSON decode, and try await Task.sleep(for:). No new dependency, no local socket, no browser spawned.
What must not change
The minted credential goes through the existing store-and-report path — the 0600 file, the atomic temp-file-and-rename, the --json shape, and the rule that nothing is written until the server has verified it. --admin semantics are unchanged. Prompts and warnings stay on stderr so --json still emits exactly one document on stdout.
Token-paste survives behind a fallback flag, for older servers and for break-glass.
Polling edge cases
slow_down means add 5 seconds to the interval and keep going, not fail. authorization_pending is the normal answer and must not be reported as an error. expired_token (~15 minutes) and access_denied are terminal and each need a message naming what the person should do next. The loop is bounded by expires_in — it must not be able to spin.
Acceptance criteria
Blocked by
The server's exchange endpoint slice in ProJedi1234/stele-pages — it must be deployed to test against.
Parent
ProJedi1234/stele-pages#25 — Epic: GitHub sign-in replaces the shared bootstrap token. The server side of this (
POST /auth/github/exchange) is a separate slice in that repo and must be deployed before this one can be tested end to end.What to build
stele auth loginruns the GitHub device flow (RFC 8628) instead of prompting for a pasted token.No callback URL, no inbound connection, no localhost redirect. The CLI only ever makes outbound HTTPS requests, which is why this works from an SSH session on a headless machine. The short code the human carries to their browser is the pairing channel a redirect would otherwise provide, and the browser doing the approving need not be on the same machine — or the same network — as the CLI.
Three interactions, all initiated by the CLI:
POST https://github.com/login/device/codewith the client ID andAccept: application/json→device_code(secret, held by the CLI),user_code(shown to the human),verification_uri,expires_in,interval.enter ABCD-1234 at github.com/login/deviceand wait.POST https://github.com/login/oauth/access_tokenwith thedevice_codeandgrant_type=urn:ietf:params:oauth:grant-type:device_code, sleepingintervalseconds between attempts, until GitHub answers with a token.Then trade that token at the server's exchange endpoint and discard it. The GitHub token is never written to disk; the only thing that reaches the credential file is the minted stele credential.
Structure
A
GitHubDeviceFlowtype inSteleKit, besideSteleClientrather than inside it. This is the CLI's first HTTP conversation with a host other than the configured stele deployment, and keeping "SteleClienttalks only to your stele host" true is worth a separate type. It also gives the tests a clean seam to fake GitHub's responses through the existing transport fakes.Nothing exotic is needed:
Transport.swiftis already plainURLSessionwith theFoundationNetworkingimport handled for Linux, so this is two form-encoded POSTs, a JSON decode, andtry await Task.sleep(for:). No new dependency, no local socket, no browser spawned.What must not change
The minted credential goes through the existing store-and-report path — the 0600 file, the atomic temp-file-and-rename, the
--jsonshape, and the rule that nothing is written until the server has verified it.--adminsemantics are unchanged. Prompts and warnings stay on stderr so--jsonstill emits exactly one document on stdout.Token-paste survives behind a fallback flag, for older servers and for break-glass.
Polling edge cases
slow_downmeans add 5 seconds to the interval and keep going, not fail.authorization_pendingis the normal answer and must not be reported as an error.expired_token(~15 minutes) andaccess_deniedare terminal and each need a message naming what the person should do next. The loop is bounded byexpires_in— it must not be able to spin.Acceptance criteria
stele auth login; user code and verification URI printed to stderrinterval,slow_down,authorization_pending,expired_tokenandaccess_denied; bounded byexpires_inand cannot spin--adminsemantics unchanged--jsonstill emits one document on stdout, with all prompting on stderrswift testneeds no networkBlocked by
The server's exchange endpoint slice in
ProJedi1234/stele-pages— it must be deployed to test against.