From 7563d0fc8ff5ec84be7057caec2e768081f17b54 Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:05:01 -0400 Subject: [PATCH 1/2] feat: sign in with GitHub instead of pasting a token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stele auth login` now asks the server to start a GitHub device flow, prints the code and the URL for a person to open, and waits. What it stores is the publish-only credential the server minted at the end of it. The flow is proxied end to end: the OAuth app's client ID lives on the server and the GitHub access token is consumed inside one server request, so this machine holds neither. A login that pasted a GitHub token would have taken a stele secret out of the agent's reach by putting a GitHub one on the same disk. The pasted-token path stays, and is not a legacy branch. `--admin` takes it, because a sign-in mints `publish` and only `publish` and can never produce the credential that flag asks to keep; and a deployment that has not configured GitHub sign-in refuses the start route, which is also how the first credential on a fresh deployment is made. Without the fallback `auth login` would have no answer at all on those deployments. No new flags and no new exit codes. A terminal refusal is the server's one byte-identical 401 — cancelled, not an owner, no allowlist — so it becomes `.refused` rather than an error with nothing to say, and a 500 still throws: an outage must not read as a person saying no. The poll loop lives in SteleKit with an injected clock, because "does slow_down actually slow the polling down" and "does this give up when the code expires" are the two questions the feature has and both are about seconds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LBzRbsSw9JzfEg1p3a3FVo --- README.md | 52 ++- Sources/SteleKit/DeviceFlow.swift | 181 ++++++++++ Sources/SteleKit/SteleClient.swift | 119 ++++++- Sources/SteleKit/Version.swift | 2 +- Sources/stele/AuthCommands.swift | 260 ++++++++++++-- Tests/SteleKitTests/DeviceFlowTests.swift | 400 ++++++++++++++++++++++ 6 files changed, 973 insertions(+), 41 deletions(-) create mode 100644 Sources/SteleKit/DeviceFlow.swift create mode 100644 Tests/SteleKitTests/DeviceFlowTests.swift diff --git a/README.md b/README.md index e5753a8..7bf4a69 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,13 @@ sees it. ``` $ stele auth login host (e.g. https://stele.example.com): https://stele.example.com -token for https://stele.example.com: -authenticated as claude-code on https://stele.example.com — scopes: publish +sign in to https://stele.example.com with GitHub. + open https://github.com/login/device + enter the code WDJB-MJHT +waiting for GitHub — this window can be left open. Ctrl-C to stop. +authenticated as projedi1234 on https://stele.example.com — scopes: publish stored 0600 in ~/.config/stele/credentials.json +no GitHub token ever reached this machine. $ stele publish report.html https://stele.example.com/quiet-cedar-otter @@ -64,7 +68,7 @@ beneath the deadline. `--json` names both and calls neither of them `url`. | Command | Who runs it | Does | | --- | --- | --- | -| `stele auth login [--host ] [--admin]` | human, once | Prompts for the token on a TTY, verifies it, writes the credential `0600`. An operator token is *spent*, not stored — see below. | +| `stele auth login [--host ] [--admin]` | human, once | Signs in with GitHub — prints a code and a URL to open, waits, writes the credential it is minted `0600`. Falls back to a token read from a TTY where the deployment has no GitHub sign-in; an operator token is *spent*, not stored — see below. | | `stele auth status` | agent or human | Host, client name, scopes, expiry, and the GitHub login behind the credential when there is one — never the token. | | `stele auth logout` | human | Forgets the local credential. Does not revoke it. | | `stele publish [--slug ] [--ttl ]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. | @@ -84,12 +88,46 @@ a machine contract. ## Getting a credential onto a machine +`stele auth login` signs in with GitHub. The server starts a device flow; this prints a short +code and a URL, and a person opens that URL wherever they like. Nothing is launched for them, +deliberately — a CLI that shells out to a browser opens nothing over SSH, nothing in a +container, and on a shared machine it opens a page on somebody else's display. What comes back +is a publish-only credential the server minted, verified and written `0600`: + +``` +$ stele auth login +sign in to https://stele.example.com with GitHub. + open https://github.com/login/device + enter the code WDJB-MJHT +waiting for GitHub — this window can be left open. Ctrl-C to stop. +authenticated as projedi1234 on https://stele.example.com — scopes: publish +stored 0600 in ~/.config/stele/credentials.json +no GitHub token ever reached this machine. +``` + +The flow is proxied end to end, and that is the whole point of it. The OAuth app's client ID +lives on the server, and the GitHub access token is born and dies inside one server request — +this machine holds neither. A login that pastes a GitHub token would have removed a *stele* +secret from the agent's reach by putting a *GitHub* one on the same disk, which is a worse +credential to lose. + +Signing in again is the recovery for a lost or forgotten credential. The server retires whatever +live credential holds that login's name and mints a replacement, which is the only recovery +there can be: it keeps a SHA-256 and cannot reissue a token it never stored. + +Two things fall back to a token typed at the terminal. `--admin` skips the sign-in outright, +because what a sign-in mints carries `publish` and only `publish` — it can never produce the +operator credential that flag asks for. And a deployment that has not configured GitHub sign-in +refuses the start route, which is also how the very first credential on a fresh deployment gets +made: the bootstrap token is in the server's environment and nobody has signed in yet. + An `admin` token is not a credential to keep. It is the one thing that can mint credentials, and on this server it cannot even publish — `admin` and `publish` are disjoint, so a machine holding the operator token can revoke every credential on the deployment and cannot upload a page. -So `stele auth login` spends it. Paste an operator token and it mints a publish-only credential -named after the machine, stores *that*, and never writes what you pasted to disk: +So on that path `stele auth login` spends it. Paste an operator token and it mints a +publish-only credential named after the machine, stores *that*, and never writes what you pasted +to disk: ``` $ stele auth login @@ -130,8 +168,8 @@ sitting at, or to mint a credential with a non-default scope or lifetime. What c it is no longer the only way — and the common case, a machine credentialling itself, no longer routes a shown-once secret through a second terminal. -One trade this makes deliberately: the operator token now gets typed at a TTY on the machine -being provisioned, rather than never leaving the workstation. It is read with echo off, used for +One trade the pasted path makes deliberately: the operator token gets typed at a TTY on the +machine being provisioned, rather than never leaving the workstation. It is read with echo off, used for one request and never written down — but it is a real exposure, and `admin clients create` remains available for anyone who would rather carry the minted token instead. diff --git a/Sources/SteleKit/DeviceFlow.swift b/Sources/SteleKit/DeviceFlow.swift new file mode 100644 index 0000000..4eeaae7 --- /dev/null +++ b/Sources/SteleKit/DeviceFlow.swift @@ -0,0 +1,181 @@ +import Foundation + +/// What the server hands back when a device sign-in is started: the two strings a human needs, +/// and the three machine values the poll loop runs on. +/// +/// The GitHub OAuth app's client ID is not in here and never travels to this machine. The +/// server holds it, calls GitHub with it, and passes only this much through — which is the +/// whole reason the flow is proxied rather than run from the CLI. The access token GitHub +/// eventually issues is born and dies inside one server request; nothing in this process ever +/// holds it. +/// +/// `verificationURI` is GitHub's, spelled the way the server spells it on the wire. It is +/// printed for a person to open by hand: see `DeviceSignIn` for why nothing here launches a +/// browser. +public struct DeviceCodeBundle: Decodable, Sendable, Equatable { + /// The short code the user types into GitHub's page. Meant to be read aloud off a terminal, + /// which is why it is the one value here that is deliberately shown. + public let userCode: String + /// Where they type it. + public let verificationURI: String + /// The handle this client polls with, and the one value in here worth withholding — see + /// `description`. + public let deviceCode: String + /// Seconds GitHub asks to be left alone between polls. + public let interval: Int + /// Seconds until the user code stops being redeemable. + public let expiresIn: Int + + public init( + userCode: String, + verificationURI: String, + deviceCode: String, + interval: Int, + expiresIn: Int + ) { + self.userCode = userCode + self.verificationURI = verificationURI + self.deviceCode = deviceCode + self.interval = interval + self.expiresIn = expiresIn + } +} + +extension DeviceCodeBundle: CustomStringConvertible { + /// Everything except the device code, which is redacted for `Token`'s reason in a smaller + /// key: for the fifteen minutes it lives, anyone holding it can complete this sign-in and + /// walk away with the credential it mints. It is not a stele token, so `Redaction` does not + /// recognise it and would not scrub it out of a message this value was interpolated into — + /// so the type declines to render it at all, and the poll loop passes it by hand. + public var description: String { + """ + DeviceCodeBundle(userCode: \(userCode), verificationURI: \(verificationURI), \ + deviceCode: \(Token.redaction), interval: \(interval), expiresIn: \(expiresIn)) + """ + } +} + +/// One answer to one poll of the exchange route. +/// +/// Three cases because the server collapses everything else into them: every terminal refusal — +/// the code expired, the user cancelled, the account is not an owner of this deployment, the +/// deployment has no owners — arrives as one byte-identical `401` and there is nothing left in +/// it to branch on. A `500` is not in here at all: that means the server could not reach GitHub, +/// which is a `SteleError` this client already knows how to describe, and it is thrown rather +/// than returned so a poll loop cannot mistake an outage for a refusal. +public enum DevicePoll: Sendable, Equatable { + /// Nobody has authorised the code yet. Carries the interval the server asked for when it + /// named one — GitHub's `slow_down` is a bigger number here and not a different case. + case pending(interval: Int?) + /// Authorised. The credential exists on the server and this is its only plaintext. + case minted(MintedClient) + /// Terminal, and this client is not told which of the several reasons it was. + case refused +} + +extension DevicePoll { + public static func == (lhs: DevicePoll, rhs: DevicePoll) -> Bool { + switch (lhs, rhs) { + case (.pending(let a), .pending(let b)): return a == b + case (.refused, .refused): return true + // Compared by the name rather than by the credential: `MintedToken` is not `Equatable` + // and should not become so — a `==` over a secret is a comparison somebody will reach + // for as a check, and the answer to "is this the token I think it is" is a request to + // the server. + case (.minted(let a), .minted(let b)): return a.client == b.client + default: return false + } + } +} + +/// Waits for a person to approve a device sign-in, and hands back what it minted. +/// +/// The loop is here rather than in the command for the reason every other decision is in +/// SteleKit: the executable target has no tests, and "does a `slow_down` actually slow the +/// polling down" is not a question worth answering by watching a terminal. `now` and `sleep` +/// are injected for the same reason `CredentialStore` takes `home` — a driver that read the +/// real clock could only be tested in real seconds. +/// +/// Nothing in here opens a browser, and that is a decision rather than an omission. This +/// command runs on machines an agent is sitting on, over SSH, and inside containers, where +/// `xdg-open` either fails or — worse — opens the page on somebody else's display. The URL is +/// printed and a human decides where to open it. +public struct DeviceSignIn: Sendable { + /// How a sign-in ended. Refusal and expiry are outcomes rather than errors because they are + /// things the *user* did — cancelled, or walked away — and the command has a sentence for + /// each. What is thrown out of `complete` is the other kind of failure: the server was + /// unreachable, or answered something this client could not read. + public enum Outcome: Sendable { + case minted(MintedClient) + /// The server said no, and said no more than that. + case refused + /// The code stopped being redeemable before anyone approved it. + case expired + } + + /// The shortest and longest a poll interval may be, whatever the server asks for. + /// + /// The floor keeps a server that answers `0` from becoming a hot loop against GitHub's rate + /// limiter; the ceiling keeps one that answers a day from parking the command until the + /// deadline. Both are guards against an answer this side cannot validate, not policy — the + /// interval GitHub asks for is five seconds and every reasonable value passes through + /// untouched. + static let minimumInterval = 1 + static let maximumInterval = 60 + /// The longest this will wait for a person, whatever `expiresIn` claimed. GitHub's device + /// codes live fifteen minutes; an hour is generous and finite. + static let maximumLifetime: TimeInterval = 3600 + + private let client: SteleClient + private let now: @Sendable () -> Date + private let sleep: @Sendable (TimeInterval) async throws -> Void + + public init( + client: SteleClient, + now: @escaping @Sendable () -> Date = { Date() }, + sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { + try await Task.sleep(nanoseconds: UInt64(max(0, $0) * 1_000_000_000)) + } + ) { + self.client = client + self.now = now + self.sleep = sleep + } + + /// Polls until the sign-in is approved, refused, or out of time. + /// + /// Sleeps *before* the first poll, deliberately: the code has just been printed and nobody + /// has had time to type it, so an immediate request can only be answered `pending` and + /// spends one of GitHub's polls to learn nothing. + public func complete(_ bundle: DeviceCodeBundle) async throws -> Outcome { + let deadline = now().addingTimeInterval( + min(Self.maximumLifetime, max(0, TimeInterval(bundle.expiresIn))) + ) + var interval = Self.bounded(bundle.interval) + + while now() < deadline { + // Never sleeps past the deadline, however large an interval was asked for. Without + // this the expiry check above is advisory: one `interval` of 86400 would suspend + // here until long after the code was dead. + let remaining = deadline.timeIntervalSince(now()) + try await sleep(min(TimeInterval(interval), max(0, remaining))) + + switch try await client.redeemDeviceCode(bundle.deviceCode) { + case .minted(let minted): + return .minted(minted) + case .refused: + return .refused + case .pending(let asked): + // Only ever upward. `slow_down` is GitHub asking for more room and arrives as a + // larger number; a smaller one is not an invitation to poll harder than the + // interval the flow was started with. + if let asked, Self.bounded(asked) > interval { interval = Self.bounded(asked) } + } + } + return .expired + } + + static func bounded(_ seconds: Int) -> Int { + min(maximumInterval, max(minimumInterval, seconds)) + } +} diff --git a/Sources/SteleKit/SteleClient.swift b/Sources/SteleKit/SteleClient.swift index d583463..49ff8ac 100644 --- a/Sources/SteleKit/SteleClient.swift +++ b/Sources/SteleKit/SteleClient.swift @@ -61,6 +61,14 @@ public struct SteleClient: Sendable { public static let whoami = "/admin/whoami" public static let clients = "/admin/clients" + /// Starts a GitHub device sign-in. Unauthenticated, like the route below it: the caller + /// is here precisely because it holds no credential yet. + public static let authDevice = "/auth/github/device" + /// Redeems the device code the route above issued. The same path the server used to + /// take a raw GitHub access token on — that body is gone, and this client never sends + /// one: a token pasted at a prompt is a token this machine has held. + public static let authExchange = "/auth/github/exchange" + /// Where an attachment's bytes are served, with nothing rendered around them. /// /// The one path here that this client never sends a request to. It is a URL this tool @@ -370,6 +378,89 @@ public struct SteleClient: Sendable { return markdown } + // MARK: - Signing in + + /// `POST /auth/github/device` — begin a GitHub sign-in, and get back what to show a human. + /// + /// The whole flow is proxied. This client never learns the OAuth app's client ID and never + /// touches the GitHub access token the flow eventually produces: the server holds the one + /// and consumes the other inside a single request, and what comes back here is a stele + /// credential or nothing. That is the same custody argument the credential file rests on, + /// moved one step earlier — a login that pasted a GitHub token would have put a *GitHub* + /// secret on the machine to avoid putting a stele one there. + /// + /// A `401` here is the deployment declining rather than refusing the caller: it is the + /// byte-identical answer an unconfigured `STELE_GITHUB_CLIENT_ID` gives, and it is + /// indistinguishable — deliberately, on the server's side — from an empty owner allowlist. + /// So it surfaces as `SteleError.unauthorized` and the caller decides what to do about a + /// deployment that does not offer this. `auth login` falls back to a pasted token. + public func startDeviceSignIn() async throws -> DeviceCodeBundle { + let response = try await perform( + SteleRequest( + method: "POST", + // No body and no `Content-Type`. The route reads neither, and an empty JSON + // object would be this client inventing a shape for the server to ignore. + url: try url(path: Path.authDevice, on: host), + credential: nil, + timeout: timeout + ), + host: host, + expectation: .any + ) + return try Self.decode(DeviceCodeBundle.self, from: response.body) + } + + /// `POST /auth/github/exchange` — one poll of a device sign-in. + /// + /// The one method here whose *status* is the answer rather than a pass-or-fail, which is why + /// it calls `perform` directly the way `delete` and `fetchSkill` do. A `202` means nobody has + /// approved the code yet and is a perfectly good outcome; routed through `send` it would be + /// handed to a decoder expecting a minted credential and reported as the server answering + /// strangely. + /// + /// A `401` becomes `.refused` rather than an error, and that is the shape of the server's + /// promise rather than a swallowed failure: expired, cancelled, not an owner and no + /// allowlist are one byte-identical refusal, so there is nothing to describe and nothing to + /// branch on. Everything else — a `500` because the server could not reach GitHub, a + /// transport failure, a body this client cannot read — throws, so a poll loop can never + /// mistake an outage for a person saying no. + public func redeemDeviceCode(_ deviceCode: String) async throws -> DevicePoll { + let response: SteleResponse + do { + response = try await perform( + SteleRequest( + method: "POST", + url: try url(path: Path.authExchange, on: host), + contentType: "application/json", + body: try JSONEncoder().encode(RedeemDeviceCodeRequest(deviceCode: deviceCode)), + credential: nil, + timeout: timeout + ), + host: host, + expectation: .any + ) + } catch let error as SteleError { + guard case .unauthorized = error else { throw error } + return .refused + } + + guard response.status != 202 else { + return .pending(interval: Self.pendingInterval(from: response.body)) + } + return .minted(try Self.decode(MintedClient.self, from: response.body)) + } + + /// The interval a `202` asked for, or nil when it named none. + /// + /// Read leniently and on purpose. The interval is advice — the flow already has one from the + /// start response, and `DeviceSignIn` only ever raises it — so a `202` whose body this + /// client cannot read is not worth failing a sign-in over that is otherwise going fine. Nil + /// means "keep polling as before", which is what an older or newer server that spells this + /// differently should get. + static func pendingInterval(from body: Data) -> Int? { + try? JSONDecoder.stele.decode(PendingResponse.self, from: body).interval + } + // MARK: - The credential itself /// `GET /admin/whoami` — who this credential says we are, according to the server. @@ -488,6 +579,19 @@ public struct SteleClient: Sendable { let expiresIn: Int? } + /// The JSON body a poll sends. One field, spelled the server's way — and spelled in *its* + /// vocabulary rather than GitHub's `device_code`, for the reason the server gives about its + /// own body: this is stele's contract with its client, and borrowing the upstream spelling + /// is the start of a wire format that pretends to be a proxy for somebody else's. + private struct RedeemDeviceCodeRequest: Encodable { + let deviceCode: String + } + + /// What a `202` carries. Optional even here: see `pendingInterval`. + private struct PendingResponse: Decodable { + let interval: Int? + } + /// `GET /admin/clients` answers with an object, not a bare array: a JSON array cannot grow a /// sibling field, so the envelope is what lets a cursor or a total appear later without /// breaking every parser. Unwrapped here so callers see the list. @@ -519,12 +623,19 @@ public struct SteleClient: Sendable { host: target, expectation: expectation ) + return try Self.decode(T.self, from: response.body) + } + + /// Decodes a response body, or reports that the server answered in a shape this client does + /// not know. + /// + /// The decoding error itself, not the body: a body that failed to decode is a body this + /// client did not understand, and echoing an unknown server's bytes into an error message is + /// how something unexpected ends up in a transcript. + static func decode(_ type: T.Type, from body: Data) throws -> T { do { - return try JSONDecoder.stele.decode(T.self, from: response.body) + return try JSONDecoder.stele.decode(T.self, from: body) } catch { - // The decoding error itself, not the body: a body that failed to decode is a body - // this client did not understand, and echoing an unknown server's bytes into an - // error message is how something unexpected ends up in a transcript. throw SteleError.malformedResponse("\(error)") } } diff --git a/Sources/SteleKit/Version.swift b/Sources/SteleKit/Version.swift index 320797d..c75d6a4 100644 --- a/Sources/SteleKit/Version.swift +++ b/Sources/SteleKit/Version.swift @@ -8,7 +8,7 @@ public enum SteleVersion { /// Bumped by hand. Nothing derives it from git: a release built from a tarball or an /// unclean tree must still report a version the server can compare. - public static let current = "0.4.0" + public static let current = "0.5.0" /// Sent on every request. The `stele-cli/` product token is what the server matches on. public static var userAgent: String { "stele-cli/\(current)" } diff --git a/Sources/stele/AuthCommands.swift b/Sources/stele/AuthCommands.swift index c6229b5..97ca183 100644 --- a/Sources/stele/AuthCommands.swift +++ b/Sources/stele/AuthCommands.swift @@ -7,9 +7,10 @@ struct AuthCommand: AsyncParsableCommand { commandName: "auth", abstract: "Manage the stored credential.", discussion: """ - `login` is the one command in this tool meant for a human: it reads the token from \ - a terminal, checks it against the server and writes it 0600. Nothing else here, or \ - anywhere else in the tree, will show you the token again. + `login` is the one command in this tool meant for a human: it signs in with GitHub \ + in a browser the user opens themselves, checks what the server minted and writes it \ + 0600. Nothing else here, or anywhere else in the tree, will show you the token \ + again. """, subcommands: [LoginCommand.self, StatusCommand.self, LogoutCommand.self], defaultSubcommand: StatusCommand.self @@ -20,10 +21,26 @@ struct AuthCommand: AsyncParsableCommand { /// Records a credential for a deployment, after checking the server agrees it is one. /// +/// The ordinary way in is a GitHub sign-in: this asks the server to start a device flow, prints +/// the code and the URL for a person to open, and waits. The credential it walks away with is +/// minted by the server and is publish-only. Nothing secret is typed at this terminal on that +/// path — no stele token, and no GitHub token either. The OAuth app's client ID lives on the +/// server and the access token GitHub issues is consumed inside one server request, so the only +/// credential this machine ever holds is the one it is entitled to hold. +/// +/// The pasted-token path is still here, and is not a legacy branch. It is what `--admin` uses, +/// because a sign-in mints `publish` and can never produce the operator credential that flag +/// asks for; and it is the fallback for a deployment that has not configured GitHub sign-in, +/// which answers the start route with a refusal. Without it, `stele auth login` would have no +/// answer at all on a server whose owners have not adopted the flow — including the first login +/// against a fresh one, which is how the bootstrap token gets spent. +/// /// Verifying before writing is what stops a typo becoming a puzzle. A token stored unverified /// looks perfectly healthy in the file and fails at the next `stele publish`, which is a 401 an /// agent then reports to the user hours later; verifying here turns that into an error the -/// person who typed it is still standing in front of. +/// person who typed it is still standing in front of. It is also what the sign-in path does +/// with what it was handed, for a second reason: a credential minted a moment ago and refused +/// by `whoami` is one worth finding out about before it reaches the file. /// /// Verifying is also what makes the second rule possible: an `admin` token is not stored, it is /// *spent*. The server's answer says which kind of credential was pasted, and an operator's one @@ -32,16 +49,24 @@ struct AuthCommand: AsyncParsableCommand { struct LoginCommand: SteleCommand { static let configuration = CommandConfiguration( commandName: "login", - abstract: "Store a credential for a stele deployment. Prompts on a terminal.", + abstract: "Sign in to a stele deployment and store the credential it mints.", discussion: """ - The token is read from the terminal with echo off. It is never an argument and \ - never an environment variable: argv shows up in `ps` and in shell history, and \ - shell history is something an agent reads. - - Paste an operator token and this mints a publish-only credential for this machine \ - and stores that instead — the admin token is used for one request and never \ - written to disk. Pass --admin to keep it, which is what the workstation you run \ - `stele admin clients` from wants. + Signs in with GitHub: the server starts a device flow and this prints a code and a \ + URL to open. Nothing is opened for you — the URL is printed and you decide where to \ + open it, which is the only thing that works over SSH and in a container. The \ + credential the server mints carries `publish`, and no GitHub token ever reaches \ + this machine. + + A deployment that has not configured GitHub sign-in falls back to a token read from \ + the terminal with echo off. It is never an argument and never an environment \ + variable: argv shows up in `ps` and in shell history, and shell history is \ + something an agent reads. + + On that path, paste an operator token and this mints a publish-only credential for \ + this machine and stores that instead — the admin token is used for one request and \ + never written to disk. Pass --admin to keep it, which is what the workstation you \ + run `stele admin clients` from wants; that flag skips the sign-in, since a sign-in \ + only ever mints `publish`. If stdin is not a TTY this command fails rather than reading it. Agents should ask \ the user to run this themselves. @@ -64,6 +89,10 @@ struct LoginCommand: SteleCommand { For the machine you administer the deployment from. A credential file holds one \ credential per host, so this machine will hold `admin` instead of `publish` and \ `stele publish` will stop working on it — the two scopes are disjoint. + + Skips the GitHub sign-in and prompts for a token, because signing in mints \ + `publish` and only `publish`: there is no sign-in that ends in the credential \ + this flag asks to keep. """ ) ) @@ -72,7 +101,117 @@ struct LoginCommand: SteleCommand { func execute() async throws { var credentials = try options.store.load() let host = try resolveHost(existing: credentials) + let client = SteleClient(host: host) + + // `--admin` asks for an operator credential, and a sign-in cannot produce one: what the + // exchange mints carries `publish` and only `publish`. Starting a flow whose answer is + // the wrong scope would waste a person's trip to a browser to arrive somewhere the flag + // already said was wrong, so it goes straight to the prompt. + if !admin, let bundle = try await beginSignIn(on: host, client: client) { + try await completeSignIn(bundle, on: host, client: client, into: &credentials) + return + } + + try await loginWithAPastedToken(on: host, client: client, into: &credentials) + } + + /// Asks the server to start a GitHub sign-in and tells the user what to do with it. + /// + /// Nil means this deployment does not offer one — the caller falls back to a pasted token. + /// A refusal is how that arrives and it is not a mistake in the reading: the server answers + /// an unconfigured client ID with the same bytes it refuses a sign-in with, deliberately, so + /// that a prober cannot tell a deployment with no OAuth app from one whose allowlist they + /// are not on. This side is not a prober and can simply try the other door. + private func beginSignIn(on host: SteleHost, client: SteleClient) async throws + -> DeviceCodeBundle? + { + let bundle: DeviceCodeBundle + do { + bundle = try await client.startDeviceSignIn() + } catch let error as SteleError { + guard case .unauthorized = error else { throw error } + // Said before the prompt appears, because a person who ran this expecting a browser + // and got a token prompt deserves to know which of the two happened. Hedged on + // purpose: all this side knows is that the deployment declined, and the server does + // not say why — the caller here is a human at their own terminal rather than a + // prober, but the answer is the same bytes either way. + Terminal.error( + options.style.dim( + "\(host) did not offer a GitHub sign-in — asking for a token instead." + ) + ) + return nil + } + + // stderr, like every other prompt: `stele auth login --json` still emits exactly one + // document on stdout, and this is the question rather than the answer. + let style = options.style + Terminal.error("sign in to \(style.accent(host.value)) with GitHub.") + Terminal.error(" open \(style.accent(bundle.verificationURI))") + Terminal.error(" enter the code \(style.bold(bundle.userCode))") + Terminal.error( + style.dim("waiting for GitHub — this window can be left open. Ctrl-C to stop.") + ) + return bundle + } + + /// Waits for the sign-in to be approved, then verifies and files what it minted. + /// + /// The credential arrives already minted, so from `verifyCredential` onwards this is the + /// same path a pasted token takes — the same `whoami` check and the same one line that + /// writes to disk. `LoginDecision` is deliberately not consulted: its whole subject is which + /// kind of credential was *pasted*, and there is nothing to decide about one the server just + /// minted to be publish-only. + private func completeSignIn( + _ bundle: DeviceCodeBundle, + on host: SteleHost, + client: SteleClient, + into credentials: inout Credentials + ) async throws { + switch try await DeviceSignIn(client: client).complete(bundle) { + case .refused: + // One sentence for several facts, because one refusal is all the server said. It + // covers a cancelled authorisation, an account that is not an owner here, and a + // deployment with no owners configured — and it names the only move that is open + // either way rather than guessing which of them happened. + throw Failure( + "sign-in failed or was cancelled — run `stele auth login` to try again." + ) + case .expired: + // Split from the refusal because it is the one outcome that is nobody's decision: + // the code simply timed out, usually because the browser was never opened. + throw Failure( + "the code expired before it was approved — run `stele auth login` to try again." + ) + case .minted(let minted): + let token: Token + do { + token = try minted.token.inCustody() + } catch { + throw Self.signInLost( + minted.client.name, + on: host, + "the token it sent back is not one a credential can hold." + ) + } + let credential = Credential(host: host, clientName: minted.client.name, token: token) + let summary: ClientSummary + do { + summary = try await client.verifyCredential(credential) + } catch { + throw Self.signInLost(minted.client.name, on: host, "\(error)") + } + try store(token, as: summary, on: host, origin: .signedIn, into: &credentials) + } + } + /// The original path: a token typed at the terminal, verified, and then either stored or + /// spent on minting the credential that is stored instead. + private func loginWithAPastedToken( + on host: SteleHost, + client: SteleClient, + into credentials: inout Credentials + ) async throws { let raw = try Prompt().secret("token for \(host): ") // `Token` refuses an empty or header-unsafe value here, before anything is sent and // long before anything is written. @@ -81,7 +220,6 @@ struct LoginCommand: SteleCommand { // The name is not known until the server answers; this placeholder exists for exactly // one HTTP request and is replaced before anything reaches the disk. let provisional = Credential(host: host, clientName: "", token: token) - let client = SteleClient(host: host) let summary = try await client.verifyCredential(provisional) // The hostname is read here rather than inside `LoginDecision`, for the reason @@ -95,11 +233,11 @@ struct LoginCommand: SteleCommand { switch decision { case .store: - try store(token, as: summary, on: host, minted: false, into: &credentials) + try store(token, as: summary, on: host, origin: .pasted, into: &credentials) case .storeAdmin(let shared): warnAboutKeepingAdmin(shared: shared, host: host) - try store(token, as: summary, on: host, minted: false, into: &credentials) + try store(token, as: summary, on: host, origin: .pasted, into: &credentials) case .mint(let suggestedName, let shared): let minted = try await mint( @@ -125,7 +263,9 @@ struct LoginCommand: SteleCommand { "the token it sent back is not one a credential can hold." ) } - try store(token, as: minted.client, on: host, minted: true, into: &credentials) + try store( + token, as: minted.client, on: host, origin: .mintedByOperator, into: &credentials + ) } } @@ -235,15 +375,49 @@ struct LoginCommand: SteleCommand { return name } + /// Where the credential being filed came from. + /// + /// Three things depend on it and they are kept together rather than as three booleans at the + /// call sites: the `minted` field `--json` promises, the reassurance printed under a + /// successful login, and what an operator is told if the credential is live on the server + /// and its only plaintext could not be kept. + private enum Origin { + /// Verified and stored as it was typed. Nothing was minted, so nothing can be orphaned. + case pasted + /// Minted with an operator token that was spent for the purpose and never written down. + case mintedByOperator + /// Minted by the server at the end of a GitHub sign-in. + case signedIn + + /// Whether this login *created* the credential it is storing — which is also the + /// assurance that whatever was pasted, if anything was, reached no file. + var minted: Bool { + switch self { + case .pasted: return false + case .mintedByOperator, .signedIn: return true + } + } + + /// The one thing worth saying that the rest of the output does not already say, or + /// nothing. Printed on the branch where it is true rather than as a footer. + var reassurance: String? { + switch self { + case .pasted: return nil + case .mintedByOperator: return "the admin token was not written to disk." + case .signedIn: return "no GitHub token ever reached this machine." + } + } + } + /// Files the credential and reports it. /// - /// One path for all three decisions, so the reporting cannot drift between them and so there - /// is exactly one line in this command that writes a token to disk. + /// One path for every way in, so the reporting cannot drift between them and so there is + /// exactly one line in this command that writes a token to disk. private func store( _ token: Token, as summary: ClientSummary, on host: SteleHost, - minted: Bool, + origin: Origin, into credentials: inout Credentials ) throws { credentials.set( @@ -254,17 +428,27 @@ struct LoginCommand: SteleCommand { do { try options.store.save(credentials) } catch { - guard minted else { throw error } - throw Self.orphaned( - summary.name, on: host, "could not write the credential file: \(error)." - ) + switch origin { + case .pasted: throw error + case .mintedByOperator: + throw Self.orphaned( + summary.name, on: host, "could not write the credential file: \(error)." + ) + case .signedIn: + throw Self.signInLost( + summary.name, on: host, "could not write the credential file: \(error)." + ) + } } if options.json { Terminal.out( try Format.json( AuthStatus( - host: host, summary: summary, path: options.store.path, minted: minted + host: host, + summary: summary, + path: options.store.path, + minted: origin.minted ) ) ) @@ -280,10 +464,10 @@ struct LoginCommand: SteleCommand { Terminal.out(style.dim("expires \(Format.moment(expiry))")) } Terminal.out(style.dim("stored 0600 in \(Format.tildify(options.store.path))")) - if minted { - // The reassurance the whole command exists to be able to give, and it is only worth - // saying where it is true — so it is printed on this branch and not as a footer. - Terminal.out(style.dim("the admin token was not written to disk.")) + if let reassurance = origin.reassurance { + // The reassurance the whole command exists to be able to give, and it says a + // different true thing depending on how the credential was got. + Terminal.out(style.dim(reassurance)) } } @@ -302,6 +486,24 @@ struct LoginCommand: SteleCommand { ) } + /// A credential a sign-in minted, whose plaintext this side then failed to keep. + /// + /// The same bad state `orphaned` describes and a different remedy, which is why it is a + /// second message rather than a shared one. There the operator holds the token that can + /// revoke the stranded credential; here they hold nothing at all — a sign-in mints + /// `publish`, which cannot revoke anything. What recovers it is signing in again: the + /// exchange retires whatever live credential holds the login's name before minting the + /// replacement, so the stranded row is cleaned up by the retry rather than by an operator. + private static func signInLost( + _ name: String, on host: SteleHost, _ problem: String + ) -> Failure { + Failure( + "signed in to \(host) and the server minted '\(name)', but \(problem) Nothing was " + + "written. Run `stele auth login` again — the next sign-in retires that " + + "credential and mints another under the same name." + ) + } + /// Why this command is about to do something the user did not ask for. /// /// stderr and above the prompt, because it is the reason the next question is being asked. diff --git a/Tests/SteleKitTests/DeviceFlowTests.swift b/Tests/SteleKitTests/DeviceFlowTests.swift new file mode 100644 index 0000000..69cc57b --- /dev/null +++ b/Tests/SteleKitTests/DeviceFlowTests.swift @@ -0,0 +1,400 @@ +import Foundation +import Testing + +@testable import SteleKit + +/// Replies to each poll with the next scripted answer, and records what it was asked. +/// +/// A queue rather than a closure over a counter, because every test in here is a *sequence* — +/// "pending, pending, then minted" is the shape of the whole feature, and a transport that +/// answers the same thing every time cannot express it. The last scripted answer repeats, so a +/// test about giving up does not have to script the give-up. +private actor ScriptedTransport: SteleTransport { + private(set) var requests: [SteleRequest] = [] + private var replies: [SteleResponse] + + init(_ replies: [SteleResponse]) { + self.replies = replies + } + + init(status: Int, body: String = "") { + self.replies = [SteleResponse(status: status, body: Data(body.utf8))] + } + + func send(_ request: SteleRequest) async throws -> SteleResponse { + requests.append(request) + return replies.count > 1 ? replies.removeFirst() : replies[0] + } + + var last: SteleRequest? { requests.last } + var count: Int { requests.count } +} + +private func response(_ status: Int, _ body: String = "") -> SteleResponse { + SteleResponse(status: status, body: Data(body.utf8)) +} + +private let bundleJSON = """ + {"userCode":"WDJB-MJHT","verificationURI":"https://github.com/login/device",\ + "deviceCode":"3584d83530557fdd1f46af8289938c8ef79f9dc5","interval":5,"expiresIn":900} + """ + +private let mintedJSON = """ + {"token":"stele_pat_minted","client":{"name":"projedi1234","scopes":["publish"],\ + "createdAt":"2026-08-25T10:00:00Z","githubLogin":"ProJedi1234"}} + """ + +private func testHost() throws -> SteleHost { + try SteleHost("https://stele.example.com") +} + +private func testBundle(interval: Int = 5, expiresIn: Int = 900) -> DeviceCodeBundle { + DeviceCodeBundle( + userCode: "WDJB-MJHT", + verificationURI: "https://github.com/login/device", + deviceCode: "device-code", + interval: interval, + expiresIn: expiresIn + ) +} + +@Suite("device sign-in requests") +struct DeviceSignInRequestTests { + @Test("starting a sign-in posts to /auth/github/device with no body and no credential") + func start() async throws { + let transport = ScriptedTransport(status: 200, body: bundleJSON) + let client = SteleClient(host: try testHost(), transport: transport) + + let bundle = try await client.startDeviceSignIn() + + #expect(bundle.userCode == "WDJB-MJHT") + #expect(bundle.verificationURI == "https://github.com/login/device") + #expect(bundle.interval == 5) + #expect(bundle.expiresIn == 900) + let request = try #require(await transport.last) + #expect(request.method == "POST") + #expect(request.url.absoluteString == "https://stele.example.com/auth/github/device") + #expect(request.body == nil) + // The route is the one place this client has nothing to present. A credential sent here + // would be one the deployment never asked for, on the request whose whole premise is + // that there is not one yet. + #expect(request.headerFields()["Authorization"] == nil) + } + + /// The deployment declining, which is what `auth login` reads as "fall back to a token". + /// It is the same answer a refused sign-in gets, by the server's design, and it has to reach + /// the caller as an ordinary `unauthorized` rather than as anything cleverer — there is + /// nothing in it to be clever about. + @Test("a deployment with no GitHub sign-in configured refuses the start route") + func startRefused() async throws { + let transport = ScriptedTransport(status: 401, body: #"{"error":{"message":"refused"}}"#) + let client = SteleClient(host: try testHost(), transport: transport) + + await #expect(throws: SteleError.unauthorized) { + _ = try await client.startDeviceSignIn() + } + } + + /// The field name is the whole contract of this body, and a misspelling of it is a `400` + /// rather than a silent nothing — but only because the server has one required field. Read + /// back off the encoded bytes for `CreateClientRequest`'s reason: a wire mismatch that + /// travels is not one any assertion about the call arguments would notice. + @Test("a poll posts the device code to /auth/github/exchange, and nothing else") + func poll() async throws { + let transport = ScriptedTransport(status: 202, body: #"{"interval":5}"#) + let client = SteleClient(host: try testHost(), transport: transport) + + _ = try await client.redeemDeviceCode("device-code") + + let request = try #require(await transport.last) + #expect(request.method == "POST") + #expect(request.url.absoluteString == "https://stele.example.com/auth/github/exchange") + #expect(request.headerFields()["Content-Type"] == "application/json") + #expect(request.headerFields()["Authorization"] == nil) + let body = try #require(request.body) + let decoded = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: String] + ) + #expect(decoded == ["deviceCode": "device-code"]) + } + + @Test("a 201 carries the minted credential") + func minted() async throws { + let transport = ScriptedTransport(status: 201, body: mintedJSON) + let client = SteleClient(host: try testHost(), transport: transport) + + guard case .minted(let minted) = try await client.redeemDeviceCode("device-code") else { + Issue.record("expected a minted credential") + return + } + #expect(minted.client.name == "projedi1234") + #expect(minted.client.githubLogin == "ProJedi1234") + #expect(minted.token.secret == "stele_pat_minted") + } + + @Test("a 202 is pending and carries the interval the server asked for") + func pending() async throws { + let transport = ScriptedTransport(status: 202, body: #"{"interval":10}"#) + let client = SteleClient(host: try testHost(), transport: transport) + + #expect(try await client.redeemDeviceCode("device-code") == .pending(interval: 10)) + } + + /// The interval is advice on top of one the flow already has, so a `202` this client cannot + /// read is still a perfectly good "keep waiting". Failing here would end a sign-in that is + /// going fine over a field that only ever slows the polling down. + @Test( + "a 202 with nothing readable in it is still pending", + arguments: ["", "{}", "not json at all", #"{"interval":"soon"}"#] + ) + func pendingWithoutAnInterval(_ body: String) async throws { + let transport = ScriptedTransport(status: 202, body: body) + let client = SteleClient(host: try testHost(), transport: transport) + + #expect(try await client.redeemDeviceCode("device-code") == .pending(interval: nil)) + } + + /// Every terminal refusal is one byte-identical `401` on the server, so there is nothing to + /// describe and nothing to retry — which is why this is an outcome rather than a thrown + /// error. The loop stops on it. + @Test("a 401 is a refusal, not an error") + func refused() async throws { + let transport = ScriptedTransport(status: 401, body: #"{"error":{"message":"refused"}}"#) + let client = SteleClient(host: try testHost(), transport: transport) + + #expect(try await client.redeemDeviceCode("device-code") == .refused) + } + + /// The counterpart, and the one worth asserting as an inequality. A `500` means the server + /// could not reach GitHub — an outage, on a sign-in that may well succeed in a minute — and + /// folding it into `.refused` would tell a user their login was rejected and send them round + /// the loop again. It throws, so the loop cannot mistake it for an answer. + @Test("a 500 throws rather than reporting a refusal") + func outage() async throws { + let transport = ScriptedTransport(status: 500, body: #"{"error":{"message":"github"}}"#) + let client = SteleClient(host: try testHost(), transport: transport) + + await #expect(throws: SteleError.self) { + _ = try await client.redeemDeviceCode("device-code") + } + } +} + +/// A clock and a sleep that cost no time. +/// +/// The point of injecting them: "does `slow_down` actually slow the polling down" and "does this +/// give up when the code expires" are the two questions this feature has, and both are about +/// seconds. Answered against a real clock they would take fifteen minutes and answer nothing. +private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var moment = Date(timeIntervalSince1970: 0) + private var slept: [TimeInterval] = [] + + var now: Date { + lock.lock() + defer { lock.unlock() } + return moment + } + + var intervals: [TimeInterval] { + lock.lock() + defer { lock.unlock() } + return slept + } + + /// Advances the clock by exactly what was waited for, so the deadline arrives after the + /// number of polls the interval says it should. + func sleep(_ seconds: TimeInterval) { + lock.lock() + defer { lock.unlock() } + slept.append(seconds) + moment = moment.addingTimeInterval(seconds) + } +} + +private func signIn( + replying replies: [SteleResponse], + clock: FakeClock +) throws -> (DeviceSignIn, ScriptedTransport) { + let transport = ScriptedTransport(replies) + let client = SteleClient(host: try testHost(), transport: transport) + return ( + DeviceSignIn( + client: client, + now: { clock.now }, + sleep: { clock.sleep($0) } + ), + transport + ) +} + +@Suite("waiting for a device sign-in") +struct DeviceSignInLoopTests { + @Test("polls until the sign-in is approved") + func approved() async throws { + let clock = FakeClock() + let (flow, transport) = try signIn( + replying: [ + response(202, #"{"interval":5}"#), + response(202, #"{"interval":5}"#), + response(201, mintedJSON), + ], + clock: clock + ) + + guard case .minted(let minted) = try await flow.complete(testBundle()) else { + Issue.record("expected a minted credential") + return + } + #expect(minted.client.name == "projedi1234") + #expect(await transport.count == 3) + } + + /// The first request cannot be answered anything but `pending` — the code was printed a + /// moment ago and nobody has had time to type it — so the loop waits first and spends no + /// poll learning that. + @Test("waits before the first poll, at the interval the start response named") + func waitsFirst() async throws { + let clock = FakeClock() + let (flow, _) = try signIn(replying: [response(201, mintedJSON)], clock: clock) + + _ = try await flow.complete(testBundle(interval: 7)) + + #expect(clock.intervals == [7]) + } + + /// `slow_down` is GitHub asking for more room, and it arrives as a larger number rather than + /// as a case of its own. Nothing here needs to know which of the two pending answers it got. + @Test("a bigger interval slows the polling down") + func slowsDown() async throws { + let clock = FakeClock() + let (flow, _) = try signIn( + replying: [ + response(202, #"{"interval":5}"#), + response(202, #"{"interval":10}"#), + response(202, #"{"interval":10}"#), + response(201, mintedJSON), + ], + clock: clock + ) + + _ = try await flow.complete(testBundle(interval: 5)) + + #expect(clock.intervals == [5, 5, 10, 10]) + } + + /// The other direction, which is the one that would be a mistake. An interval is a floor the + /// server asks for, so a smaller number later in the flow is not permission to poll harder + /// than the flow began with — and a `202` carrying no interval at all is not permission + /// either, which is what the nil case pins. + @Test("a smaller interval, or none, never speeds it up") + func neverSpeedsUp() async throws { + let clock = FakeClock() + let (flow, _) = try signIn( + replying: [ + response(202, #"{"interval":1}"#), + response(202, "{}"), + response(201, mintedJSON), + ], + clock: clock + ) + + _ = try await flow.complete(testBundle(interval: 10)) + + #expect(clock.intervals == [10, 10, 10]) + } + + @Test("a refusal ends the wait at once") + func refused() async throws { + let clock = FakeClock() + let (flow, transport) = try signIn(replying: [response(401)], clock: clock) + + guard case .refused = try await flow.complete(testBundle()) else { + Issue.record("expected a refusal") + return + } + #expect(await transport.count == 1) + } + + /// Giving up is what stops this command waiting for a person who has walked away. The + /// arithmetic is the assertion: a thirty-second code polled every five seconds is six polls + /// and then a verdict, not a seventh poll and not an endless loop. + @Test("gives up when the code expires") + func expires() async throws { + let clock = FakeClock() + let (flow, transport) = try signIn( + replying: [response(202, #"{"interval":5}"#)], clock: clock + ) + + guard case .expired = try await flow.complete(testBundle(interval: 5, expiresIn: 30)) + else { + Issue.record("expected the sign-in to expire") + return + } + #expect(await transport.count == 6) + } + + /// The expiry check is only as good as the sleeps it interrupts: one interval of a day would + /// suspend the command until long after the code was dead, and the loop would then report an + /// expiry it had spent a day arriving at. So a wait is clamped to what is left, and the + /// ceiling on the interval itself is what keeps that from being the only guard. + @Test("never waits past the deadline, whatever interval it is handed") + func neverWaitsPastTheDeadline() async throws { + let clock = FakeClock() + let (flow, _) = try signIn( + replying: [response(202, #"{"interval":86400}"#)], clock: clock + ) + + _ = try await flow.complete(testBundle(interval: 86400, expiresIn: 30)) + + #expect(clock.intervals.reduce(0, +) <= 30) + } + + /// Bounds rather than policy: an interval this side cannot validate should not become a hot + /// loop against GitHub's rate limiter, nor a command parked until the deadline. Every value + /// GitHub actually sends passes through untouched. + @Test( + "an absurd interval is bounded at both ends", + arguments: [(0, 1), (-5, 1), (5, 5), (60, 60), (86400, 60)] + ) + func bounds(_ asked: Int, _ used: Int) { + #expect(DeviceSignIn.bounded(asked) == used) + } + + @Test("every poll presents the device code from the start response") + func carriesTheCode() async throws { + let clock = FakeClock() + let (flow, transport) = try signIn( + replying: [response(202, #"{"interval":5}"#), response(201, mintedJSON)], + clock: clock + ) + + _ = try await flow.complete(testBundle()) + + let bodies = await transport.requests.compactMap(\.body) + #expect(bodies.count == 2) + for body in bodies { + #expect(String(data: body, encoding: .utf8)?.contains("device-code") == true) + } + } +} + +@Suite("device code custody") +struct DeviceCodeCustodyTests { + /// The device code is not a stele token, so `Redaction` does not recognise it and would not + /// scrub it out of anything this value were interpolated into. For the quarter of an hour it + /// lives, whoever holds it can finish this sign-in and take the credential it mints — so the + /// type declines to render it, and the poll loop passes it by hand instead. + @Test("printing the bundle does not print the device code") + func redacted() { + let bundle = testBundle() + let printed = "\(bundle)" + + #expect(!printed.contains("device-code")) + #expect(printed.contains(Token.redaction)) + // The half that is meant to be shown, and the reason this is a redaction rather than a + // refusal to print: the user code and the URL are what a person is asked to read. + #expect(printed.contains("WDJB-MJHT")) + #expect(printed.contains("https://github.com/login/device")) + } +} From 8f50a9d6e73e9c20be2e691fffc23abd6fd822cb Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:51:10 -0400 Subject: [PATCH 2/2] fix: fall back to the token prompt when the start route is a 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stele server that predates the device flow answers the start route with its uniform 404 — deliberately, so the route's absence is not a probe result — and for as long as such servers exist, "this CLI is newer than that server" is the likeliest meaning of the status. The generic notFound error read as "wrong host" and sent the person auditing a URL they had typed correctly. The 404 now takes the same fallback the 401 does, with its own sentence: the 401 is a deployment that answered and declined, the 404 is one that may simply be older, and the hedge is kept because all this side has seen is a status an arbitrary non-stele host would also produce. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LBzRbsSw9JzfEg1p3a3FVo --- Sources/stele/AuthCommands.swift | 40 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/Sources/stele/AuthCommands.swift b/Sources/stele/AuthCommands.swift index 97ca183..6d74b4c 100644 --- a/Sources/stele/AuthCommands.swift +++ b/Sources/stele/AuthCommands.swift @@ -122,6 +122,14 @@ struct LoginCommand: SteleCommand { /// an unconfigured client ID with the same bytes it refuses a sign-in with, deliberately, so /// that a prober cannot tell a deployment with no OAuth app from one whose allowlist they /// are not on. This side is not a prober and can simply try the other door. + /// + /// A 404 falls back too, with its own sentence. A stele server that predates the device + /// flow answers this path with its uniform 404 — the same page it gives every unknown + /// slug, deliberately, so the route's absence is not a probe result either — and for as + /// long as such servers exist, "this CLI is newer than that server" is the likeliest + /// meaning of the status. The generic notFound error would read as "wrong host", which + /// sends the person auditing the URL they typed instead of updating the deployment; the + /// token prompt is the door that still works on exactly those servers. private func beginSignIn(on host: SteleHost, client: SteleClient) async throws -> DeviceCodeBundle? { @@ -129,17 +137,31 @@ struct LoginCommand: SteleCommand { do { bundle = try await client.startDeviceSignIn() } catch let error as SteleError { - guard case .unauthorized = error else { throw error } // Said before the prompt appears, because a person who ran this expecting a browser - // and got a token prompt deserves to know which of the two happened. Hedged on - // purpose: all this side knows is that the deployment declined, and the server does - // not say why — the caller here is a human at their own terminal rather than a - // prober, but the answer is the same bytes either way. - Terminal.error( - options.style.dim( - "\(host) did not offer a GitHub sign-in — asking for a token instead." + // and got a token prompt deserves to know which of the two happened. The two + // sentences are hedged differently on purpose. A 401 is a deployment that answered + // and declined, and the server does not say why — the caller here is a human at + // their own terminal rather than a prober, but the answer is the same bytes either + // way. A 404 is a server that has never heard of the route, and the version gap is + // named as a maybe rather than a fact: all this side has seen is a status code that + // an arbitrary non-stele host would also produce. + switch error { + case .unauthorized: + Terminal.error( + options.style.dim( + "\(host) did not offer a GitHub sign-in — asking for a token instead." + ) ) - ) + case .notFound: + Terminal.error( + options.style.dim( + "\(host) has no sign-in route — the server may predate the device " + + "flow. Asking for a token instead." + ) + ) + default: + throw error + } return nil }