krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
sonarcloud-cleanup: octosentry/AuthStore.swift · raw
1//
2// AuthStore.swift
3// octosentry
4//
5// Drives the device authorization flow and mirrors whether a token is
6// currently in the Keychain. Replaces the GITHUB_TOKEN env var dev
7// shortcut (spec §13) with the real v1 auth flow (spec §6).
8//
9// Sign-in requests the minimal security_events scope by default.
10// Broader "repo" scope (needed to list repos for the picker, #15) is
11// only ever requested on demand via requestRepoAccess(), never by
12// default — a deliberate choice to keep the default blast radius small.
13//
14
15import Foundation
16import Observation
17
18@Observable
19final class AuthStore {
20 private(set) var state: AuthState
21 private(set) var errorMessage: String?
22 private(set) var hasRepoAccess = false
23
24 private let client = GitHubDeviceAuthClient()
25 private let persistenceStore = PersistenceStore()
26 private var authorizationTask: Task<Void, Never>?
27
28 init() {
29 state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
30 Task {
31 hasRepoAccess = await persistenceStore.load().hasRepoScope
32 }
33 }
34
35 var isSignedIn: Bool {
36 if case .signedIn = state { return true }
37 return false
38 }
39
40 func signIn() {
41 beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope)
42 }
43
44 /// Re-runs device auth with broader scope so the repo picker can list
45 /// repos. Only called explicitly from the repo picker UI, never on
46 /// the default sign-in path.
47 func requestRepoAccess() {
48 beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope)
49 }
50
51 func signOut() {
52 authorizationTask?.cancel()
53 authorizationTask = nil
54 KeychainTokenStore.delete()
55 state = .signedOut
56 hasRepoAccess = false
57 }
58
59 private func beginAuthorization(scope: String) {
60 guard authorizationTask == nil else { return }
61 errorMessage = nil
62
63 authorizationTask = Task {
64 defer { authorizationTask = nil }
65 do {
66 let deviceCode = try await client.requestDeviceCode(scope: scope)
67 state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri)
68
69 let token = try await client.pollForToken(
70 deviceCode: deviceCode.deviceCode,
71 interval: deviceCode.interval,
72 expiresIn: deviceCode.expiresIn
73 )
74 try KeychainTokenStore.save(token)
75
76 let grantedRepoScope = scope.contains("repo")
77 var persisted = await persistenceStore.load()
78 persisted.hasRepoScope = grantedRepoScope
79 await persistenceStore.save(persisted)
80 hasRepoAccess = grantedRepoScope
81
82 state = .signedIn
83 } catch {
84 errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
85 // A failed re-auth (e.g. requestRepoAccess while already
86 // signed in) shouldn't sign the user out of their existing
87 // valid token — only reflect reality from the Keychain.
88 state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut
89 }
90 }
91 }
92}