krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

8e6d29f1806cc569b48d913564c7d80a1c2f2355

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T22:04:15Z

Replace env-var PAT with GitHub device authorization flow + Keychain

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.
 README.md                               |  29 ++++---
 octosentry/AuthState.swift              |  12 +++
 octosentry/AuthStore.swift              |  60 ++++++++++++++
 octosentry/DeviceAuthModels.swift       |  38 +++++++++
 octosentry/GitHubAPIError.swift         |   2 +-
 octosentry/GitHubDeviceAuthClient.swift | 135 ++++++++++++++++++++++++++++++++
 octosentry/KeychainTokenStore.swift     |  73 +++++++++++++++++
 octosentry/SecurityEventListView.swift  |  35 ++++++---
 octosentry/SecurityEventStore.swift     |   8 +-
 octosentry/SignInView.swift             |  86 ++++++++++++++++++++
 octosentry/octosentryApp.swift          |   3 +-
 11 files changed, 450 insertions(+), 31 deletions(-)

diff --git a/README.md b/README.md
index 2415317..2b9497e 100644
--- a/README.md
+++ b/README.md
@@ -14,33 +14,32 @@ just a fast triage view that deep-links out to github.com to act.
 - Click an alert to open it directly on github.com
 - Errors and unavailable sources (e.g. an alert type disabled for a repo)
   are surfaced in the popover instead of failing silently
+- Add or remove watched repos from the popover; a background poll keeps
+  the feed fresh even while it's closed
+- Sign in with GitHub via device authorization — no password or manually
+  generated token needed, and nothing is ever typed into the app itself
 - Zero third-party dependencies — pure SwiftUI and URLSession
 
 ## Requirements
 
 - macOS 14 or later
-- A GitHub personal access token (fine-grained or classic) with read
-  access to Dependabot alerts, code scanning alerts, and secret scanning
-  alerts for the repo you want to watch
+- A GitHub account with access to whatever repos you want to watch
 
 ## Usage
 
-octosentry currently watches a single, hardcoded repo and reads its
-GitHub token from the `GITHUB_TOKEN` environment variable — this is a
-development-only shortcut ahead of a proper device authorization flow.
-
 1. Build and run the app (see Building, below).
-2. Set `GITHUB_TOKEN` in your **personal, non-shared** Xcode scheme
-   (Product → Scheme → Edit Scheme… → Run → Arguments →
-   Environment Variables). Don't add it to a shared scheme — that would
-   commit the token to git.
-3. Click the shield icon in the menu bar to open the popover. It fetches
-   automatically on open, or use the refresh button.
+2. Click the shield icon in the menu bar, then **Sign in with GitHub**.
+   You'll get a short code — click **Open GitHub**, enter the code there,
+   and authorize. The popover updates automatically once that completes.
+3. Click the gear icon to add or remove watched repos (`owner/repo`).
 4. Click any alert to open it on github.com.
 
 If a source shows as unavailable, it usually means that alert type is
-disabled for the repo, or the token is missing that one permission — not
-that something is broken.
+disabled for the repo, or your account lacks permission for it — not
+that something is broken. Classic OAuth's `security_events` scope
+(what device flow grants) may not be sufficient for Dependabot alerts on
+private repos — if you hit that, it needs verifying against a real
+private repo case by case.
 
 ## Building
 
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)
     }