Skip to content

Phase 1: GitHub APIクライアント層 + 認証 - #1

Merged
tukuyomil032 merged 11 commits into
mainfrom
phase-1/github-client-auth
Aug 22, 2026
Merged

Phase 1: GitHub APIクライアント層 + 認証#1
tukuyomil032 merged 11 commits into
mainfrom
phase-1/github-client-auth

Conversation

@tukuyomil032

Copy link
Copy Markdown
Owner

Summary

  • GitHub REST APIクライアント(search/releases/readme/user)をGitHubClientProtocol/GitHubClientとして実装。未認証でも動作し、レート制限(GitHubRateLimiter)をX-RateLimit-*ヘッダから実測補正。
  • GitHub OAuth Device Flow認証一式を実装:DeviceFlowAuthenticator(device_code取得・ポーリング)、KeychainTokenStore(Keychain永続化、端末ローカルのみ)、AuthenticationState@MainActor @Observable、状態遷移・自動refresh token更新)、DeviceFlowSignInView(Liquid Glassスタイルのサインイン画面)。
  • GitHubClientonUnauthorizedフック経由で401受信をAuthenticationState.tokenInvalid遷移に接続できるよう疎結合設計。実際のDI配線は次のUI機能フェーズで行う。

設計判断

  • 当初classic OAuth App(Ov23liプレフィックス)を作成したが、Device Flowで発行されるトークンが失効せずrefresh_tokenも付与されないことが判明(refresh_token自動更新タスクが死んだコードになる)。GitHub App(Iv23liプレフィックス)に作り直し、curlでの実機検証により「未インストールの他人の公開リポジトリにも問題なくアクセスできる」ことを確認した上で採用。
  • Client IDはアプリバイナリに定数として埋め込み(GitHubOAuthConfig.swift、Client Secret不要のPublic Client方式)。

Test plan

  • swift test — 41テスト全パス(GitHubModels/GitHubRateLimiter/GitHubClient/DeviceFlowAuthenticator/KeychainTokenStore/AuthenticationStateの各スイート)
  • just lint相当(swift-format + swiftlint、警告のみ・エラーなし)
  • 実機確認:swift runでアプリを起動し、DeviceFlowSignInViewからサインインボタン押下→ブラウザ起動→user_code自動コピー→GitHub側で認可→アプリ側が「サインイン済み」状態に遷移することを確認

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

tukuyomil032 and others added 11 commits August 22, 2026 18:04
Codable structs for Repository, Release, ReleaseAsset, and GitHubUser matching the GitHub REST API response shape, split into Models/ per file (Repository/Release/GitHubUser) as documented in CLAUDE.md.

Only fields needed by the search/releases/readme/user endpoints are included. Decode tests cover realistic API response JSON for each type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Actor tracking GitHub REST API rate-limit state from X-RateLimit-* response headers rather than a self-maintained counter, since GitHub returns the authoritative remaining count on every response.

On 429, records a backoff deadline from Retry-After (falling back to X-RateLimit-Reset), and waitIfNeeded() suspends the caller until that deadline via an injectable sleep function for deterministic testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GitHubClientProtocol + GitHubClient covering search/releases/readme/user via URLSession, protocol-abstracted for test mocking. No Authorization header is sent yet (browsing works unauthenticated); accessTokenProvider is wired to a nil-returning default and will be connected to AuthenticationState in a later task.

Maps HTTP status to GitHubClientError (401 -> tokenInvalid, 403/429 -> rateLimited via GitHubRateLimiter, other non-2xx -> httpError). readme() treats 404 as the normal not-yet-published case and returns nil rather than throwing. Tests stub URLSession via a custom URLProtocol.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the GitHub OAuth Device Flow: requestDeviceCode() gets the user_code, pollForAccessToken() polls /login/oauth/access_token handling authorization_pending (retry), slow_down (+5s interval), access_denied, and expired_token per GitHub's spec. Sleep is injectable for deterministic polling tests.

Uses a GitHub App (not a classic OAuth App) as the Client ID source, switched after empirically verifying via curl that a GitHub App's user-to-server token can read public repositories the user neither owns nor has installed the app on (required for Cairn's discovery use case) and that it correctly returns expires_in/refresh_token, unlike classic OAuth Apps which never expire tokens and would have made the plan's refresh-token task dead code. Client ID is embedded as a public constant in GitHubOAuthConfig (Public Client, no secret).

GitHubClientTests' StubURLProtocol was extracted to its own file; DeviceFlowAuthenticatorTests uses a separate DeviceFlowStubURLProtocol class so the two test suites' shared static stub state doesn't race when swift test runs suites in parallel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Persists the GitHub OAuth access/refresh tokens (StoredToken) to the macOS Keychain via SecItem calls, isolated behind a KeychainStoring protocol so tests use an in-memory fake instead of touching the real Keychain.

SystemKeychain sets kSecAttrAccessibleAfterFirstUnlock and explicitly kSecAttrSynchronizable = false (device-local only, matching GitHub Desktop/gh CLI convention rather than iCloud Keychain sync, per the implementation plan).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@observable class orchestrating Device Flow sign-in, restoring auth status from Keychain at launch, and exposing status (.unauthenticated/.authenticating/.authenticated/.tokenInvalid) for the UI to react to. handleUnauthorizedResponse() lets GitHubClient push a 401 into a .tokenInvalid transition; signOut() clears the Keychain.

signIn() and the underlying pollForAccessToken() both take an injectable sleep function so tests don't block on real Device Flow polling intervals. InMemoryKeychain was promoted out of KeychainTokenStoreTests into its own file so AuthenticationStateTests can reuse it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DeviceFlowAuthenticator.refreshAccessToken(refreshToken:) posts a grant_type=refresh_token request, sharing the POST/decode plumbing with pollOnce via a new postToTokenEndpoint helper. AuthenticationState.validAccessToken() is the refresh-aware accessor: returns the stored token as-is if unexpired, transparently refreshes and persists a new one if the access token expired but the refresh token hasn't, and falls back to .tokenInvalid (returning nil) when the refresh token is also expired or the refresh call itself fails.

GitHub Apps' user-to-server tokens expire in 8 hours by design (confirmed empirically in task 4), so this keeps the user signed in without repeating the Device Flow every 8 hours. validAccessToken() is what GitHubClient's accessTokenProvider will call once wired up in the next task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GitHubClient gains an onUnauthorized hook, called on every 401 before throwing .tokenInvalid. Composition-root wiring (accessTokenProvider: { await authState.validAccessToken() }, onUnauthorized: { authState.handleUnauthorizedResponse() }) is left for the feature phase that actually instantiates GitHubClient in the UI, since CairnApp has no consumer of it yet.

GitHubClient stays decoupled from AuthenticationState by design (closures only, no direct reference), keeping the protocol-mockable GitHubClientProtocol usable independent of the auth layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SwiftUI view rendering the Device Flow sign-in UI: a sign-in button when unauthenticated, the user_code plus a progress indicator while authenticating, a success state, and a retry prompt on .tokenInvalid. Opening the browser (NSWorkspace) and copying the user_code to the clipboard happen via .onChange(of: authState.status) rather than a completion closure passed into signIn().

AuthenticationState is now @mainactor: Swift 6's strict concurrency checker flagged passing a MainActor-isolated UI closure into signIn() (a nonisolated async method) as a data-race risk, and since AuthenticationState only ever gets driven from SwiftUI anyway, isolating the whole class to MainActor is the correct fix rather than working around the check. AuthenticationStateTests is marked @mainactor to match; GitHubClient's onUnauthorized/accessTokenProvider closures already cross the boundary safely since they're async and awaited.

No automated test targets this view directly (no snapshot/UI-testing framework in the project's minimal-dependency stack per docs/dependencies.md) - manual verification against the running app is the plan's stated way to confirm the Device Flow completes end-to-end once a Client ID is available, which it now is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updates docs/progress.md to reflect the completed GitHub App setup, the OAuth App -> GitHub App switch rationale, and the 9 implementation tasks finished on this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wraps the sign-in card in .glassEffect(.regular, in: .rect(cornerRadius:)) and renders the user_code as an interactive glass capsule, following macOS 26's Liquid Glass HIG guidance instead of a flat background. Standard controls (.borderedProminent, .bordered buttons) already adopt Liquid Glass automatically on macOS 26.

Verified end-to-end on-device: swift run launched the app, the sign-in button triggered the Device Flow, the browser opened with the user_code pre-copied to the clipboard, and completing authorization in the browser transitioned the view to the 'signed in' state, confirming the token round-tripped through Keychain via AuthenticationState.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tukuyomil032
tukuyomil032 merged commit 6f1669f into main Aug 22, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant