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.
14- Click an alert to open it directly on github.com 14- Click an alert to open it directly on github.com
15- Errors and unavailable sources (e.g. an alert type disabled for a repo) 15- Errors and unavailable sources (e.g. an alert type disabled for a repo)
16 are surfaced in the popover instead of failing silently 16 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
17- Zero third-party dependencies — pure SwiftUI and URLSession 21- Zero third-party dependencies — pure SwiftUI and URLSession
18 22
19## Requirements 23## Requirements
20 24
21- macOS 14 or later 25- macOS 14 or later
22- A GitHub personal access token (fine-grained or classic) with read 26- A GitHub account with access to whatever repos you want to watch
23 access to Dependabot alerts, code scanning alerts, and secret scanning
24 alerts for the repo you want to watch
25 27
26## Usage 28## Usage
27 29
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
321. Build and run the app (see Building, below). 301. Build and run the app (see Building, below).
332. Set `GITHUB_TOKEN` in your **personal, non-shared** Xcode scheme 312. Click the shield icon in the menu bar, then **Sign in with GitHub**.
34 (Product → Scheme → Edit Scheme… → Run → Arguments → 32 You'll get a short code — click **Open GitHub**, enter the code there,
35 Environment Variables). Don't add it to a shared scheme — that would 33 and authorize. The popover updates automatically once that completes.
36 commit the token to git. 343. Click the gear icon to add or remove watched repos (`owner/repo`).
373. Click the shield icon in the menu bar to open the popover. It fetches
38 automatically on open, or use the refresh button.
394. Click any alert to open it on github.com. 354. Click any alert to open it on github.com.
40 36
41If a source shows as unavailable, it usually means that alert type is 37If 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 38disabled for the repo, or your account lacks permission for it — not
43that something is broken. 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.
44 43
45## Building 44## Building
46 45
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 {
19 var errorDescription: String? { 19 var errorDescription: String? {
20 switch self { 20 switch self {
21 case .missingToken: 21 case .missingToken:
22 "No GitHub token found in the GITHUB_TOKEN environment variable." 22 "Not signed in to GitHub."
23 case .network(let message): 23 case .network(let message):
24 "Network error: \(message)" 24 "Network error: \(message)"
25 case .invalidResponse: 25 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
8 8
9struct SecurityEventListView: View { 9struct SecurityEventListView: View {
10 var store: SecurityEventStore 10 var store: SecurityEventStore
11 var authStore: AuthStore
11 @State private var showingRepoManager = false 12 @State private var showingRepoManager = false
12 13
13 var body: some View { 14 var body: some View {
14 VStack(alignment: .leading, spacing: 0) { 15 VStack(alignment: .leading, spacing: 0) {
15 header 16 header
16 Divider() 17 Divider()
17 if showingRepoManager { 18 if !authStore.isSignedIn {
18 RepoManagerView(store: store) 19 SignInView(authStore: authStore)
20 } else if showingRepoManager {
21 RepoManagerView(store: store, authStore: authStore)
19 } else { 22 } else {
20 content 23 content
21 } 24 }
22 } 25 }
23 .frame(width: 380, height: 420) 26 .frame(width: 380, height: 420)
24 .task { 27 .task(id: authStore.isSignedIn) {
28 guard authStore.isSignedIn else { return }
25 await store.refresh() 29 await store.refresh()
26 store.startPolling() 30 store.startPolling()
27 } 31 }
@@ -39,7 +43,7 @@ struct SecurityEventListView: View {
39 43
40 Spacer() 44 Spacer()
41 45
42 if !showingRepoManager { 46 if authStore.isSignedIn && !showingRepoManager {
43 Picker("Minimum severity", selection: Binding( 47 Picker("Minimum severity", selection: Binding(
44 get: { store.minimumSeverity }, 48 get: { store.minimumSeverity },
45 set: { newValue in Task { await store.setMinimumSeverity(newValue) } } 49 set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
@@ -61,12 +65,14 @@ struct SecurityEventListView: View {
61 .disabled(store.isLoading) 65 .disabled(store.isLoading)
62 } 66 }
63 67
64 Button { 68 if authStore.isSignedIn {
65 showingRepoManager.toggle() 69 Button {
66 } label: { 70 showingRepoManager.toggle()
67 Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape") 71 } label: {
72 Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape")
73 }
74 .buttonStyle(.plain)
68 } 75 }
69 .buttonStyle(.plain)
70 76
71 Button("Quit") { 77 Button("Quit") {
72 NSApplication.shared.terminate(nil) 78 NSApplication.shared.terminate(nil)
@@ -121,6 +127,7 @@ struct SecurityEventListView: View {
121 127
122private struct RepoManagerView: View { 128private struct RepoManagerView: View {
123 var store: SecurityEventStore 129 var store: SecurityEventStore
130 var authStore: AuthStore
124 @State private var newRepoText = "" 131 @State private var newRepoText = ""
125 132
126 var body: some View { 133 var body: some View {
@@ -167,6 +174,14 @@ private struct RepoManagerView: View {
167 } 174 }
168 175
169 Spacer() 176 Spacer()
177
178 Divider()
179
180 Button("Sign Out") {
181 authStore.signOut()
182 }
183 .buttonStyle(.plain)
184 .foregroundStyle(.red)
170 } 185 }
171 .padding(12) 186 .padding(12)
172 .frame(maxWidth: .infinity, alignment: .leading) 187 .frame(maxWidth: .infinity, alignment: .leading)
@@ -234,5 +249,5 @@ private struct StatusView: View {
234} 249}
235 250
236#Preview { 251#Preview {
237 SecurityEventListView(store: SecurityEventStore()) 252 SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore())
238} 253}
octosentry/SecurityEventStore.swift +4 −4
@@ -3,8 +3,8 @@
3// octosentry 3// octosentry
4// 4//
5// Holds the fetched event stream for the popover. Watch list, seen-state, 5// Holds the fetched event stream for the popover. Watch list, seen-state,
6// and last-fetch timestamps are persisted (see PersistedState); the PAT 6// and last-fetch timestamps are persisted (see PersistedState); the token
7// is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13). 7// comes from Keychain, put there by the device authorization flow (spec §6).
8// 8//
9// Each alert source is fetched independently, per repo, so a problem 9// Each alert source is fetched independently, per repo, so a problem
10// with one endpoint (or one repo) doesn't blank out the rest. A 403/404 10// with one endpoint (or one repo) doesn't blank out the rest. A 403/404
@@ -42,8 +42,8 @@ final class SecurityEventStore {
42 minimumSeverity = state.minimumSeverity 42 minimumSeverity = state.minimumSeverity
43 watchedRepos = state.watchedRepos 43 watchedRepos = state.watchedRepos
44 44
45 guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { 45 guard let token = KeychainTokenStore.load() else {
46 errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] 46 errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."]
47 return 47 return
48 } 48 }
49 49
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
10@main 10@main
11struct octosentryApp: App { 11struct octosentryApp: App {
12 @State private var store = SecurityEventStore() 12 @State private var store = SecurityEventStore()
13 @State private var authStore = AuthStore()
13 14
14 var body: some Scene { 15 var body: some Scene {
15 MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") { 16 MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") {
16 SecurityEventListView(store: store) 17 SecurityEventListView(store: store, authStore: authStore)
17 } 18 }
18 .menuBarExtraStyle(.window) 19 .menuBarExtraStyle(.window)
19 } 20 }