diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 17:04:15 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-17 17:04:15 -0500 |
| commit | 8e6d29f1806cc569b48d913564c7d80a1c2f2355 (patch) | |
| tree | 60ae62636b45c1a6368f4d0b3c119df74b6ba029 /octosentry/GitHubDeviceAuthClient.swift | |
| parent | 2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0 (diff) | |
| download | octosentry-8e6d29f1806cc569b48d913564c7d80a1c2f2355.tar.gz octosentry-8e6d29f1806cc569b48d913564c7d80a1c2f2355.tar.bz2 octosentry-8e6d29f1806cc569b48d913564c7d80a1c2f2355.zip | |
Replace env-var PAT with GitHub device authorization flow + Keychain0.4.0
Closes #6, #7 (milestone 0.4.0).
- GitHubDeviceAuthClient implements the OAuth 2.0 device authorization
grant (device code request + poll for token) against GitHub's OAuth
App endpoints. Verified against the real endpoints directly.
- KeychainTokenStore stores the resulting token in the app's own
Keychain item (not synced to iCloud Keychain), no shared entitlement
needed since nothing else reads it.
- AuthStore drives the sign-in state machine (signedOut /
awaitingAuthorization / signedIn) and a new SignInView replaces the
old "missing token" error state with an actual sign-in UI.
- SecurityEventStore now reads the token from Keychain instead of the
GITHUB_TOKEN environment variable, which is fully retired.
- Scope requested is security_events, the narrowest available for
classic OAuth Apps (no read-only variant exists at this level, unlike
fine-grained PATs). Private-repo Dependabot alerts may need broader
repo scope โ to be confirmed with real-world testing.
Diffstat (limited to 'octosentry/GitHubDeviceAuthClient.swift')
| -rw-r--r-- | octosentry/GitHubDeviceAuthClient.swift | 135 |
1 files changed, 135 insertions, 0 deletions
diff --git a/octosentry/GitHubDeviceAuthClient.swift b/octosentry/GitHubDeviceAuthClient.swift new file mode 100644 index 0000000..63799ff --- /dev/null +++ b/octosentry/GitHubDeviceAuthClient.swift @@ -0,0 +1,135 @@ +// +// GitHubDeviceAuthClient.swift +// octosentry +// +// Implements the GitHub device authorization flow (spec ยง6): request a +// device/user code pair, show the user code, then poll until they've +// authorized it on github.com/login/device. No client secret involved โ +// device flow for native apps doesn't use one. +// + +import Foundation + +actor GitHubDeviceAuthClient { + // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). + // Not a secret โ safe to embed in source. + private let clientID = "Ov23li6tqaTghDc4IJYv" + + // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth + // scopes have no read-only variant (unlike fine-grained PATs); this is the + // narrowest scope GitHub offers for these three endpoints via OAuth Apps. + private let scope = "security_events" + + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func requestDeviceCode() async throws -> DeviceCodeResponse { + let data = try await post( + url: URL(string: "https://github.com/login/device/code")!, + parameters: ["client_id": clientID, "scope": scope] + ) + do { + return try JSONDecoder().decode(DeviceCodeResponse.self, from: data) + } catch { + throw DeviceAuthError.decodingFailed(error.localizedDescription) + } + } + + /// Polls until the user authorizes, denies, or the device code expires. + func pollForToken(deviceCode: String, interval: Int, expiresIn: Int) async throws -> String { + var currentInterval = interval + let deadline = Date().addingTimeInterval(TimeInterval(expiresIn)) + + while Date() < deadline { + try await Task.sleep(for: .seconds(currentInterval)) + try Task.checkCancellation() + + let data = try await post( + url: URL(string: "https://github.com/login/oauth/access_token")!, + parameters: [ + "client_id": clientID, + "device_code": deviceCode, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + ] + ) + + let response: AccessTokenResponse + do { + response = try JSONDecoder().decode(AccessTokenResponse.self, from: data) + } catch { + throw DeviceAuthError.decodingFailed(error.localizedDescription) + } + + if let token = response.accessToken { + return token + } + + switch response.error { + case "authorization_pending": + continue + case "slow_down": + currentInterval = response.interval ?? (currentInterval + 5) + case "expired_token": + throw DeviceAuthError.expired + case "access_denied": + throw DeviceAuthError.denied + default: + throw DeviceAuthError.unknown(response.error ?? "unrecognized response") + } + } + throw DeviceAuthError.expired + } + + private func post(url: URL, parameters: [String: String]) async throws -> Data { + var components = URLComponents() + components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = Data((components.percentEncodedQuery ?? "").utf8) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw DeviceAuthError.network(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw DeviceAuthError.requestFailed + } + return data + } +} + +nonisolated enum DeviceAuthError: Error, LocalizedError { + case network(String) + case requestFailed + case decodingFailed(String) + case expired + case denied + case unknown(String) + + var errorDescription: String? { + switch self { + case .network(let message): + "Network error: \(message)" + case .requestFailed: + "Failed to reach GitHub." + case .decodingFailed(let message): + "Unexpected response from GitHub: \(message)" + case .expired: + "The sign-in code expired before it was used. Try again." + case .denied: + "Sign-in was denied on GitHub." + case .unknown(let message): + "GitHub sign-in failed: \(message)" + } + } +} |
