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 | |
| 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')
| -rw-r--r-- | octosentry/AuthState.swift | 12 | ||||
| -rw-r--r-- | octosentry/AuthStore.swift | 60 | ||||
| -rw-r--r-- | octosentry/DeviceAuthModels.swift | 38 | ||||
| -rw-r--r-- | octosentry/GitHubAPIError.swift | 2 | ||||
| -rw-r--r-- | octosentry/GitHubDeviceAuthClient.swift | 135 | ||||
| -rw-r--r-- | octosentry/KeychainTokenStore.swift | 73 | ||||
| -rw-r--r-- | octosentry/SecurityEventListView.swift | 35 | ||||
| -rw-r--r-- | octosentry/SecurityEventStore.swift | 8 | ||||
| -rw-r--r-- | octosentry/SignInView.swift | 86 | ||||
| -rw-r--r-- | octosentry/octosentryApp.swift | 3 |
10 files changed, 436 insertions, 16 deletions
diff --git a/octosentry/AuthState.swift b/octosentry/AuthState.swift new file mode 100644 index 0000000..8850a26 --- /dev/null +++ b/octosentry/AuthState.swift @@ -0,0 +1,12 @@ +// +// AuthState.swift +// octosentry +// + +import Foundation + +nonisolated enum AuthState { + case signedOut + case awaitingAuthorization(userCode: String, verificationURL: URL) + case signedIn +} diff --git a/octosentry/AuthStore.swift b/octosentry/AuthStore.swift new file mode 100644 index 0000000..4386e38 --- /dev/null +++ b/octosentry/AuthStore.swift @@ -0,0 +1,60 @@ +// +// AuthStore.swift +// octosentry +// +// Drives the device authorization flow and mirrors whether a token is +// currently in the Keychain. Replaces the GITHUB_TOKEN env var dev +// shortcut (spec §13) with the real v1 auth flow (spec §6). +// + +import Foundation +import Observation + +@Observable +final class AuthStore { + private(set) var state: AuthState + private(set) var errorMessage: String? + + private let client = GitHubDeviceAuthClient() + private var authorizationTask: Task<Void, Never>? + + init() { + state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut + } + + var isSignedIn: Bool { + if case .signedIn = state { return true } + return false + } + + func signIn() { + guard authorizationTask == nil else { return } + errorMessage = nil + + authorizationTask = Task { + defer { authorizationTask = nil } + do { + let deviceCode = try await client.requestDeviceCode() + state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) + + let token = try await client.pollForToken( + deviceCode: deviceCode.deviceCode, + interval: deviceCode.interval, + expiresIn: deviceCode.expiresIn + ) + try KeychainTokenStore.save(token) + state = .signedIn + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + state = .signedOut + } + } + } + + func signOut() { + authorizationTask?.cancel() + authorizationTask = nil + KeychainTokenStore.delete() + state = .signedOut + } +} diff --git a/octosentry/DeviceAuthModels.swift b/octosentry/DeviceAuthModels.swift new file mode 100644 index 0000000..dcd62c6 --- /dev/null +++ b/octosentry/DeviceAuthModels.swift @@ -0,0 +1,38 @@ +// +// DeviceAuthModels.swift +// octosentry +// +// Wire types for GitHub's OAuth 2.0 Device Authorization Grant +// (RFC 8628): github.com/login/device/code and +// github.com/login/oauth/access_token. +// + +import Foundation + +nonisolated struct DeviceCodeResponse: Decodable { + let deviceCode: String + let userCode: String + let verificationUri: URL + let expiresIn: Int + let interval: Int + + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationUri = "verification_uri" + case expiresIn = "expires_in" + case interval + } +} + +nonisolated struct AccessTokenResponse: Decodable { + let accessToken: String? + let error: String? + let interval: Int? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case error + case interval + } +} diff --git a/octosentry/GitHubAPIError.swift b/octosentry/GitHubAPIError.swift index 7baf918..e9f8334 100644 --- a/octosentry/GitHubAPIError.swift +++ b/octosentry/GitHubAPIError.swift @@ -19,7 +19,7 @@ enum GitHubAPIError: Error, LocalizedError, Sendable { var errorDescription: String? { switch self { case .missingToken: - "No GitHub token found in the GITHUB_TOKEN environment variable." + "Not signed in to GitHub." case .network(let message): "Network error: \(message)" case .invalidResponse: 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)" + } + } +} diff --git a/octosentry/KeychainTokenStore.swift b/octosentry/KeychainTokenStore.swift new file mode 100644 index 0000000..c48c9f6 --- /dev/null +++ b/octosentry/KeychainTokenStore.swift @@ -0,0 +1,73 @@ +// +// KeychainTokenStore.swift +// octosentry +// +// Stores the GitHub OAuth token in the app's own Keychain item. Not +// synced to iCloud Keychain by default (spec §6) — deliberate given the +// token's access scope. No keychain-access-groups entitlement needed: +// that's only required to share an item across multiple apps/extensions, +// not for an app reading/writing its own item. +// + +import Foundation +import Security + +nonisolated enum KeychainTokenStore { + private static let service = "net.cleberg.octosentry.github-token" + private static let account = "github-oauth-token" + + static func save(_ token: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + + var attributes = query + attributes[kSecValueData as String] = Data(token.utf8) + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + attributes[kSecAttrSynchronizable as String] = false + + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.unhandled(status) + } + } + + static func load() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } + + enum KeychainError: Error, LocalizedError { + case unhandled(OSStatus) + + var errorDescription: String? { + switch self { + case .unhandled(let status): + let message = SecCopyErrorMessageString(status, nil) as String? ?? "unknown" + return "Keychain error \(status): \(message)" + } + } + } +} diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift index a5dba3e..298aa2f 100644 --- a/octosentry/SecurityEventListView.swift +++ b/octosentry/SecurityEventListView.swift @@ -8,20 +8,24 @@ import SwiftUI struct SecurityEventListView: View { var store: SecurityEventStore + var authStore: AuthStore @State private var showingRepoManager = false var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() - if showingRepoManager { - RepoManagerView(store: store) + if !authStore.isSignedIn { + SignInView(authStore: authStore) + } else if showingRepoManager { + RepoManagerView(store: store, authStore: authStore) } else { content } } .frame(width: 380, height: 420) - .task { + .task(id: authStore.isSignedIn) { + guard authStore.isSignedIn else { return } await store.refresh() store.startPolling() } @@ -39,7 +43,7 @@ struct SecurityEventListView: View { Spacer() - if !showingRepoManager { + if authStore.isSignedIn && !showingRepoManager { Picker("Minimum severity", selection: Binding( get: { store.minimumSeverity }, set: { newValue in Task { await store.setMinimumSeverity(newValue) } } @@ -61,12 +65,14 @@ struct SecurityEventListView: View { .disabled(store.isLoading) } - Button { - showingRepoManager.toggle() - } label: { - Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") + if authStore.isSignedIn { + Button { + showingRepoManager.toggle() + } label: { + Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) Button("Quit") { NSApplication.shared.terminate(nil) @@ -121,6 +127,7 @@ struct SecurityEventListView: View { private struct RepoManagerView: View { var store: SecurityEventStore + var authStore: AuthStore @State private var newRepoText = "" var body: some View { @@ -167,6 +174,14 @@ private struct RepoManagerView: View { } Spacer() + + Divider() + + Button("Sign Out") { + authStore.signOut() + } + .buttonStyle(.plain) + .foregroundStyle(.red) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) @@ -234,5 +249,5 @@ private struct StatusView: View { } #Preview { - SecurityEventListView(store: SecurityEventStore()) + SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) } diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift index 11f813c..65a7280 100644 --- a/octosentry/SecurityEventStore.swift +++ b/octosentry/SecurityEventStore.swift @@ -3,8 +3,8 @@ // octosentry // // Holds the fetched event stream for the popover. Watch list, seen-state, -// and last-fetch timestamps are persisted (see PersistedState); the PAT -// is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13). +// and last-fetch timestamps are persisted (see PersistedState); the token +// comes from Keychain, put there by the device authorization flow (spec §6). // // Each alert source is fetched independently, per repo, so a problem // with one endpoint (or one repo) doesn't blank out the rest. A 403/404 @@ -42,8 +42,8 @@ final class SecurityEventStore { minimumSeverity = state.minimumSeverity watchedRepos = state.watchedRepos - guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { - errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] + guard let token = KeychainTokenStore.load() else { + errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] return } diff --git a/octosentry/SignInView.swift b/octosentry/SignInView.swift new file mode 100644 index 0000000..6d791df --- /dev/null +++ b/octosentry/SignInView.swift @@ -0,0 +1,86 @@ +// +// SignInView.swift +// octosentry +// + +import AppKit +import SwiftUI + +struct SignInView: View { + var authStore: AuthStore + + var body: some View { + VStack(spacing: 16) { + Spacer() + + switch authStore.state { + case .signedOut: + signedOutContent + case .awaitingAuthorization(let userCode, let verificationURL): + awaitingAuthorizationContent(userCode: userCode, verificationURL: verificationURL) + case .signedIn: + EmptyView() + } + + if let errorMessage = authStore.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var signedOutContent: some View { + VStack(spacing: 16) { + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("Sign in with GitHub to see your security alerts.") + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + Button("Sign in with GitHub") { + authStore.signIn() + } + .buttonStyle(.borderedProminent) + } + } + + private func awaitingAuthorizationContent(userCode: String, verificationURL: URL) -> some View { + VStack(spacing: 12) { + Text("Enter this code on GitHub") + .font(.callout) + .foregroundStyle(.secondary) + + Text(userCode) + .font(.system(.title, design: .monospaced).weight(.bold)) + .textSelection(.enabled) + + HStack(spacing: 8) { + Button("Copy Code") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(userCode, forType: .string) + } + Button("Open GitHub") { + NSWorkspace.shared.open(verificationURL) + } + .buttonStyle(.borderedProminent) + } + + ProgressView() + .controlSize(.small) + .padding(.top, 4) + } + } +} + +#Preview { + SignInView(authStore: AuthStore()) +} diff --git a/octosentry/octosentryApp.swift b/octosentry/octosentryApp.swift index 4d5a859..cf7522d 100644 --- a/octosentry/octosentryApp.swift +++ b/octosentry/octosentryApp.swift @@ -10,10 +10,11 @@ import SwiftUI @main struct octosentryApp: App { @State private var store = SecurityEventStore() + @State private var authStore = AuthStore() var body: some Scene { MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { - SecurityEventListView(store: store) + SecurityEventListView(store: store, authStore: authStore) } .menuBarExtraStyle(.window) } |
