summaryrefslogtreecommitdiff
path: root/octosentry
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 17:04:15 -0500
committerChristian Cleberg <[email protected]>2026-07-17 17:04:15 -0500
commit8e6d29f1806cc569b48d913564c7d80a1c2f2355 (patch)
tree60ae62636b45c1a6368f4d0b3c119df74b6ba029 /octosentry
parent2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0 (diff)
downloadoctosentry-0.4.0.tar.gz
octosentry-0.4.0.tar.bz2
octosentry-0.4.0.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.swift12
-rw-r--r--octosentry/AuthStore.swift60
-rw-r--r--octosentry/DeviceAuthModels.swift38
-rw-r--r--octosentry/GitHubAPIError.swift2
-rw-r--r--octosentry/GitHubDeviceAuthClient.swift135
-rw-r--r--octosentry/KeychainTokenStore.swift73
-rw-r--r--octosentry/SecurityEventListView.swift35
-rw-r--r--octosentry/SecurityEventStore.swift8
-rw-r--r--octosentry/SignInView.swift86
-rw-r--r--octosentry/octosentryApp.swift3
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)
}