354 lines
17 KiB
Swift
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))
|
|
}
|
|
}
|