krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit 3690864493

369086449306c064286ee82dadc6aa6c61c35d66

parent: 1ff98b494e

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 22:04 UTC

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.

Layout: unified · split

README.md +14 −15
@@ -14,33 +14,32 @@ just a fast triage view that deep-links out to github.com to act.
1414- Click an alert to open it directly on github.com
1515- Errors and unavailable sources (e.g. an alert type disabled for a repo)
1616 are surfaced in the popover instead of failing silently
17- Add or remove watched repos from the popover; a background poll keeps
18 the feed fresh even while it's closed
19- Sign in with GitHub via device authorization — no password or manually
20 generated token needed, and nothing is ever typed into the app itself
1721- Zero third-party dependencies — pure SwiftUI and URLSession
1822
1923## Requirements
2024
2125- macOS 14 or later
22- A GitHub personal access token (fine-grained or classic) with read
23 access to Dependabot alerts, code scanning alerts, and secret scanning
24 alerts for the repo you want to watch
26- A GitHub account with access to whatever repos you want to watch
2527
2628## Usage
2729
28octosentry currently watches a single, hardcoded repo and reads its
29GitHub token from the `GITHUB_TOKEN` environment variable — this is a
30development-only shortcut ahead of a proper device authorization flow.
31
32301. Build and run the app (see Building, below).
332. Set `GITHUB_TOKEN` in your **personal, non-shared** Xcode scheme
34 (Product → Scheme → Edit Scheme… → Run → Arguments →
35 Environment Variables). Don't add it to a shared scheme — that would
36 commit the token to git.
373. Click the shield icon in the menu bar to open the popover. It fetches
38 automatically on open, or use the refresh button.
312. Click the shield icon in the menu bar, then **Sign in with GitHub**.
32 You'll get a short code — click **Open GitHub**, enter the code there,
33 and authorize. The popover updates automatically once that completes.
343. Click the gear icon to add or remove watched repos (`owner/repo`).
39354. Click any alert to open it on github.com.
4036
4137If a source shows as unavailable, it usually means that alert type is
42disabled for the repo, or the token is missing that one permission — not
43that something is broken.
38disabled for the repo, or your account lacks permission for it — not
39that something is broken. Classic OAuth's `security_events` scope
40(what device flow grants) may not be sufficient for Dependabot alerts on
41private repos — if you hit that, it needs verifying against a real
42private repo case by case.
4443
4544## Building
4645
octosentry/AuthState.swift added +12
@@ -0,0 +1,12 @@
1//
2// AuthState.swift
3// octosentry
4//
5
6import Foundation
7
8nonisolated enum AuthState {
9 case signedOut
10 case awaitingAuthorization(userCode: String, verificationURL: URL)
11 case signedIn
12}
octosentry/AuthStore.swift added +60
@@ -0,0 +1,60 @@
1//
2// AuthStore.swift
3// octosentry
4//
5// Drives the device authorization flow and mirrors whether a token is
6// currently in the Keychain. Replaces the GITHUB_TOKEN env var dev
7// shortcut (spec §13) with the real v1 auth flow (spec §6).
8//
9
10import Foundation
11import Observation
12
13@Observable
14final class AuthStore {
15 private(set) var state: AuthState
16 private(set) var errorMessage: String?
17
18 private let client = GitHubDeviceAuthClient()
19 private var authorizationTask: Task<Void, Never>?
20
21 init() {
22 state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
23 }
24
25 var isSignedIn: Bool {
26 if case .signedIn = state { return true }
27 return false
28 }
29
30 func signIn() {
31 guard authorizationTask == nil else { return }
32 errorMessage = nil
33
34 authorizationTask = Task {
35 defer { authorizationTask = nil }
36 do {
37 let deviceCode = try await client.requestDeviceCode()
38 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
39
40 let token = try await client.pollForToken(
41 deviceCode: deviceCode.deviceCode,
42 interval: deviceCode.interval,
43 expiresIn: deviceCode.expiresIn
44 )
45 try KeychainTokenStore.save(token)
46 state = .signedIn
47 } catch {
48 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
49 state = .signedOut
50 }
51 }
52 }
53
54 func signOut() {
55 authorizationTask?.cancel()
56 authorizationTask = nil
57 KeychainTokenStore.delete()
58 state = .signedOut
59 }
60}
octosentry/DeviceAuthModels.swift added +38
@@ -0,0 +1,38 @@
1//
2// DeviceAuthModels.swift
3// octosentry
4//
5// Wire types for GitHub's OAuth 2.0 Device Authorization Grant
6// (RFC 8628): github.com/login/device/code and
7// github.com/login/oauth/access_token.
8//
9
10import Foundation
11
12nonisolated struct DeviceCodeResponse: Decodable {
13 let deviceCode: String
14 let userCode: String
15 let verificationUri: URL
16 let expiresIn: Int
17 let interval: Int
18
19 enum CodingKeys: String, CodingKey {
20 case deviceCode = "device_code"
21 case userCode = "user_code"
22 case verificationUri = "verification_uri"
23 case expiresIn = "expires_in"
24 case interval
25 }
26}
27
28nonisolated struct AccessTokenResponse: Decodable {
29 let accessToken: String?
30 let error: String?
31 let interval: Int?
32
33 enum CodingKeys: String, CodingKey {
34 case accessToken = "access_token"
35 case error
36 case interval
37 }
38}
octosentry/GitHubAPIError.swift +1 −1
@@ -19,7 +19,7 @@ enum GitHubAPIError: Error, LocalizedError, Sendable {
1919 var errorDescription: String? {
2020 switch self {
2121 case .missingToken:
22 "No GitHub token found in the GITHUB_TOKEN environment variable."
22 "Not signed in to GitHub."
2323 case .network(let message):
2424 "Network error: \(message)"
2525 case .invalidResponse:
octosentry/GitHubDeviceAuthClient.swift added +135
@@ -0,0 +1,135 @@
1//
2// GitHubDeviceAuthClient.swift
3// octosentry
4//
5// Implements the GitHub device authorization flow (spec §6): request a
6// device/user code pair, show the user code, then poll until they've
7// authorized it on github.com/login/device. No client secret involved —
8// device flow for native apps doesn't use one.
9//
10
11import Foundation
12
13actor GitHubDeviceAuthClient {
14 // Public client identifier for the "octosentry" OAuth App (Device Flow enabled).
15 // Not a secret — safe to embed in source.
16 private let clientID = "Ov23li6tqaTghDc4IJYv"
17
18 // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth
19 // scopes have no read-only variant (unlike fine-grained PATs); this is the
20 // narrowest scope GitHub offers for these three endpoints via OAuth Apps.
21 private let scope = "security_events"
22
23 private let session: URLSession
24
25 init(session: URLSession = .shared) {
26 self.session = session
27 }
28
29 func requestDeviceCode() async throws -> DeviceCodeResponse {
30 let data = try await post(
31 url: URL(string: "https://github.com/login/device/code")!,
32 parameters: ["client_id": clientID, "scope": scope]
33 )
34 do {
35 return try JSONDecoder().decode(DeviceCodeResponse.self, from: data)
36 } catch {
37 throw DeviceAuthError.decodingFailed(error.localizedDescription)
38 }
39 }
40
41 /// Polls until the user authorizes, denies, or the device code expires.
42 func pollForToken(deviceCode: String, interval: Int, expiresIn: Int) async throws -> String {
43 var currentInterval = interval
44 let deadline = Date().addingTimeInterval(TimeInterval(expiresIn))
45
46 while Date() < deadline {
47 try await Task.sleep(for: .seconds(currentInterval))
48 try Task.checkCancellation()
49
50 let data = try await post(
51 url: URL(string: "https://github.com/login/oauth/access_token")!,
52 parameters: [
53 "client_id": clientID,
54 "device_code": deviceCode,
55 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
56 ]
57 )
58
59 let response: AccessTokenResponse
60 do {
61 response = try JSONDecoder().decode(AccessTokenResponse.self, from: data)
62 } catch {
63 throw DeviceAuthError.decodingFailed(error.localizedDescription)
64 }
65
66 if let token = response.accessToken {
67 return token
68 }
69
70 switch response.error {
71 case "authorization_pending":
72 continue
73 case "slow_down":
74 currentInterval = response.interval ?? (currentInterval + 5)
75 case "expired_token":
76 throw DeviceAuthError.expired
77 case "access_denied":
78 throw DeviceAuthError.denied
79 default:
80 throw DeviceAuthError.unknown(response.error ?? "unrecognized response")
81 }
82 }
83 throw DeviceAuthError.expired
84 }
85
86 private func post(url: URL, parameters: [String: String]) async throws -> Data {
87 var components = URLComponents()
88 components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
89
90 var request = URLRequest(url: url)
91 request.httpMethod = "POST"
92 request.setValue("application/json", forHTTPHeaderField: "Accept")
93 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
94 request.httpBody = Data((components.percentEncodedQuery ?? "").utf8)
95
96 let data: Data
97 let response: URLResponse
98 do {
99 (data, response) = try await session.data(for: request)
100 } catch {
101 throw DeviceAuthError.network(error.localizedDescription)
102 }
103
104 guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
105 throw DeviceAuthError.requestFailed
106 }
107 return data
108 }
109}
110
111nonisolated enum DeviceAuthError: Error, LocalizedError {
112 case network(String)
113 case requestFailed
114 case decodingFailed(String)
115 case expired
116 case denied
117 case unknown(String)
118
119 var errorDescription: String? {
120 switch self {
121 case .network(let message):
122 "Network error: \(message)"
123 case .requestFailed:
124 "Failed to reach GitHub."
125 case .decodingFailed(let message):
126 "Unexpected response from GitHub: \(message)"
127 case .expired:
128 "The sign-in code expired before it was used. Try again."
129 case .denied:
130 "Sign-in was denied on GitHub."
131 case .unknown(let message):
132 "GitHub sign-in failed: \(message)"
133 }
134 }
135}
octosentry/KeychainTokenStore.swift added +73
@@ -0,0 +1,73 @@
1//
2// KeychainTokenStore.swift
3// octosentry
4//
5// Stores the GitHub OAuth token in the app's own Keychain item. Not
6// synced to iCloud Keychain by default (spec §6) — deliberate given the
7// token's access scope. No keychain-access-groups entitlement needed:
8// that's only required to share an item across multiple apps/extensions,
9// not for an app reading/writing its own item.
10//
11
12import Foundation
13import Security
14
15nonisolated enum KeychainTokenStore {
16 private static let service = "net.cleberg.octosentry.github-token"
17 private static let account = "github-oauth-token"
18
19 static func save(_ token: String) throws {
20 let query: [String: Any] = [
21 kSecClass as String: kSecClassGenericPassword,
22 kSecAttrService as String: service,
23 kSecAttrAccount as String: account,
24 ]
25 SecItemDelete(query as CFDictionary)
26
27 var attributes = query
28 attributes[kSecValueData as String] = Data(token.utf8)
29 attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
30 attributes[kSecAttrSynchronizable as String] = false
31
32 let status = SecItemAdd(attributes as CFDictionary, nil)
33 guard status == errSecSuccess else {
34 throw KeychainError.unhandled(status)
35 }
36 }
37
38 static func load() -> String? {
39 let query: [String: Any] = [
40 kSecClass as String: kSecClassGenericPassword,
41 kSecAttrService as String: service,
42 kSecAttrAccount as String: account,
43 kSecReturnData as String: true,
44 kSecMatchLimit as String: kSecMatchLimitOne,
45 ]
46
47 var result: AnyObject?
48 let status = SecItemCopyMatching(query as CFDictionary, &result)
49 guard status == errSecSuccess, let data = result as? Data else { return nil }
50 return String(data: data, encoding: .utf8)
51 }
52
53 static func delete() {
54 let query: [String: Any] = [
55 kSecClass as String: kSecClassGenericPassword,
56 kSecAttrService as String: service,
57 kSecAttrAccount as String: account,
58 ]
59 SecItemDelete(query as CFDictionary)
60 }
61
62 enum KeychainError: Error, LocalizedError {
63 case unhandled(OSStatus)
64
65 var errorDescription: String? {
66 switch self {
67 case .unhandled(let status):
68 let message = SecCopyErrorMessageString(status, nil) as String? ?? "unknown"
69 return "Keychain error \(status): \(message)"
70 }
71 }
72 }
73}
octosentry/SecurityEventListView.swift +25 −10
@@ -8,20 +8,24 @@ import SwiftUI
88
99struct SecurityEventListView: View {
1010 var store: SecurityEventStore
11 var authStore: AuthStore
1112 @State private var showingRepoManager = false
1213
1314 var body: some View {
1415 VStack(alignment: .leading, spacing: 0) {
1516 header
1617 Divider()
17 if showingRepoManager {
18 RepoManagerView(store: store)
18 if !authStore.isSignedIn {
19 SignInView(authStore: authStore)
20 } else if showingRepoManager {
21 RepoManagerView(store: store, authStore: authStore)
1922 } else {
2023 content
2124 }
2225 }
2326 .frame(width: 380, height: 420)
24 .task {
27 .task(id: authStore.isSignedIn) {
28 guard authStore.isSignedIn else { return }
2529 await store.refresh()
2630 store.startPolling()
2731 }
@@ -39,7 +43,7 @@ struct SecurityEventListView: View {
3943
4044 Spacer()
4145
42 if !showingRepoManager {
46 if authStore.isSignedIn && !showingRepoManager {
4347 Picker("Minimum severity", selection: Binding(
4448 get: { store.minimumSeverity },
4549 set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
@@ -61,12 +65,14 @@ struct SecurityEventListView: View {
6165 .disabled(store.isLoading)
6266 }
6367
64 Button {
65 showingRepoManager.toggle()
66 } label: {
67 Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape")
68 if authStore.isSignedIn {
69 Button {
70 showingRepoManager.toggle()
71 } label: {
72 Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape")
73 }
74 .buttonStyle(.plain)
6875 }
69 .buttonStyle(.plain)
7076
7177 Button("Quit") {
7278 NSApplication.shared.terminate(nil)
@@ -121,6 +127,7 @@ struct SecurityEventListView: View {
121127
122128private struct RepoManagerView: View {
123129 var store: SecurityEventStore
130 var authStore: AuthStore
124131 @State private var newRepoText = ""
125132
126133 var body: some View {
@@ -167,6 +174,14 @@ private struct RepoManagerView: View {
167174 }
168175
169176 Spacer()
177
178 Divider()
179
180 Button("Sign Out") {
181 authStore.signOut()
182 }
183 .buttonStyle(.plain)
184 .foregroundStyle(.red)
170185 }
171186 .padding(12)
172187 .frame(maxWidth: .infinity, alignment: .leading)
@@ -234,5 +249,5 @@ private struct StatusView: View {
234249}
235250
236251#Preview {
237 SecurityEventListView(store: SecurityEventStore())
252 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore())
238253}
octosentry/SecurityEventStore.swift +4 −4
@@ -3,8 +3,8 @@
33// octosentry
44//
55// Holds the fetched event stream for the popover. Watch list, seen-state,
6// and last-fetch timestamps are persisted (see PersistedState); the PAT
7// is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13).
6// and last-fetch timestamps are persisted (see PersistedState); the token
7// comes from Keychain, put there by the device authorization flow (spec §6).
88//
99// Each alert source is fetched independently, per repo, so a problem
1010// with one endpoint (or one repo) doesn't blank out the rest. A 403/404
@@ -42,8 +42,8 @@ final class SecurityEventStore {
4242 minimumSeverity = state.minimumSeverity
4343 watchedRepos = state.watchedRepos
4444
45 guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else {
46 errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."]
45 guard let token = KeychainTokenStore.load() else {
46 errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."]
4747 return
4848 }
4949
octosentry/SignInView.swift added +86
@@ -0,0 +1,86 @@
1//
2// SignInView.swift
3// octosentry
4//
5
6import AppKit
7import SwiftUI
8
9struct SignInView: View {
10 var authStore: AuthStore
11
12 var body: some View {
13 VStack(spacing: 16) {
14 Spacer()
15
16 switch authStore.state {
17 case .signedOut:
18 signedOutContent
19 case .awaitingAuthorization(let userCode, let verificationURL):
20 awaitingAuthorizationContent(userCode: userCode, verificationURL: verificationURL)
21 case .signedIn:
22 EmptyView()
23 }
24
25 if let errorMessage = authStore.errorMessage {
26 Text(errorMessage)
27 .font(.caption)
28 .foregroundStyle(.red)
29 .multilineTextAlignment(.center)
30 .padding(.horizontal)
31 }
32
33 Spacer()
34 }
35 .frame(maxWidth: .infinity, maxHeight: .infinity)
36 .padding()
37 }
38
39 private var signedOutContent: some View {
40 VStack(spacing: 16) {
41 Image(systemName: "shield.lefthalf.filled")
42 .font(.system(size: 40))
43 .foregroundStyle(.secondary)
44 Text("Sign in with GitHub to see your security alerts.")
45 .font(.callout)
46 .multilineTextAlignment(.center)
47 .foregroundStyle(.secondary)
48 .padding(.horizontal)
49 Button("Sign in with GitHub") {
50 authStore.signIn()
51 }
52 .buttonStyle(.borderedProminent)
53 }
54 }
55
56 private func awaitingAuthorizationContent(userCode: String, verificationURL: URL) -> some View {
57 VStack(spacing: 12) {
58 Text("Enter this code on GitHub")
59 .font(.callout)
60 .foregroundStyle(.secondary)
61
62 Text(userCode)
63 .font(.system(.title, design: .monospaced).weight(.bold))
64 .textSelection(.enabled)
65
66 HStack(spacing: 8) {
67 Button("Copy Code") {
68 NSPasteboard.general.clearContents()
69 NSPasteboard.general.setString(userCode, forType: .string)
70 }
71 Button("Open GitHub") {
72 NSWorkspace.shared.open(verificationURL)
73 }
74 .buttonStyle(.borderedProminent)
75 }
76
77 ProgressView()
78 .controlSize(.small)
79 .padding(.top, 4)
80 }
81 }
82}
83
84#Preview {
85 SignInView(authStore: AuthStore())
86}
octosentry/octosentryApp.swift +2 −1
@@ -10,10 +10,11 @@ import SwiftUI
1010@main
1111struct octosentryApp: App {
1212 @State private var store = SecurityEventStore()
13 @State private var authStore = AuthStore()
1314
1415 var body: some Scene {
1516 MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") {
16 SecurityEventListView(store: store)
17 SecurityEventListView(store: store, authStore: authStore)
1718 }
1819 .menuBarExtraStyle(.window)
1920 }