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..6d74b4c 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,139 @@ 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. + /// + /// 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? + { + let bundle: DeviceCodeBundle + do { + bundle = try await client.startDeviceSignIn() + } catch let error as SteleError { + // 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. 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 + } + + // 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 +242,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 +255,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 +285,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 +397,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 +450,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 +486,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 +508,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")) + } +}