From c8cdc7c7138cfef06529816dee1527edc6a6608a Mon Sep 17 00:00:00 2001 From: Jakub Kiermasz Date: Mon, 19 Jan 2026 16:25:00 +0100 Subject: [PATCH 1/2] feat(auth): add Personal Access Token authentication Support PAT as alternative to OAuth for users in SAML SSO organizations. PATs can be authorized for SSO in GitHub settings, solving visibility issues for organization-owned repositories. - Add AuthMethod enum to UserSettings - Add PAT storage methods to TokenStore - Create PATAuthenticator for token validation - Update AccountSettingsView with auth method picker - Add tests for PAT storage and validation Co-Authored-By: Claude Sonnet 4.5 --- Sources/RepoBar/App/AppState+Auth.swift | 43 +++ Sources/RepoBar/App/AppState+Refresh.swift | 2 +- Sources/RepoBar/App/AppState.swift | 23 +- Sources/RepoBar/Auth/PATAuthenticator.swift | 122 +++++++++ .../Settings/AccountSettingsView.swift | 152 ++++++++--- Sources/RepoBarCore/Auth/TokenStore.swift | 17 ++ .../RepoBarCore/Settings/UserSettings.swift | 13 + .../RepoBarTests/PATAuthenticatorTests.swift | 253 ++++++++++++++++++ Tests/RepoBarTests/TokenStorePATTests.swift | 72 +++++ 9 files changed, 657 insertions(+), 40 deletions(-) create mode 100644 Sources/RepoBar/Auth/PATAuthenticator.swift create mode 100644 Tests/RepoBarTests/PATAuthenticatorTests.swift create mode 100644 Tests/RepoBarTests/TokenStorePATTests.swift diff --git a/Sources/RepoBar/App/AppState+Auth.swift b/Sources/RepoBar/App/AppState+Auth.swift index c3480fc4..8c91c169 100644 --- a/Sources/RepoBar/App/AppState+Auth.swift +++ b/Sources/RepoBar/App/AppState+Auth.swift @@ -9,6 +9,8 @@ extension AppState { await self.github.setAPIHost(self.defaultAPIHost) self.session.settings.githubHost = self.defaultGitHubHost self.session.settings.enterpriseHost = nil + self.session.settings.authMethod = .oauth + self.persistSettings() do { try await self.auth.login( @@ -30,4 +32,45 @@ extension AppState { self.session.lastError = error.userFacingMessage } } + + /// Authenticates with a Personal Access Token. + func loginWithPAT(_ pat: String, host: URL) async { + self.session.account = .loggingIn + self.session.lastError = nil + let apiHost = host.host == "github.com" + ? URL(string: "https://api.github.com")! + : host.appendingPathComponent("api/v3") + await self.github.setAPIHost(apiHost) + self.session.settings.githubHost = host + if host.host?.lowercased() == "github.com" { + self.session.settings.enterpriseHost = nil + } else { + self.session.settings.enterpriseHost = host + } + + do { + let user = try await self.patAuth.authenticate(pat: pat, host: host) + self.session.settings.authMethod = .pat + self.session.hasStoredTokens = true + self.session.account = .loggedIn(user) + self.session.lastError = nil + self.persistSettings() + await self.refresh() + } catch { + self.session.account = .loggedOut + self.session.settings.authMethod = .oauth + self.persistSettings() + self.session.lastError = error.localizedDescription + } + } + + /// Logs out the current user, clearing tokens based on the current auth method. + func logoutCurrentMethod() async { + await self.auth.logout() + await self.patAuth.logout() + self.session.account = .loggedOut + self.session.hasStoredTokens = false + self.session.settings.authMethod = .oauth + self.persistSettings() + } } diff --git a/Sources/RepoBar/App/AppState+Refresh.swift b/Sources/RepoBar/App/AppState+Refresh.swift index 67f24380..368e0b8b 100644 --- a/Sources/RepoBar/App/AppState+Refresh.swift +++ b/Sources/RepoBar/App/AppState+Refresh.swift @@ -49,7 +49,7 @@ extension AppState { if Task.isCancelled { return } let now = Date() self.updateHeatmapRange(now: now) - if self.auth.loadTokens() == nil { + if self.auth.loadTokens() == nil && self.patAuth.loadPAT() == nil { let localSnapshot = await self.snapshotForLoggedOutState(localSettings: localSettings) await self.applyLoggedOutState(localSnapshot: localSnapshot, lastError: nil) return diff --git a/Sources/RepoBar/App/AppState.swift b/Sources/RepoBar/App/AppState.swift index 747f0216..3b7a1118 100644 --- a/Sources/RepoBar/App/AppState.swift +++ b/Sources/RepoBar/App/AppState.swift @@ -9,6 +9,7 @@ import RepoBarCore final class AppState { var session = Session() let auth = OAuthCoordinator() + let patAuth = PATAuthenticator() let github = GitHubClient() let refreshScheduler = RefreshScheduler() let settingsStore = SettingsStore() @@ -39,17 +40,31 @@ final class AppState { verbosity: self.session.settings.loggingVerbosity, fileLoggingEnabled: self.session.settings.fileLoggingEnabled ) - let storedTokens = self.auth.loadTokens() - self.session.hasStoredTokens = (storedTokens != nil) + let storedOAuthTokens = self.auth.loadTokens() + let storedPAT = self.patAuth.loadPAT() + self.session.hasStoredTokens = (storedOAuthTokens != nil) || (storedPAT != nil) + let inferredAuthMethod: AuthMethod = storedPAT != nil ? .pat : .oauth + if self.session.settings.authMethod != inferredAuthMethod { + self.session.settings.authMethod = inferredAuthMethod + self.settingsStore.save(self.session.settings) + } + // Capture tokenStore separately for Sendable compliance + let tokenStore = TokenStore.shared Task { await self.github.setTokenProvider { @Sendable [weak self] () async throws -> OAuthTokens? in - try? await self?.auth.refreshIfNeeded() + guard let self else { return nil } + if self.session.settings.authMethod == .pat { + if let pat = try? tokenStore.loadPAT() { + return OAuthTokens(accessToken: pat, refreshToken: "", expiresAt: nil) + } + } + return try? await self.auth.refreshIfNeeded() } } self.tokenRefreshTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { - if self.auth.loadTokens() != nil { + if self.session.settings.authMethod == .oauth, self.auth.loadTokens() != nil { _ = try? await self.auth.refreshIfNeeded() } try? await Task.sleep(for: .seconds(self.tokenRefreshInterval)) diff --git a/Sources/RepoBar/Auth/PATAuthenticator.swift b/Sources/RepoBar/Auth/PATAuthenticator.swift new file mode 100644 index 00000000..f41860ca --- /dev/null +++ b/Sources/RepoBar/Auth/PATAuthenticator.swift @@ -0,0 +1,122 @@ +import Foundation +import OSLog +import RepoBarCore + +public enum PATAuthError: Error, LocalizedError { + case invalidToken + case forbidden(String) + case networkError(Error) + case invalidResponse + + public var errorDescription: String? { + switch self { + case .invalidToken: + "Invalid token" + case let .forbidden(message): + message + case let .networkError(error): + error.localizedDescription + case .invalidResponse: + "Invalid response from server" + } + } +} + +/// Handles Personal Access Token authentication as an alternative to OAuth. +/// PATs can be authorized for SAML SSO organizations in GitHub settings. +@MainActor +public final class PATAuthenticator { + private let tokenStore: TokenStore + private let signposter = OSSignposter(subsystem: "com.steipete.repobar", category: "pat-auth") + private var cachedPAT: String? + private var hasLoadedPAT = false + private let session: URLSession + + public init( + tokenStore: TokenStore = .shared, + session: URLSession = .shared + ) { + self.tokenStore = tokenStore + self.session = session + } + + /// Validates PAT via GET /user, stores on success, returns UserIdentity. + public func authenticate(pat: String, host: URL) async throws -> UserIdentity { + let signpost = self.signposter.beginInterval("authenticate") + defer { self.signposter.endInterval("authenticate", signpost) } + + let apiHost = Self.apiHost(for: host) + let userURL = apiHost.appendingPathComponent("user") + + var request = URLRequest(url: userURL) + request.setValue("Bearer \(pat)", forHTTPHeaderField: "Authorization") + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await self.session.data(for: request) + } catch { + throw PATAuthError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw PATAuthError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw PATAuthError.invalidToken + case 403: + throw PATAuthError.forbidden("Access forbidden. Token may lack required scopes (repo, read:org)") + default: + throw PATAuthError.invalidResponse + } + + struct UserResponse: Decodable { + let login: String + } + + let user: UserResponse + do { + user = try JSONDecoder().decode(UserResponse.self, from: data) + } catch { + throw PATAuthError.invalidResponse + } + + try self.tokenStore.savePAT(pat) + self.cachedPAT = pat + self.hasLoadedPAT = true + await DiagnosticsLogger.shared.message("PAT login succeeded; token stored.") + + return UserIdentity(username: user.login, host: host) + } + + /// Loads the stored PAT from Keychain. + public func loadPAT() -> String? { + if self.hasLoadedPAT { return self.cachedPAT } + self.hasLoadedPAT = true + let pat = try? self.tokenStore.loadPAT() + self.cachedPAT = pat + return pat + } + + /// Clears the stored PAT. + public func logout() async { + self.tokenStore.clearPAT() + self.cachedPAT = nil + self.hasLoadedPAT = false + await DiagnosticsLogger.shared.message("PAT cleared.") + } + + /// Converts a GitHub host URL to its API endpoint. + private static func apiHost(for host: URL) -> URL { + let hostString = host.host ?? "github.com" + if hostString == "github.com" { + return URL(string: "https://api.github.com")! + } + // Enterprise: use /api/v3 path + return host.appendingPathComponent("api/v3") + } +} diff --git a/Sources/RepoBar/Settings/AccountSettingsView.swift b/Sources/RepoBar/Settings/AccountSettingsView.swift index f53b2e0a..39db84d6 100644 --- a/Sources/RepoBar/Settings/AccountSettingsView.swift +++ b/Sources/RepoBar/Settings/AccountSettingsView.swift @@ -9,6 +9,9 @@ struct AccountSettingsView: View { @State private var clientSecret = "" @State private var enterpriseHost = "" @State private var hostMode: HostMode = .githubCom + @State private var authMethod: AuthMethod = .oauth + @State private var patInput = "" + @State private var isValidatingPAT = false @State private var validationError: String? @State private var tokenValidation: TokenValidationState = .unknown private let enterpriseFieldMinWidth: CGFloat = 260 @@ -44,9 +47,9 @@ struct AccountSettingsView: View { Spacer() Button("Log out") { Task { - await self.appState.auth.logout() - self.session.account = .loggedOut - self.session.hasStoredTokens = false + await self.appState.logoutCurrentMethod() + self.authMethod = .oauth + self.patInput = "" } } .buttonStyle(.bordered) @@ -68,54 +71,95 @@ struct AccountSettingsView: View { Task { await self.validateToken() } } .disabled(self.tokenValidation == .checking) - Button("Refresh token") { - Task { await self.refreshToken() } + if self.session.settings.authMethod == .oauth { + Button("Refresh token") { + Task { await self.refreshToken() } + } + .disabled(self.tokenValidation == .checking) } - .disabled(self.tokenValidation == .checking) } .buttonStyle(.bordered) } .padding(.vertical, 4) default: - if self.hostMode == .enterprise { - LabeledContent("Enterprise Base URL") { - TextField("https://ghe.example.com", text: self.$enterpriseHost) - .frame(minWidth: self.enterpriseFieldMinWidth) - .layoutPriority(1) + Picker("Authentication", selection: self.$authMethod) { + ForEach(AuthMethod.allCases, id: \.self) { method in + Text(method.label).tag(method) } - LabeledContent("Client ID") { - TextField("", text: self.$clientID) - .frame(minWidth: self.enterpriseFieldMinWidth) - .layoutPriority(1) - } - LabeledContent("Client Secret") { - SecureField("", text: self.$clientSecret) + } + .pickerStyle(.segmented) + + if self.authMethod == .pat { + LabeledContent("Token") { + SecureField("ghp_...", text: self.$patInput) .frame(minWidth: self.enterpriseFieldMinWidth) .layoutPriority(1) } - Text("Create an OAuth App in your enterprise server. Callback URL: http://127.0.0.1:53682/callback") + Text("Recommended for SAML SSO organizations. Required scopes: repo, read:org") .font(.caption) .foregroundStyle(.secondary) - } else { - Text("Uses the built-in GitHub.com OAuth app.") + Link("Create a token on GitHub", destination: self.createTokenURL()) .font(.caption) - .foregroundStyle(.secondary) - } - HStack(spacing: 8) { - if self.session.account == .loggingIn { - ProgressView() - .controlSize(.small) - .frame(width: self.spinnerSize, height: self.spinnerSize) + if self.hostMode == .enterprise { + LabeledContent("Enterprise Base URL") { + TextField("https://ghe.example.com", text: self.$enterpriseHost) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + } + HStack(spacing: 8) { + if self.isValidatingPAT { + ProgressView() + .controlSize(.small) + .frame(width: self.spinnerSize, height: self.spinnerSize) + } + Button(self.isValidatingPAT ? "Signing in…" : "Sign in with Token") { + self.loginWithPAT() + } + .disabled(self.patInput.isEmpty || self.isValidatingPAT) + .buttonStyle(.borderedProminent) + } + } else { + if self.hostMode == .enterprise { + LabeledContent("Enterprise Base URL") { + TextField("https://ghe.example.com", text: self.$enterpriseHost) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + LabeledContent("Client ID") { + TextField("", text: self.$clientID) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + LabeledContent("Client Secret") { + SecureField("", text: self.$clientSecret) + .frame(minWidth: self.enterpriseFieldMinWidth) + .layoutPriority(1) + } + Text("Create an OAuth App in your enterprise server. Callback URL: http://127.0.0.1:53682/callback") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Uses the built-in GitHub.com OAuth app.") + .font(.caption) + .foregroundStyle(.secondary) } - Button(self.session.account == .loggingIn ? "Signing in…" : self.hostMode == .enterprise ? "Sign in to Enterprise" : "Sign in to GitHub.com") { - self.login() + HStack(spacing: 8) { + if self.session.account == .loggingIn { + ProgressView() + .controlSize(.small) + .frame(width: self.spinnerSize, height: self.spinnerSize) + } + Button(self.session.account == .loggingIn ? "Signing in…" : self.hostMode == .enterprise ? "Sign in to Enterprise" : "Sign in to GitHub.com") { + self.login() + } + .disabled(self.session.account == .loggingIn) + .buttonStyle(.borderedProminent) } - .disabled(self.session.account == .loggingIn) - .buttonStyle(.borderedProminent) + Text("Uses browser-based OAuth. Tokens are stored in the system Keychain.") + .font(.caption) + .foregroundStyle(.secondary) } - Text("Uses browser-based OAuth. Tokens are stored in the system Keychain.") - .font(.caption) - .foregroundStyle(.secondary) } } @@ -148,6 +192,7 @@ struct AccountSettingsView: View { self.clientSecret = RepoBarAuthDefaults.clientSecret } } + self.authMethod = self.session.settings.authMethod } .task(id: self.session.account) { guard case .loggedIn = self.session.account else { @@ -199,6 +244,8 @@ struct AccountSettingsView: View { host: self.session.settings.enterpriseHost ?? self.session.settings.githubHost, loopbackPort: self.session.settings.loopbackPort ) + self.session.settings.authMethod = .oauth + self.appState.persistSettings() self.session.hasStoredTokens = true if let user = try? await appState.github.currentUser() { self.session.account = .loggedIn(user) @@ -214,6 +261,41 @@ struct AccountSettingsView: View { } } + private func loginWithPAT() { + Task { @MainActor in + self.isValidatingPAT = true + self.validationError = nil + + let host: URL + if self.hostMode == .enterprise { + guard let enterpriseURL = self.normalizedEnterpriseHost() else { + self.validationError = "Enterprise Base URL must be a valid https:// URL with a trusted certificate." + self.isValidatingPAT = false + return + } + self.session.settings.enterpriseHost = enterpriseURL + host = enterpriseURL + } else { + self.session.settings.enterpriseHost = nil + host = URL(string: "https://github.com")! + } + + await self.appState.loginWithPAT(self.patInput, host: host) + self.isValidatingPAT = false + + if case .loggedIn = self.session.account { + self.patInput = "" + } + } + } + + private func createTokenURL() -> URL { + let baseHost = self.hostMode == .enterprise + ? (self.normalizedEnterpriseHost()?.absoluteString ?? "https://github.com") + : "https://github.com" + return URL(string: "\(baseHost)/settings/tokens/new?scopes=repo,read:org&description=RepoBar")! + } + private func normalizedEnterpriseHost() -> URL? { guard !self.enterpriseHost.isEmpty else { return nil } guard var components = URLComponents(string: enterpriseHost) else { return nil } diff --git a/Sources/RepoBarCore/Auth/TokenStore.swift b/Sources/RepoBarCore/Auth/TokenStore.swift index 6c65f386..70085a42 100644 --- a/Sources/RepoBarCore/Auth/TokenStore.swift +++ b/Sources/RepoBarCore/Auth/TokenStore.swift @@ -66,6 +66,23 @@ public struct TokenStore: Sendable { public func clear() { self.clear(account: "default") self.clear(account: "client") + self.clearPAT() + } + + // MARK: - PAT Storage + + public func savePAT(_ token: String) throws { + let data = Data(token.utf8) + try self.save(data: data, account: "pat") + } + + public func loadPAT() throws -> String? { + guard let data = try self.loadData(account: "pat") else { return nil } + return String(data: data, encoding: .utf8) + } + + public func clearPAT() { + self.clear(account: "pat") } } diff --git a/Sources/RepoBarCore/Settings/UserSettings.swift b/Sources/RepoBarCore/Settings/UserSettings.swift index e67579cf..ca641f9e 100644 --- a/Sources/RepoBarCore/Settings/UserSettings.swift +++ b/Sources/RepoBarCore/Settings/UserSettings.swift @@ -15,10 +15,23 @@ public struct UserSettings: Equatable, Codable { public var githubHost: URL = .init(string: "https://github.com")! public var enterpriseHost: URL? public var loopbackPort: Int = 53682 + public var authMethod: AuthMethod = .oauth public init() {} } +public enum AuthMethod: String, CaseIterable, Equatable, Codable, Sendable { + case oauth + case pat + + public var label: String { + switch self { + case .oauth: "OAuth" + case .pat: "Personal Access Token" + } + } +} + public struct HeatmapSettings: Equatable, Codable { public var display: HeatmapDisplay = .inline public var span: HeatmapSpan = .twelveMonths diff --git a/Tests/RepoBarTests/PATAuthenticatorTests.swift b/Tests/RepoBarTests/PATAuthenticatorTests.swift new file mode 100644 index 00000000..ee573df5 --- /dev/null +++ b/Tests/RepoBarTests/PATAuthenticatorTests.swift @@ -0,0 +1,253 @@ +import Foundation +@testable import RepoBar +@testable import RepoBarCore +import Testing + +struct PATAuthenticatorTests { + @Test + @MainActor + func validatePATSuccess() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + #expect(request.url?.path == "/user") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer ghp_testtoken") + + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + let data = Data(""" + {"login":"testuser"} + """.utf8) + return (data, response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + let user = try await authenticator.authenticate( + pat: "ghp_testtoken", + host: URL(string: "https://github.com")! + ) + + #expect(user.username == "testuser") + #expect(user.host.absoluteString == "https://github.com") + #expect(try store.loadPAT() == "ghp_testtoken") + } + + @Test + @MainActor + func validatePATInvalidToken() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + do { + _ = try await authenticator.authenticate( + pat: "invalid_token", + host: URL(string: "https://github.com")! + ) + Issue.record("Expected invalidToken error") + } catch let error as PATAuthError { + guard case .invalidToken = error else { + Issue.record("Expected PATAuthError.invalidToken, got \(error)") + return + } + } + + #expect(try store.loadPAT() == nil) + } + + @Test + @MainActor + func validatePATForbidden() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 403, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + do { + _ = try await authenticator.authenticate( + pat: "token_without_scopes", + host: URL(string: "https://github.com")! + ) + Issue.record("Expected forbidden error") + } catch let error as PATAuthError { + guard case .forbidden = error else { + Issue.record("Expected PATAuthError.forbidden, got \(error)") + return + } + } + + #expect(try store.loadPAT() == nil) + } + + @Test + @MainActor + func logoutClearsPAT() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + try store.savePAT("ghp_testtoken") + + let authenticator = PATAuthenticator(tokenStore: store) + + // Load the PAT first + let loadedBefore = authenticator.loadPAT() + #expect(loadedBefore == "ghp_testtoken") + + await authenticator.logout() + + let loadedAfter = authenticator.loadPAT() + #expect(loadedAfter == nil) + } + + @Test + @MainActor + func loadPATReturnsNilWhenNotStored() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let authenticator = PATAuthenticator(tokenStore: store) + + let loaded = authenticator.loadPAT() + #expect(loaded == nil) + } + + @Test + @MainActor + func enterpriseHostUsesAPIPath() async throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let session = URLSession(configuration: Self.sessionConfiguration()) + let handlerID = UUID().uuidString + Self.MockURLProtocol.register(handlerID: handlerID) { request in + // Enterprise should use /api/v3/user path + #expect(request.url?.path == "/api/v3/user") + + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + let data = Data(""" + {"login":"enterpriseuser"} + """.utf8) + return (data, response) + } + defer { Self.MockURLProtocol.unregister(handlerID: handlerID) } + + let authenticator = PATAuthenticator( + tokenStore: store, + session: Self.taggedSession(session, handlerID: handlerID) + ) + + let user = try await authenticator.authenticate( + pat: "ghp_enterprisetoken", + host: URL(string: "https://ghe.example.com")! + ) + + #expect(user.username == "enterpriseuser") + } +} + +private extension PATAuthenticatorTests { + static func sessionConfiguration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config + } + + static func taggedSession(_ session: URLSession, handlerID: String) -> URLSession { + // Create a new session with the handler ID embedded + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + config.httpAdditionalHeaders = ["X-Handler-ID": handlerID] + return URLSession(configuration: config) + } + + // swiftlint:disable static_over_final_class + final class MockURLProtocol: URLProtocol { + private static let handlersLock = NSLock() + private nonisolated(unsafe) static var handlers: [String: @Sendable (URLRequest) throws -> (Data, URLResponse)] = [:] + + static func register( + handlerID: String, + handler: @escaping @Sendable (URLRequest) throws -> (Data, URLResponse) + ) { + self.handlersLock.lock() + self.handlers[handlerID] = handler + self.handlersLock.unlock() + } + + static func unregister(handlerID: String) { + self.handlersLock.lock() + self.handlers[handlerID] = nil + self.handlersLock.unlock() + } + + override class func canInit(with request: URLRequest) -> Bool { + request.value(forHTTPHeaderField: "X-Handler-ID") != nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard + let handlerID = request.value(forHTTPHeaderField: "X-Handler-ID"), + let handler = Self.handler(for: handlerID) + else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + + do { + let (data, response) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + private static func handler(for handlerID: String) -> (@Sendable (URLRequest) throws -> (Data, URLResponse))? { + self.handlersLock.lock() + defer { handlersLock.unlock() } + return self.handlers[handlerID] + } + } + // swiftlint:enable static_over_final_class +} diff --git a/Tests/RepoBarTests/TokenStorePATTests.swift b/Tests/RepoBarTests/TokenStorePATTests.swift new file mode 100644 index 00000000..03d8b4ba --- /dev/null +++ b/Tests/RepoBarTests/TokenStorePATTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import RepoBarCore +import Testing + +struct TokenStorePATTests { + @Test + func savePATAndLoad() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let pat = "ghp_test123456789" + try store.savePAT(pat) + + let loaded = try store.loadPAT() + #expect(loaded == pat) + } + + @Test + func clearRemovesPAT() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let pat = "ghp_test123456789" + try store.savePAT(pat) + + store.clearPAT() + + let loaded = try store.loadPAT() + #expect(loaded == nil) + } + + @Test + func loadPATWhenNoneStored() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let loaded = try store.loadPAT() + #expect(loaded == nil) + } + + @Test + func clearAlsoClearsPAT() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + let pat = "ghp_test123456789" + try store.savePAT(pat) + + // clear() should also clear PAT + store.clear() + + let loaded = try store.loadPAT() + #expect(loaded == nil) + } + + @Test + func savePATOverwritesPrevious() throws { + let service = "com.steipete.repobar.auth.tests.\(UUID().uuidString)" + let store = TokenStore(service: service) + defer { store.clear() } + + try store.savePAT("ghp_first") + try store.savePAT("ghp_second") + + let loaded = try store.loadPAT() + #expect(loaded == "ghp_second") + } +} From 82a1212fb441f8abdf926074a78e6b0eb7d43da8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 20:41:58 +0000 Subject: [PATCH 2/2] fix: persist PAT auth state (#21) (thanks @kkiermasz) --- CHANGELOG.md | 1 + Sources/RepoBar/App/AppState.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e209c5..efaeacfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - macOS: prevent token check/refresh from hanging; add timeouts and diagnostics logging. - macOS: detect auth failures (401/refresh errors) and log out cleanly with a clearer message. - macOS: preserve keychain access groups in signed builds so app + CLI share tokens (#16, thanks @jj3ny). +- macOS: add Personal Access Token authentication with persistence + logout fixes (#21, thanks @kkiermasz). - macOS: keep the menu open after pin/unpin/hide/move actions (#25, thanks @bahag-chaurasiak). - macOS: make Local Projects scan depth configurable (default 4) (#11, thanks @shunkakinoki). - macOS: allow importing GitHub CLI tokens with host matching and no refresh loop (#24, thanks @bahag-chaurasiak). diff --git a/Sources/RepoBar/App/AppState.swift b/Sources/RepoBar/App/AppState.swift index 3b7a1118..de67b756 100644 --- a/Sources/RepoBar/App/AppState.swift +++ b/Sources/RepoBar/App/AppState.swift @@ -53,7 +53,8 @@ final class AppState { Task { await self.github.setTokenProvider { @Sendable [weak self] () async throws -> OAuthTokens? in guard let self else { return nil } - if self.session.settings.authMethod == .pat { + let authMethod = await MainActor.run { self.session.settings.authMethod } + if authMethod == .pat { if let pat = try? tokenStore.loadPAT() { return OAuthTokens(accessToken: pat, refreshToken: "", expiresAt: nil) }