Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
43 changes: 43 additions & 0 deletions Sources/RepoBar/App/AppState+Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
}
}
2 changes: 1 addition & 1 deletion Sources/RepoBar/App/AppState+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions Sources/RepoBar/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -39,17 +40,32 @@ 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 }
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)
}
}
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))
Expand Down
122 changes: 122 additions & 0 deletions Sources/RepoBar/Auth/PATAuthenticator.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading