Commit ccfd0dafd5
Unsigned
Layout: unified · split
octosentry/Account.swift +39 −6
| @@ -20,9 +20,37 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { | |||
| 20 | var login: String | 20 | var login: String |
| 21 | var keychainAccount: String | 21 | var keychainAccount: String |
| 22 | var hasRepoScope: Bool | 22 | 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 | } | ||
| 23 | 50 | ||
| 24 | var displayName: String { | 51 | 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)" | ||
| 26 | } | 54 | } |
| 27 | 55 | ||
| 28 | /// The account an upgrading install already has a token for. | 56 | /// The account an upgrading install already has a token for. |
| @@ -31,16 +59,21 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { | |||
| 31 | id: 0, | 59 | id: 0, |
| 32 | login: "", | 60 | login: "", |
| 33 | keychainAccount: KeychainTokenStore.legacyAccount, | 61 | keychainAccount: KeychainTokenStore.legacyAccount, |
| 34 | hasRepoScope: false | 62 | hasRepoScope: false, |
| 63 | host: .dotCom | ||
| 35 | ) | 64 | ) |
| 36 | } | 65 | } |
| 37 | 66 | ||
| 38 | static func new(id: Int, login: String, hasRepoScope: Bool) -> Account { | 67 | static func new(id: Int, login: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) -> Account { |
| 39 | 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( | ||
| 40 | id: id, | 72 | id: id, |
| 41 | login: login, | 73 | login: login, |
| 42 | keychainAccount: "account-\(id)", | 74 | keychainAccount: "account-\(suffix)", |
| 43 | hasRepoScope: hasRepoScope | 75 | hasRepoScope: hasRepoScope, |
| 76 | host: host | ||
| 44 | ) | 77 | ) |
| 45 | } | 78 | } |
| 46 | } | 79 | } |
octosentry/AuthStore.swift +25 −18
| @@ -21,7 +21,6 @@ final class AuthStore { | |||
| 21 | private(set) var errorMessage: String? | 21 | private(set) var errorMessage: String? |
| 22 | private(set) var accounts: [Account] = [] | 22 | private(set) var accounts: [Account] = [] |
| 23 | 23 | ||
| 24 | private let client = GitHubDeviceAuthClient() | ||
| 25 | private let persistenceStore = PersistenceStore() | 24 | private let persistenceStore = PersistenceStore() |
| 26 | private var authorizationTask: Task<Void, Never>? | 25 | private var authorizationTask: Task<Void, Never>? |
| 27 | 26 | ||
| @@ -43,21 +42,22 @@ final class AuthStore { | |||
| 43 | accounts.contains(where: \.hasRepoScope) | 42 | accounts.contains(where: \.hasRepoScope) |
| 44 | } | 43 | } |
| 45 | 44 | ||
| 46 | func signIn() { | 45 | func signIn(host: GitHubHost = .dotCom) { |
| 47 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) | 46 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host) |
| 48 | } | 47 | } |
| 49 | 48 | ||
| 50 | /// Adds another identity. Same flow as signing in — GitHub decides which | 49 | /// Adds another identity, optionally on a GitHub Enterprise Server |
| 51 | /// account authorizes the code. | 50 | /// instance. Same flow either way — GitHub decides which account |
| 52 | func addAccount() { | 51 | /// authorizes the code. |
| 53 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) | 52 | func addAccount(host: GitHubHost = .dotCom) { |
| 53 | beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host) | ||
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | /// Re-runs device auth with broader scope so the repo picker can list | 56 | /// Re-runs device auth with broader scope so the repo picker can list |
| 57 | /// repos. Only called explicitly from the repo picker UI, never on | 57 | /// repos. Only called explicitly from the repo picker UI, never on |
| 58 | /// the default sign-in path. | 58 | /// the default sign-in path. |
| 59 | func requestRepoAccess() { | 59 | func requestRepoAccess(host: GitHubHost = .dotCom) { |
| 60 | beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope) | 60 | beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope, host: host) |
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | /// Signs out one account, leaving the others alone. | 63 | /// Signs out one account, leaving the others alone. |
| @@ -121,7 +121,7 @@ final class AuthStore { | |||
| 121 | 121 | ||
| 122 | for account in unresolved { | 122 | for account in unresolved { |
| 123 | guard let token = KeychainTokenStore.load(account: account.keychainAccount), | 123 | 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(), |
| 125 | let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount }) | 125 | let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount }) |
| 126 | else { continue } | 126 | else { continue } |
| 127 | 127 | ||
| @@ -143,13 +143,14 @@ final class AuthStore { | |||
| 143 | } | 143 | } |
| 144 | } | 144 | } |
| 145 | 145 | ||
| 146 | private func beginAuthorization(scope: String) { | 146 | private func beginAuthorization(scope: String, host: GitHubHost) { |
| 147 | guard authorizationTask == nil else { return } | 147 | guard authorizationTask == nil else { return } |
| 148 | errorMessage = nil | 148 | errorMessage = nil |
| 149 | 149 | ||
| 150 | authorizationTask = Task { | 150 | authorizationTask = Task { |
| 151 | defer { authorizationTask = nil } | 151 | defer { authorizationTask = nil } |
| 152 | do { | 152 | do { |
| 153 | let client = GitHubDeviceAuthClient(host: host) | ||
| 153 | let deviceCode = try await client.requestDeviceCode(scope: scope) | 154 | let deviceCode = try await client.requestDeviceCode(scope: scope) |
| 154 | state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) | 155 | state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) |
| 155 | 156 | ||
| @@ -158,7 +159,7 @@ final class AuthStore { | |||
| 158 | interval: deviceCode.interval, | 159 | interval: deviceCode.interval, |
| 159 | expiresIn: deviceCode.expiresIn | 160 | expiresIn: deviceCode.expiresIn |
| 160 | ) | 161 | ) |
| 161 | try await register(token: token, grantedRepoScope: scope.contains("repo")) | 162 | try await register(token: token, grantedRepoScope: scope.contains("repo"), host: host) |
| 162 | state = .signedIn | 163 | state = .signedIn |
| 163 | } catch { | 164 | } catch { |
| 164 | errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription | 165 | errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription |
| @@ -173,30 +174,36 @@ final class AuthStore { | |||
| 173 | /// Stores a freshly authorized token under its own Keychain item and | 174 | /// Stores a freshly authorized token under its own Keychain item and |
| 174 | /// records the account. Re-authorizing an account already present updates | 175 | /// records the account. Re-authorizing an account already present updates |
| 175 | /// it in place rather than adding a duplicate. | 176 | /// it in place rather than adding a duplicate. |
| 176 | private func register(token: String, grantedRepoScope: Bool) async throws { | 177 | private func register(token: String, grantedRepoScope: Bool, host: GitHubHost) async throws { |
| 177 | let user = try await GitHubSecurityAPIClient(token: token).fetchCurrentUser() | 178 | let user = try await GitHubSecurityAPIClient(token: token, host: host).fetchCurrentUser() |
| 178 | 179 | ||
| 179 | var persisted = await persistenceStore.load() | 180 | var persisted = await persistenceStore.load() |
| 180 | 181 | ||
| 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 }) { |
| 182 | persisted.accounts[index].login = user.login | 183 | persisted.accounts[index].login = user.login |
| 183 | persisted.accounts[index].hasRepoScope = grantedRepoScope | 184 | persisted.accounts[index].hasRepoScope = grantedRepoScope |
| 184 | try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount) | 185 | 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 }) { |
| 186 | // The adopted single-account entry, now identified. | 187 | // The adopted single-account entry, now identified. |
| 187 | let keychainAccount = persisted.accounts[index].keychainAccount | 188 | let keychainAccount = persisted.accounts[index].keychainAccount |
| 188 | persisted.accounts[index] = Account( | 189 | persisted.accounts[index] = Account( |
| 189 | id: user.id, | 190 | id: user.id, |
| 190 | login: user.login, | 191 | login: user.login, |
| 191 | keychainAccount: keychainAccount, | 192 | keychainAccount: keychainAccount, |
| 192 | hasRepoScope: grantedRepoScope | 193 | hasRepoScope: grantedRepoScope, |
| 194 | host: host | ||
| 193 | ) | 195 | ) |
| 194 | for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 { | 196 | for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 { |
| 195 | persisted.watchedRepos[repoIndex].accountID = user.id | 197 | persisted.watchedRepos[repoIndex].accountID = user.id |
| 196 | } | 198 | } |
| 197 | try KeychainTokenStore.save(token, account: keychainAccount) | 199 | try KeychainTokenStore.save(token, account: keychainAccount) |
| 198 | } else { | 200 | } 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 | ) | ||
| 200 | try KeychainTokenStore.save(token, account: account.keychainAccount) | 207 | try KeychainTokenStore.save(token, account: account.keychainAccount) |
| 201 | persisted.accounts.append(account) | 208 | persisted.accounts.append(account) |
| 202 | } | 209 | } |
octosentry/GitHubDeviceAuthClient.swift +10 −5
| @@ -12,8 +12,12 @@ import Foundation | |||
| 12 | 12 | ||
| 13 | actor GitHubDeviceAuthClient { | 13 | actor GitHubDeviceAuthClient { |
| 14 | // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). | 14 | // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). |
| 15 | // Not a secret — safe to embed in source. | 15 | // Not a secret — safe to embed in source. A GitHub Enterprise Server |
| 16 | private let clientID = "Ov23li6tqaTghDc4IJYv" | 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 } | ||
| 17 | 21 | ||
| 18 | // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert | 22 | // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert |
| 19 | // access. Classic OAuth scopes have no read-only variant (unlike fine-grained | 23 | // access. Classic OAuth scopes have no read-only variant (unlike fine-grained |
| @@ -26,13 +30,14 @@ actor GitHubDeviceAuthClient { | |||
| 26 | 30 | ||
| 27 | private let session: URLSession | 31 | private let session: URLSession |
| 28 | 32 | ||
| 29 | init(session: URLSession = .shared) { | 33 | init(host: GitHubHost = .dotCom, session: URLSession = .shared) { |
| 34 | self.host = host | ||
| 30 | self.session = session | 35 | self.session = session |
| 31 | } | 36 | } |
| 32 | 37 | ||
| 33 | func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse { | 38 | func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse { |
| 34 | let data = try await post( | 39 | let data = try await post( |
| 35 | url: URL(string: "https://github.com/login/device/code")!, | 40 | url: host.deviceCodeURL, |
| 36 | parameters: ["client_id": clientID, "scope": scope] | 41 | parameters: ["client_id": clientID, "scope": scope] |
| 37 | ) | 42 | ) |
| 38 | do { | 43 | do { |
| @@ -52,7 +57,7 @@ actor GitHubDeviceAuthClient { | |||
| 52 | try Task.checkCancellation() | 57 | try Task.checkCancellation() |
| 53 | 58 | ||
| 54 | let data = try await post( | 59 | let data = try await post( |
| 55 | url: URL(string: "https://github.com/login/oauth/access_token")!, | 60 | url: host.accessTokenURL, |
| 56 | parameters: [ | 61 | parameters: [ |
| 57 | "client_id": clientID, | 62 | "client_id": clientID, |
| 58 | "device_code": deviceCode, | 63 | "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 | |||
| 16 | import Foundation | ||
| 17 | |||
| 18 | nonisolated 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 | |||
| 13 | actor GitHubSecurityAPIClient { | 13 | actor GitHubSecurityAPIClient { |
| 14 | private let token: String | 14 | private let token: String |
| 15 | private let session: URLSession | 15 | private let session: URLSession |
| 16 | private let baseURL = URL(string: "https://api.github.com")! | 16 | private let baseURL: URL |
| 17 | 17 | ||
| 18 | private static let decoder: JSONDecoder = { | 18 | private static let decoder: JSONDecoder = { |
| 19 | let decoder = JSONDecoder() | 19 | let decoder = JSONDecoder() |
| @@ -21,8 +21,9 @@ actor GitHubSecurityAPIClient { | |||
| 21 | return decoder | 21 | return decoder |
| 22 | }() | 22 | }() |
| 23 | 23 | ||
| 24 | init(token: String, session: URLSession = .shared) { | 24 | init(token: String, host: GitHubHost = .dotCom, session: URLSession = .shared) { |
| 25 | self.token = token | 25 | self.token = token |
| 26 | self.baseURL = host.apiBaseURL | ||
| 26 | self.session = session | 27 | self.session = session |
| 27 | } | 28 | } |
| 28 | 29 | ||
| @@ -94,7 +95,7 @@ actor GitHubSecurityAPIClient { | |||
| 94 | /// sign-in scope). Used by the repo picker (#15). | 95 | /// sign-in scope). Used by the repo picker (#15). |
| 95 | func fetchAccessibleRepos() async throws -> [String] { | 96 | func fetchAccessibleRepos() async throws -> [String] { |
| 96 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! | 97 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 97 | components.path = "/user/repos" | 98 | components.path = baseURL.path + "/user/repos" |
| 98 | components.queryItems = [ | 99 | components.queryItems = [ |
| 99 | URLQueryItem(name: "per_page", value: "100"), | 100 | URLQueryItem(name: "per_page", value: "100"), |
| 100 | URLQueryItem(name: "sort", value: "full_name"), | 101 | URLQueryItem(name: "sort", value: "full_name"), |
| @@ -107,14 +108,14 @@ actor GitHubSecurityAPIClient { | |||
| 107 | /// alerts attributed. Needs no scope beyond a valid user token. | 108 | /// alerts attributed. Needs no scope beyond a valid user token. |
| 108 | func fetchCurrentUser() async throws -> GitHubUserDTO { | 109 | func fetchCurrentUser() async throws -> GitHubUserDTO { |
| 109 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! | 110 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 110 | components.path = "/user" | 111 | components.path = baseURL.path + "/user" |
| 111 | let (data, _) = try await fetchData(url: components.url!) | 112 | let (data, _) = try await fetchData(url: components.url!) |
| 112 | return try decode(data) | 113 | return try decode(data) |
| 113 | } | 114 | } |
| 114 | 115 | ||
| 115 | private func alertsURL(owner: String, repo: String, path: String) -> URL { | 116 | private func alertsURL(owner: String, repo: String, path: String) -> URL { |
| 116 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! | 117 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 117 | components.path = "/repos/\(owner)/\(repo)/\(path)" | 118 | components.path = baseURL.path + "/repos/\(owner)/\(repo)/\(path)" |
| 118 | components.queryItems = [ | 119 | components.queryItems = [ |
| 119 | URLQueryItem(name: "state", value: "open"), | 120 | URLQueryItem(name: "state", value: "open"), |
| 120 | URLQueryItem(name: "per_page", value: "100"), | 121 | URLQueryItem(name: "per_page", value: "100"), |
octosentry/SecurityEventListView.swift +49 −1
| @@ -347,6 +347,8 @@ private struct RepoManagerView: View { | |||
| 347 | .buttonStyle(.plain) | 347 | .buttonStyle(.plain) |
| 348 | .foregroundStyle(Color.accentColor) | 348 | .foregroundStyle(Color.accentColor) |
| 349 | 349 | ||
| 350 | EnterpriseSignInView(authStore: authStore) | ||
| 351 | |||
| 350 | if let errorMessage = store.watchListErrorMessage { | 352 | if let errorMessage = store.watchListErrorMessage { |
| 351 | Text(errorMessage) | 353 | Text(errorMessage) |
| 352 | .font(.caption2) | 354 | .font(.caption2) |
| @@ -485,7 +487,7 @@ private struct RepoManagerView: View { | |||
| 485 | 487 | ||
| 486 | private func startBrowsing(_ account: Account) { | 488 | private func startBrowsing(_ account: Account) { |
| 487 | guard account.hasRepoScope else { | 489 | guard account.hasRepoScope else { |
| 488 | authStore.requestRepoAccess() | 490 | authStore.requestRepoAccess(host: account.host) |
| 489 | return | 491 | return |
| 490 | } | 492 | } |
| 491 | browsingAccount = account | 493 | browsingAccount = account |
| @@ -509,6 +511,52 @@ private struct RepoManagerView: View { | |||
| 509 | } | 511 | } |
| 510 | } | 512 | } |
| 511 | 513 | ||
| 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. | ||
| 517 | private 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 | |||
| 512 | private struct FilterLabel: View { | 560 | private struct FilterLabel: View { |
| 513 | let title: String | 561 | let title: String |
| 514 | let count: Int | 562 | let count: Int |
octosentry/SecurityEventStore.swift +2 −2
| @@ -111,7 +111,7 @@ final class SecurityEventStore { | |||
| 111 | errors.append("\(repoFullName): no signed-in account can reach this repo.") | 111 | errors.append("\(repoFullName): no signed-in account can reach this repo.") |
| 112 | continue | 112 | continue |
| 113 | } | 113 | } |
| 114 | let client = GitHubSecurityAPIClient(token: token) | 114 | let client = GitHubSecurityAPIClient(token: token, host: account.host) |
| 115 | let label = accountsByID.count > 1 | 115 | let label = accountsByID.count > 1 |
| 116 | ? "\(repoFullName) (\(account.displayName))" | 116 | ? "\(repoFullName) (\(account.displayName))" |
| 117 | : repoFullName | 117 | : repoFullName |
| @@ -257,7 +257,7 @@ final class SecurityEventStore { | |||
| 257 | guard let token = KeychainTokenStore.load(account: account.keychainAccount) else { | 257 | guard let token = KeychainTokenStore.load(account: account.keychainAccount) else { |
| 258 | throw GitHubAPIError.missingToken | 258 | throw GitHubAPIError.missingToken |
| 259 | } | 259 | } |
| 260 | return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos() | 260 | return try await GitHubSecurityAPIClient(token: token, host: account.host).fetchAccessibleRepos() |
| 261 | } | 261 | } |
| 262 | 262 | ||
| 263 | /// Local-only triage state (spec §11) — no API write, no scope beyond | 263 | /// 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 | |||
| 6 | import Foundation | ||
| 7 | import Testing | ||
| 8 | @testable import octosentry | ||
| 9 | |||
| 10 | struct 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 | } | ||