Files
nucleic/Sources/NucleicCore/Git/GitHubOAuth.swift
T
2026-08-02 00:25:23 -07:00

354 lines
17 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// GitHub sign-in for Managed Git — the alternative to making the user hand-craft a personal access
/// token in GitHub's settings and paste it in.
///
/// GitHub's **device flow** is the right shape for a desktop app: it's a public-client flow (no
/// client secret to ship, no loopback port to bind, nothing to register a redirect URI for). Nucleic
/// asks GitHub for a device/user code pair, shows the short user code and opens
/// `github.com/login/device`, and polls the token endpoint until the user approves in their browser.
/// The resulting token lands in the same Keychain slot a pasted PAT would
/// (``GitHubCredentialStore/saveToken(_:)``), so every downstream consumer — the HTTPS credential
/// helper, `GITHUB_TOKEN`/`GH_TOKEN` for `gh`, the credential bundle sealed to runners — is unchanged.
///
/// Unlike ``ClaudeOAuth``/``CodexOAuth``, which drive the vendors' own published CLI clients, this
/// needs an OAuth App **you** register (see ``clientID``): GitHub has no public client id Nucleic can
/// borrow, and the consent screen names whoever owns the app.
///
/// The parsing surface is pure and unit-tested; the two networked calls go through an injectable
/// ``Transport`` so tests exercise the flow without touching GitHub.
public struct GitHubOAuth: Sendable {
// MARK: - Client configuration
/// The OAuth App client id compiled into Nucleic.
///
/// GitHub publishes no shared client id the way Anthropic and OpenAI do for their CLIs, so this
/// is a Nucleic-owned OAuth App registered at <https://github.com/settings/developers> with
/// **Enable Device Flow** checked. Shipping it in the binary is correct, not a leak: the device
/// flow is a *public client* flow — it authenticates with the client id alone and has no client
/// secret to protect (a secret embedded in a distributed app wouldn't be one anyway). A user who
/// wants to run sign-in against their own OAuth App overrides it via ``clientIDKey``.
public static let builtInClientID = "Ov23liFCGvKvJYcmU8JY"
/// `UserDefaults` override for ``builtInClientID`` — lets a user (or a build that hasn't baked an
/// app id in yet) point sign-in at their own OAuth App. Plain text, not a secret.
public static let clientIDKey = "nucleic.github.oauthClientID"
/// The client id sign-in should use: the user's override when set, else the built-in one, else
/// `nil` — meaning sign-in isn't configured and the UI should say so rather than fail mid-flow.
public static var clientID: String? {
let override = (UserDefaults.standard.string(forKey: clientIDKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let resolved = override.isEmpty ? builtInClientID : override
return resolved.isEmpty ? nil : resolved
}
/// Whether the token in ``GitHubCredentialStore`` came from a browser sign-in rather than being
/// pasted in by hand. Both land in the same Keychain slot and behave identically — this only
/// lets Settings say "Signed in to GitHub" honestly instead of claiming an account connection
/// for a PAT the user minted themselves. Deliberately *not* part of ``GitCredentialBundle``: a
/// runner never signs in interactively, so forwarding it would only churn the mesh payload.
public static let signedInKey = "nucleic.github.signedInViaOAuth"
/// True when a browser sign-in issued the token Nucleic currently holds. The flag alone isn't
/// trusted — this re-checks the store, so a token cleared out from under it (an applied
/// credential bundle, a Keychain reset) reads as signed out rather than leaving Settings
/// asserting an account connection that no longer exists. Settings reconciles the stored flag
/// against this on appear.
public static var isSignedIn: Bool {
UserDefaults.standard.bool(forKey: signedInKey)
&& !(GitHubCredentialStore.loadToken() ?? "").isEmpty
}
/// Where Nucleic requests the device/user code pair.
public static let deviceCodeEndpoint = "https://github.com/login/device/code"
/// Where Nucleic polls for the access token once the user has approved.
public static let tokenEndpoint = "https://github.com/login/oauth/access_token"
/// The scopes Nucleic asks for, matching what Managed Git actually does: `repo` (clone/push
/// private repos, open PRs), `workflow` (push commits that touch `.github/workflows`, which
/// GitHub rejects without it), and `read:org` (resolve org-owned repos and teams).
public static let scopes = ["repo", "workflow", "read:org"]
// MARK: - Dependencies
/// A single HTTP round-trip: request in, `(body, response)` out. Injectable so tests never hit
/// the network; the default drives `URLSession`.
public typealias Transport = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
private let clientID: String
private let transport: Transport
/// Injected wait between polls, so tests run the retry loop instantly.
private let sleep: @Sendable (TimeInterval) async throws -> Void
/// Injected clock for the expiry backstop, so tests don't have to wait one out.
private let now: @Sendable () -> Date
public init(
clientID: String,
transport: @escaping Transport = GitHubOAuth.liveTransport,
sleep: @escaping @Sendable (TimeInterval) async throws -> Void = GitHubOAuth.liveSleep,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.clientID = clientID
self.transport = transport
self.sleep = sleep
self.now = now
}
public enum OAuthError: Error, CustomStringConvertible, Equatable {
/// No OAuth App client id is configured, so there's nothing to sign in against.
case notConfigured
case network(String)
case httpStatus(Int, String)
case malformedResponse
/// The user clicked Cancel on GitHub's approval page.
case denied
/// The device code aged out before the user approved it (GitHub allows ~15 minutes).
case expired
/// Any other OAuth error GitHub reported (`error` + `error_description`).
case provider(String, String)
public var description: String {
switch self {
case .notConfigured:
return "No GitHub OAuth App is configured for sign-in."
case .network(let m):
return "Network error: \(m)"
case .httpStatus(let code, _):
return "GitHub returned HTTP \(code)"
case .malformedResponse:
return "GitHub returned an unexpected response"
case .denied:
return "Sign-in was cancelled on GitHub"
case .expired:
return "The sign-in code expired before it was approved"
case .provider(let code, let detail):
return detail.isEmpty ? "GitHub reported \(code)" : detail
}
}
}
// MARK: - Device code
/// GitHub's answer to the device-code request: what to show the user and how to poll.
public struct DeviceCode: Sendable, Equatable {
/// The secret half, submitted when polling. Never shown to the user.
public let deviceCode: String
/// The short code the user types on GitHub (e.g. `ABCD-1234`).
public let userCode: String
/// Where the user enters it — `https://github.com/login/device`.
public let verificationURI: String
/// Seconds until `deviceCode` stops being accepted.
public let expiresIn: TimeInterval
/// Minimum seconds between polls, per GitHub's rate limit.
public let interval: TimeInterval
public init(
deviceCode: String, userCode: String, verificationURI: String,
expiresIn: TimeInterval, interval: TimeInterval
) {
self.deviceCode = deviceCode
self.userCode = userCode
self.verificationURI = verificationURI
self.expiresIn = expiresIn
self.interval = interval
}
/// The verification page to open, with the user code appended as a `user_code` query
/// parameter so GitHub can pre-fill it and the user doesn't have to retype it.
///
/// GitHub does **not** return RFC 8628's `verification_uri_complete` (verified against the
/// live endpoint: the response carries only the five fields above), so Nucleic composes the
/// link itself. GitHub carries the parameter through its own sign-in redirect — a logged-out
/// user gets bounced to `/login?return_to=…user_code%3D…` and lands back here with it intact —
/// so it survives the worst case of the user not being signed in yet. Should GitHub ever
/// ignore the parameter, the page just renders its empty code box: exactly what the user
/// would have seen without it, with the code still on screen and on their clipboard.
public var verificationURL: URL? {
guard var components = URLComponents(string: verificationURI) else { return nil }
components.queryItems = (components.queryItems ?? [])
+ [URLQueryItem(name: "user_code", value: userCode)]
return components.url
}
}
/// Step 1: ask GitHub for a device/user code pair. The caller then shows `userCode` and opens
/// `verificationURL` before calling ``pollForToken(_:)``.
public func requestDeviceCode() async throws -> DeviceCode {
let data = try await post(
Self.deviceCodeEndpoint,
form: ["client_id": clientID, "scope": Self.scopes.joined(separator: " ")])
guard let device = Self.deviceCode(from: data) else {
// A device-code request that parses as an OAuth error usually means the app exists but
// doesn't have device flow enabled — surface GitHub's own wording, it's the actionable bit.
if case .failed(let error) = Self.pollOutcome(from: data) { throw error }
throw OAuthError.malformedResponse
}
return device
}
/// Parse the device-code response. GitHub omits `interval` on some responses; default to its
/// documented 5 seconds rather than hammering the endpoint into a `slow_down`.
static func deviceCode(from data: Data) -> DeviceCode? {
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let deviceCode = obj["device_code"] as? String, !deviceCode.isEmpty,
let userCode = obj["user_code"] as? String, !userCode.isEmpty
else { return nil }
return DeviceCode(
deviceCode: deviceCode,
userCode: userCode,
verificationURI: (obj["verification_uri"] as? String) ?? "https://github.com/login/device",
expiresIn: number(obj["expires_in"]) ?? 900,
interval: number(obj["interval"]) ?? 5)
}
// MARK: - Polling
/// What one poll of the token endpoint told us.
enum PollOutcome: Equatable {
/// The user approved — here's the access token.
case token(String)
/// Not approved yet; wait the current interval and poll again.
case pending
/// We polled too fast. GitHub's response carries the new floor when it bothers to say.
case slowDown(TimeInterval?)
/// Terminal: denied, expired, or a malformed/unknown response.
case failed(OAuthError)
}
/// Step 2: poll until the user approves, GitHub refuses, or the device code expires. Honors
/// GitHub's `interval` and backs off on `slow_down`. Cancelling the enclosing `Task` (the user
/// hitting Cancel) throws `CancellationError`, leaving nothing stored.
public func pollForToken(_ device: DeviceCode) async throws -> String {
var interval = max(1, device.interval)
// One extra interval of slack: `expires_in` is GitHub's clock, and a poll that lands a hair
// late gets `expired_token` back anyway, which we surface as `.expired` regardless.
let deadline = now().addingTimeInterval(device.expiresIn + interval)
while true {
try Task.checkCancellation()
try await sleep(interval)
try Task.checkCancellation()
let data = try await post(
Self.tokenEndpoint,
form: [
"client_id": clientID,
"device_code": device.deviceCode,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
])
switch Self.pollOutcome(from: data) {
case .token(let token):
return token
case .pending:
break
case .slowDown(let newInterval):
// GitHub's documented backoff is "add 5 seconds"; prefer the interval it returns.
interval = max(interval + 5, newInterval ?? 0)
case .failed(let error):
throw error
}
if now() >= deadline { throw OAuthError.expired }
}
}
/// Classify one token-endpoint response. GitHub answers the device-flow poll with HTTP 200 in
/// every case — success and error alike — so the body, not the status, is what decides.
static func pollOutcome(from data: Data) -> PollOutcome {
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return .failed(.malformedResponse)
}
if let token = obj["access_token"] as? String, !token.isEmpty {
return .token(token)
}
guard let error = obj["error"] as? String else { return .failed(.malformedResponse) }
let detail = (obj["error_description"] as? String) ?? ""
switch error {
case "authorization_pending":
return .pending
case "slow_down":
return .slowDown(number(obj["interval"]))
case "access_denied":
return .failed(.denied)
case "expired_token":
return .failed(.expired)
default:
return .failed(.provider(error, detail))
}
}
// MARK: - HTTP
private func post(_ endpoint: String, form: [String: String]) async throws -> Data {
var request = URLRequest(url: URL(string: endpoint)!)
request.httpMethod = "POST"
// GitHub's OAuth endpoints default to a form-encoded *response*; ask for JSON explicitly.
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(Self.formURLEncode(form).utf8)
let (data, response): (Data, HTTPURLResponse)
do {
(data, response) = try await transport(request)
} catch {
throw OAuthError.network(String(describing: error))
}
guard (200..<300).contains(response.statusCode) else {
throw OAuthError.httpStatus(response.statusCode, String(decoding: data, as: UTF8.self))
}
return data
}
// MARK: - Helpers
/// Form-encode a body, sorted by key so the output is deterministic for tests.
static func formURLEncode(_ fields: [String: String]) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return fields.keys.sorted().map { key in
let value = fields[key] ?? ""
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
return "\(encodedKey)=\(encodedValue)"
}.joined(separator: "&")
}
/// Read a JSON number that GitHub may send as a number or a string.
private static func number(_ value: Any?) -> TimeInterval? {
if let n = value as? Double { return n }
if let n = value as? Int { return Double(n) }
if let s = value as? String, let n = Double(s) { return n }
return nil
}
/// The default `Transport`: a `URLSession` round-trip wrapped in a continuation so it compiles
/// identically on macOS and on the Linux runner (whose `FoundationNetworking` lacks the async
/// `data(for:)` overload in some toolchains). Mirrors ``ClaudeOAuth/liveTransport``.
public static let liveTransport: Transport = { request in
try await withCheckedThrowingContinuation { continuation in
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let http = response as? HTTPURLResponse else {
continuation.resume(throwing: OAuthError.malformedResponse)
return
}
continuation.resume(returning: (data ?? Data(), http))
}
task.resume()
}
}
/// The default inter-poll wait. Cancellable, so a user cancelling sign-in doesn't linger for a
/// whole interval first.
public static let liveSleep: @Sendable (TimeInterval) async throws -> Void = { seconds in
try await Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000))
}
}