krz/octosentry

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

Commit ccfd0dafd5

ccfd0dafd5eebc40310860131c354053731618aa

parent: bee5f10a92

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 21:33 UTC
committer: <noreply@github.com>

Support GitHub Enterprise Server (#34)

The API base URL is configurable per account, defaulting to github.com.
Enterprise Server differs structurally, not just by hostname: REST lives
at https://HOST/api/v3 on the same host, and device flow needs an OAuth
app registered on the instance, so the client ID travels with the host.

Host is a property of an account rather than a global setting, so
github.com and an employer's instance can be watched at once. Keychain
items for Enterprise accounts are namespaced by host, since user ids
are only unique within an instance.

Account gets an explicit decoder: synthesized decoding ignores property
defaults, so an account written before this change would fail to decode
and take the whole state file with it.

Deep-links needed no change — rows already open each alert's html_url
from the API response, which Enterprise returns pointing at its own
host.

Closes #20

Layout: unified · split

octosentry/Account.swift +39 −6
@@ -20,9 +20,37 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable {
2020 var login: String
2121 var keychainAccount: String
2222 var hasRepoScope: Bool
23 /// Which GitHub this account lives on. Absent in files written before
24 /// Enterprise support, which means github.com.
25 var host: GitHubHost = .dotCom
26
27 enum CodingKeys: String, CodingKey {
28 case id, login, keychainAccount, hasRepoScope, host
29 }
30
31 init(id: Int, login: String, keychainAccount: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) {
32 self.id = id
33 self.login = login
34 self.keychainAccount = keychainAccount
35 self.hasRepoScope = hasRepoScope
36 self.host = host
37 }
38
39 // Synthesized decoding ignores property defaults, so an account written
40 // before Enterprise support would fail to decode and take the whole
41 // state file down with it.
42 init(from decoder: Decoder) throws {
43 let container = try decoder.container(keyedBy: CodingKeys.self)
44 id = try container.decode(Int.self, forKey: .id)
45 login = try container.decode(String.self, forKey: .login)
46 keychainAccount = try container.decode(String.self, forKey: .keychainAccount)
47 hasRepoScope = try container.decode(Bool.self, forKey: .hasRepoScope)
48 host = try container.decodeIfPresent(GitHubHost.self, forKey: .host) ?? .dotCom
49 }
2350
2451 var displayName: String {
25 login.isEmpty ? "GitHub account" : login
52 let name = login.isEmpty ? "GitHub account" : login
53 return host.isDotCom ? name : "\(name) @ \(host.displayName)"
2654 }
2755
2856 /// The account an upgrading install already has a token for.
@@ -31,16 +59,21 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable {
3159 id: 0,
3260 login: "",
3361 keychainAccount: KeychainTokenStore.legacyAccount,
34 hasRepoScope: false
62 hasRepoScope: false,
63 host: .dotCom
3564 )
3665 }
3766
38 static func new(id: Int, login: String, hasRepoScope: Bool) -> Account {
39 Account(
67 static func new(id: Int, login: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) -> Account {
68 // Ids are only unique within an instance, so a GHES account's
69 // Keychain item is namespaced by host too.
70 let suffix = host.isDotCom ? "\(id)" : "\(host.displayName)-\(id)"
71 return Account(
4072 id: id,
4173 login: login,
42 keychainAccount: "account-\(id)",
43 hasRepoScope: hasRepoScope
74 keychainAccount: "account-\(suffix)",
75 hasRepoScope: hasRepoScope,
76 host: host
4477 )
4578 }
4679}
octosentry/AuthStore.swift +25 −18
@@ -21,7 +21,6 @@ final class AuthStore {
2121 private(set) var errorMessage: String?
2222 private(set) var accounts: [Account] = []
2323
24 private let client = GitHubDeviceAuthClient()
2524 private let persistenceStore = PersistenceStore()
2625 private var authorizationTask: Task<Void, Never>?
2726
@@ -43,21 +42,22 @@ final class AuthStore {
4342 accounts.contains(where: \.hasRepoScope)
4443 }
4544
46 func signIn() {
47 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
45 func signIn(host: GitHubHost = .dotCom) {
46 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host)
4847 }
4948
50 /// Adds another identity. Same flow as signing in — GitHub decides which
51 /// account authorizes the code.
52 func addAccount() {
53 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
49 /// Adds another identity, optionally on a GitHub Enterprise Server
50 /// instance. Same flow either way — GitHub decides which account
51 /// authorizes the code.
52 func addAccount(host: GitHubHost = .dotCom) {
53 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host)
5454 }
5555
5656 /// Re-runs device auth with broader scope so the repo picker can list
5757 /// repos. Only called explicitly from the repo picker UI, never on
5858 /// the default sign-in path.
59 func requestRepoAccess() {
60 beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope)
59 func requestRepoAccess(host: GitHubHost = .dotCom) {
60 beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope, host: host)
6161 }
6262
6363 /// Signs out one account, leaving the others alone.
@@ -121,7 +121,7 @@ final class AuthStore {
121121
122122 for account in unresolved {
123123 guard let token = KeychainTokenStore.load(account: account.keychainAccount),
124 let user = try? await GitHubSecurityAPIClient(token: token).fetchCurrentUser(),
124 let user = try? await GitHubSecurityAPIClient(token: token, host: account.host).fetchCurrentUser(),
125125 let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount })
126126 else { continue }
127127
@@ -143,13 +143,14 @@ final class AuthStore {
143143 }
144144 }
145145
146 private func beginAuthorization(scope: String) {
146 private func beginAuthorization(scope: String, host: GitHubHost) {
147147 guard authorizationTask == nil else { return }
148148 errorMessage = nil
149149
150150 authorizationTask = Task {
151151 defer { authorizationTask = nil }
152152 do {
153 let client = GitHubDeviceAuthClient(host: host)
153154 let deviceCode = try await client.requestDeviceCode(scope: scope)
154155 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
155156
@@ -158,7 +159,7 @@ final class AuthStore {
158159 interval: deviceCode.interval,
159160 expiresIn: deviceCode.expiresIn
160161 )
161 try await register(token: token, grantedRepoScope: scope.contains("repo"))
162 try await register(token: token, grantedRepoScope: scope.contains("repo"), host: host)
162163 state = .signedIn
163164 } catch {
164165 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
@@ -173,30 +174,36 @@ final class AuthStore {
173174 /// Stores a freshly authorized token under its own Keychain item and
174175 /// records the account. Re-authorizing an account already present updates
175176 /// it in place rather than adding a duplicate.
176 private func register(token: String, grantedRepoScope: Bool) async throws {
177 let user = try await GitHubSecurityAPIClient(token: token).fetchCurrentUser()
177 private func register(token: String, grantedRepoScope: Bool, host: GitHubHost) async throws {
178 let user = try await GitHubSecurityAPIClient(token: token, host: host).fetchCurrentUser()
178179
179180 var persisted = await persistenceStore.load()
180181
181 if let index = persisted.accounts.firstIndex(where: { $0.id == user.id }) {
182 if let index = persisted.accounts.firstIndex(where: { $0.id == user.id && $0.host == host }) {
182183 persisted.accounts[index].login = user.login
183184 persisted.accounts[index].hasRepoScope = grantedRepoScope
184185 try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount)
185 } else if let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) {
186 } else if host.isDotCom, let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) {
186187 // The adopted single-account entry, now identified.
187188 let keychainAccount = persisted.accounts[index].keychainAccount
188189 persisted.accounts[index] = Account(
189190 id: user.id,
190191 login: user.login,
191192 keychainAccount: keychainAccount,
192 hasRepoScope: grantedRepoScope
193 hasRepoScope: grantedRepoScope,
194 host: host
193195 )
194196 for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 {
195197 persisted.watchedRepos[repoIndex].accountID = user.id
196198 }
197199 try KeychainTokenStore.save(token, account: keychainAccount)
198200 } else {
199 let account = Account.new(id: user.id, login: user.login, hasRepoScope: grantedRepoScope)
201 let account = Account.new(
202 id: user.id,
203 login: user.login,
204 hasRepoScope: grantedRepoScope,
205 host: host
206 )
200207 try KeychainTokenStore.save(token, account: account.keychainAccount)
201208 persisted.accounts.append(account)
202209 }
octosentry/GitHubDeviceAuthClient.swift +10 −5
@@ -12,8 +12,12 @@ import Foundation
1212
1313actor GitHubDeviceAuthClient {
1414 // 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"
15 // Not a secret — safe to embed in source. A GitHub Enterprise Server
16 // instance needs its own admin-registered app instead (#20).
17 static let dotComClientID = "Ov23li6tqaTghDc4IJYv"
18
19 private let host: GitHubHost
20 private var clientID: String { host.clientID ?? Self.dotComClientID }
1721
1822 // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert
1923 // access. Classic OAuth scopes have no read-only variant (unlike fine-grained
@@ -26,13 +30,14 @@ actor GitHubDeviceAuthClient {
2630
2731 private let session: URLSession
2832
29 init(session: URLSession = .shared) {
33 init(host: GitHubHost = .dotCom, session: URLSession = .shared) {
34 self.host = host
3035 self.session = session
3136 }
3237
3338 func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse {
3439 let data = try await post(
35 url: URL(string: "https://github.com/login/device/code")!,
40 url: host.deviceCodeURL,
3641 parameters: ["client_id": clientID, "scope": scope]
3742 )
3843 do {
@@ -52,7 +57,7 @@ actor GitHubDeviceAuthClient {
5257 try Task.checkCancellation()
5358
5459 let data = try await post(
55 url: URL(string: "https://github.com/login/oauth/access_token")!,
60 url: host.accessTokenURL,
5661 parameters: [
5762 "client_id": clientID,
5863 "device_code": deviceCode,
octosentry/GitHubHost.swift added +67
@@ -0,0 +1,67 @@
1//
2// GitHubHost.swift
3// octosentry
4//
5// Which GitHub a token talks to. github.com and GitHub Enterprise Server
6// differ in more than a hostname: GHES puts the REST API under /api/v3 on
7// the same host rather than on a separate api. domain, and its device flow
8// needs an OAuth app registered by an instance admin, so octosentry's own
9// client ID doesn't apply.
10//
11// Host is a property of an account (#19), not a global setting — someone
12// can reasonably watch repos on github.com and on their employer's GHES at
13// the same time.
14//
15
16import Foundation
17
18nonisolated struct GitHubHost: Codable, Equatable, Hashable {
19 /// nil means github.com. Otherwise the GHES web host, without a scheme.
20 var host: String?
21 /// Required for GHES; github.com uses octosentry's registered app.
22 var clientID: String?
23
24 static let dotCom = GitHubHost(host: nil, clientID: nil)
25
26 /// Accepts what a user is likely to paste — with or without a scheme,
27 /// with or without a trailing slash or path.
28 init(host: String?, clientID: String?) {
29 self.host = host.flatMap(Self.normalize)
30 let trimmedClientID = clientID?.trimmingCharacters(in: .whitespacesAndNewlines)
31 self.clientID = (trimmedClientID?.isEmpty == false) ? trimmedClientID : nil
32 }
33
34 private static func normalize(_ raw: String) -> String? {
35 var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
36 for prefix in ["https://", "http://"] where value.hasPrefix(prefix) {
37 value.removeFirst(prefix.count)
38 }
39 if let slash = value.firstIndex(of: "/") {
40 value = String(value[value.startIndex..<slash])
41 }
42 guard !value.isEmpty, value != "github.com" else { return nil }
43 return value
44 }
45
46 var isDotCom: Bool { host == nil }
47
48 var displayName: String { host ?? "github.com" }
49
50 /// GHES serves the REST API from the same host under /api/v3.
51 var apiBaseURL: URL {
52 guard let host else { return URL(string: "https://api.github.com")! }
53 return URL(string: "https://\(host)/api/v3")!
54 }
55
56 var webBaseURL: URL {
57 URL(string: "https://\(host ?? "github.com")")!
58 }
59
60 var deviceCodeURL: URL {
61 webBaseURL.appendingPathComponent("login/device/code")
62 }
63
64 var accessTokenURL: URL {
65 webBaseURL.appendingPathComponent("login/oauth/access_token")
66 }
67}
octosentry/GitHubSecurityAPIClient.swift +6 −5
@@ -13,7 +13,7 @@ import Foundation
1313actor GitHubSecurityAPIClient {
1414 private let token: String
1515 private let session: URLSession
16 private let baseURL = URL(string: "https://api.github.com")!
16 private let baseURL: URL
1717
1818 private static let decoder: JSONDecoder = {
1919 let decoder = JSONDecoder()
@@ -21,8 +21,9 @@ actor GitHubSecurityAPIClient {
2121 return decoder
2222 }()
2323
24 init(token: String, session: URLSession = .shared) {
24 init(token: String, host: GitHubHost = .dotCom, session: URLSession = .shared) {
2525 self.token = token
26 self.baseURL = host.apiBaseURL
2627 self.session = session
2728 }
2829
@@ -94,7 +95,7 @@ actor GitHubSecurityAPIClient {
9495 /// sign-in scope). Used by the repo picker (#15).
9596 func fetchAccessibleRepos() async throws -> [String] {
9697 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
97 components.path = "/user/repos"
98 components.path = baseURL.path + "/user/repos"
9899 components.queryItems = [
99100 URLQueryItem(name: "per_page", value: "100"),
100101 URLQueryItem(name: "sort", value: "full_name"),
@@ -107,14 +108,14 @@ actor GitHubSecurityAPIClient {
107108 /// alerts attributed. Needs no scope beyond a valid user token.
108109 func fetchCurrentUser() async throws -> GitHubUserDTO {
109110 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
110 components.path = "/user"
111 components.path = baseURL.path + "/user"
111112 let (data, _) = try await fetchData(url: components.url!)
112113 return try decode(data)
113114 }
114115
115116 private func alertsURL(owner: String, repo: String, path: String) -> URL {
116117 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
117 components.path = "/repos/\(owner)/\(repo)/\(path)"
118 components.path = baseURL.path + "/repos/\(owner)/\(repo)/\(path)"
118119 components.queryItems = [
119120 URLQueryItem(name: "state", value: "open"),
120121 URLQueryItem(name: "per_page", value: "100"),
octosentry/SecurityEventListView.swift +49 −1
@@ -347,6 +347,8 @@ private struct RepoManagerView: View {
347347 .buttonStyle(.plain)
348348 .foregroundStyle(Color.accentColor)
349349
350 EnterpriseSignInView(authStore: authStore)
351
350352 if let errorMessage = store.watchListErrorMessage {
351353 Text(errorMessage)
352354 .font(.caption2)
@@ -485,7 +487,7 @@ private struct RepoManagerView: View {
485487
486488 private func startBrowsing(_ account: Account) {
487489 guard account.hasRepoScope else {
488 authStore.requestRepoAccess()
490 authStore.requestRepoAccess(host: account.host)
489491 return
490492 }
491493 browsingAccount = account
@@ -509,6 +511,52 @@ private struct RepoManagerView: View {
509511 }
510512}
511513
514/// Signing in to a GitHub Enterprise Server instance. Collapsed by default —
515/// most people are on github.com, and GHES needs details they have to get
516/// from an instance admin.
517private struct EnterpriseSignInView: View {
518 var authStore: AuthStore
519 @State private var isExpanded = false
520 @State private var hostText = ""
521 @State private var clientIDText = ""
522
523 private var host: GitHubHost {
524 GitHubHost(host: hostText, clientID: clientIDText)
525 }
526
527 var body: some View {
528 VStack(alignment: .leading, spacing: 6) {
529 Button {
530 isExpanded.toggle()
531 } label: {
532 Label("Add a GitHub Enterprise account", systemImage: "building.2")
533 .font(.caption)
534 }
535 .buttonStyle(.plain)
536 .foregroundStyle(Color.accentColor)
537
538 if isExpanded {
539 TextField("github.example.com", text: $hostText)
540 .textFieldStyle(.roundedBorder)
541 TextField("OAuth app client ID", text: $clientIDText)
542 .textFieldStyle(.roundedBorder)
543
544 Text("Device flow on Enterprise Server needs an OAuth app registered on the instance. Ask an admin for its client ID.")
545 .font(.caption2)
546 .foregroundStyle(.secondary)
547
548 Button("Sign In") {
549 authStore.addAccount(host: host)
550 isExpanded = false
551 hostText = ""
552 clientIDText = ""
553 }
554 .disabled(host.isDotCom || host.clientID == nil)
555 }
556 }
557 }
558}
559
512560private struct FilterLabel: View {
513561 let title: String
514562 let count: Int
octosentry/SecurityEventStore.swift +2 −2
@@ -111,7 +111,7 @@ final class SecurityEventStore {
111111 errors.append("\(repoFullName): no signed-in account can reach this repo.")
112112 continue
113113 }
114 let client = GitHubSecurityAPIClient(token: token)
114 let client = GitHubSecurityAPIClient(token: token, host: account.host)
115115 let label = accountsByID.count > 1
116116 ? "\(repoFullName) (\(account.displayName))"
117117 : repoFullName
@@ -257,7 +257,7 @@ final class SecurityEventStore {
257257 guard let token = KeychainTokenStore.load(account: account.keychainAccount) else {
258258 throw GitHubAPIError.missingToken
259259 }
260 return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos()
260 return try await GitHubSecurityAPIClient(token: token, host: account.host).fetchAccessibleRepos()
261261 }
262262
263263 /// Local-only triage state (spec §11) — no API write, no scope beyond
octosentryTests/GitHubHostTests.swift added +124
@@ -0,0 +1,124 @@
1//
2// GitHubHostTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct GitHubHostTests {
11
12 // MARK: - github.com
13
14 @Test func theDefaultIsGitHubDotCom() {
15 #expect(GitHubHost.dotCom.isDotCom)
16 #expect(GitHubHost.dotCom.displayName == "github.com")
17 #expect(GitHubHost.dotCom.apiBaseURL.absoluteString == "https://api.github.com")
18 #expect(GitHubHost.dotCom.webBaseURL.absoluteString == "https://github.com")
19 }
20
21 @Test func dotComKeepsItsExistingAuthEndpoints() {
22 #expect(GitHubHost.dotCom.deviceCodeURL.absoluteString == "https://github.com/login/device/code")
23 #expect(GitHubHost.dotCom.accessTokenURL.absoluteString == "https://github.com/login/oauth/access_token")
24 }
25
26 // Naming github.com explicitly is still github.com, not an enterprise host.
27 @Test func namingGitHubDotComExplicitlyIsNotEnterprise() {
28 #expect(GitHubHost(host: "github.com", clientID: "abc").isDotCom)
29 #expect(GitHubHost(host: "https://github.com", clientID: "abc").isDotCom)
30 }
31
32 // MARK: - Enterprise Server
33
34 // GHES serves the REST API from the same host under /api/v3, not from a
35 // separate api. domain.
36 @Test func enterpriseAPILivesUnderApiV3OnTheSameHost() {
37 let host = GitHubHost(host: "github.example.com", clientID: "abc")
38
39 #expect(host.apiBaseURL.absoluteString == "https://github.example.com/api/v3")
40 #expect(host.webBaseURL.absoluteString == "https://github.example.com")
41 #expect(!host.isDotCom)
42 }
43
44 @Test func enterpriseAuthEndpointsAreOnTheInstance() {
45 let host = GitHubHost(host: "github.example.com", clientID: "abc")
46
47 #expect(host.deviceCodeURL.absoluteString == "https://github.example.com/login/device/code")
48 #expect(host.accessTokenURL.absoluteString == "https://github.example.com/login/oauth/access_token")
49 }
50
51 // MARK: - Normalizing what a user pastes
52
53 @Test func aPastedURLIsReducedToItsHost() {
54 for input in [
55 "https://github.example.com",
56 "http://github.example.com",
57 "github.example.com/",
58 "https://github.example.com/some/path",
59 " GitHub.Example.com ",
60 ] {
61 #expect(GitHubHost(host: input, clientID: "abc").host == "github.example.com", "input: \(input)")
62 }
63 }
64
65 @Test func anEmptyHostMeansGitHubDotCom() {
66 #expect(GitHubHost(host: "", clientID: nil).isDotCom)
67 #expect(GitHubHost(host: " ", clientID: nil).isDotCom)
68 #expect(GitHubHost(host: nil, clientID: nil).isDotCom)
69 }
70
71 @Test func anEmptyClientIDIsTreatedAsAbsent() {
72 #expect(GitHubHost(host: "github.example.com", clientID: "").clientID == nil)
73 #expect(GitHubHost(host: "github.example.com", clientID: " ").clientID == nil)
74 #expect(GitHubHost(host: "github.example.com", clientID: " abc ").clientID == "abc")
75 }
76
77 // MARK: - Persistence
78
79 @Test func roundTripsThroughCodable() throws {
80 for host in [GitHubHost.dotCom, GitHubHost(host: "github.example.com", clientID: "abc")] {
81 let decoded = try JSONDecoder().decode(GitHubHost.self, from: try JSONEncoder().encode(host))
82 #expect(decoded == host)
83 }
84 }
85
86 // An account written before Enterprise support has no host field.
87 @Test func anAccountWithoutAHostFieldDecodesAsGitHubDotCom() throws {
88 let json = """
89 {"id": 7, "login": "octocat", "keychainAccount": "account-7", "hasRepoScope": false}
90 """
91
92 let account = try JSONDecoder().decode(Account.self, from: Data(json.utf8))
93
94 #expect(account.host == .dotCom)
95 #expect(account.displayName == "octocat")
96 }
97
98 // MARK: - Accounts
99
100 // Ids are only unique within an instance, so two accounts that happen to
101 // share an id on different hosts must not share a Keychain item.
102 @Test func sameIdOnDifferentHostsGetsDifferentKeychainItems() {
103 let dotCom = Account.new(id: 7, login: "octocat", hasRepoScope: false)
104 let enterprise = Account.new(
105 id: 7,
106 login: "octocat",
107 hasRepoScope: false,
108 host: GitHubHost(host: "github.example.com", clientID: "abc")
109 )
110
111 #expect(dotCom.keychainAccount != enterprise.keychainAccount)
112 }
113
114 @Test func enterpriseAccountsAreLabelledWithTheirHost() {
115 let account = Account.new(
116 id: 7,
117 login: "octocat",
118 hasRepoScope: false,
119 host: GitHubHost(host: "github.example.com", clientID: "abc")
120 )
121
122 #expect(account.displayName == "octocat @ github.example.com")
123 }
124}