Skip to content

T-7: OAuth + PKCE flow + GitHubAuthService #222

@kirich1409

Description

@kirich1409

Description

(1) PKCE helper — PKCE.swift

public struct PKCEChallenge: Sendable {
    public let verifier: String
    public let challenge: String
    public let method: String = "S256"
    public static func generate() -> PKCEChallenge
}
  • 64 random bytes via SecRandomCopyBytes
  • verifier = base64URLEncoded(bytes) (43–128 chars, no padding)
  • challenge = base64URLEncoded(SHA256(verifier.utf8))

(2) GitHubAuthService.swift

public actor GitHubAuthService {
    public init(config: GitHubOAuthConfig.Type,
                credentialStore: GitHubCredentialStore,
                clientFactory: @Sendable (String) -> GitHubClient)

    public func signIn(presentationAnchor: ASPresentationAnchor)
        async throws(GitHubAuthError) -> GHAuthInfo

    public func signOut(login: String) async

    public func currentUser() async -> GHAuthInfo?
}

(3) signIn flow

  1. Generate PKCE + state (base64URLEncoded(32 random bytes)).
  2. Build URL https://github.com/login/oauth/authorize?client_id=<id>&redirect_uri=relay://oauth/callback&scope=repo&state=<s>&code_challenge=<c>&code_challenge_method=S256.
  3. Wrap ASWebAuthenticationSession(url:, callbackURLScheme: "relay") via withCheckedThrowingContinuation. prefersEphemeralWebBrowserSession = false (reuse Safari session).
  4. presentationContextProvider@MainActor wrapper for ASPresentationAnchor.
  5. User cancel → .userCancelled.
  6. Parse callback URL: extract code + state. Validate state matches → else .stateMismatch.
  7. Exchange: POST https://github.com/login/oauth/access_token form-encoded {client_id, code, code_verifier, redirect_uri} + Accept: application/json. Parse JSON {access_token, scope, token_type}. Non-2xx → .exchangeFailed(statusCode).
  8. Build temp GitHubClient(token: access_token), call validateToken()GHAuthInfo with login.
  9. credentialStore.save(token, for: login) + setCurrentAccount(login).
  10. Return GHAuthInfo.

(4) Errors

public enum GitHubAuthError: Error, Sendable {
    case userCancelled
    case stateMismatch
    case exchangeFailed(statusCode: Int)
    case networkError
    case invalidResponse
    case keychainFailed(KeychainError)
    case apiError(GitHubAPIError)
}

(5) Swift 6 concurrency

ASWebAuthenticationSession is @MainActor. Actor methods dispatch UI presentation via Task { @MainActor in ... }. @Sendable closures for continuation resume. Log via GitHubLog.auth — never log token, code, code_verifier, state.

Spec reference

See swarm-report/github-integration-decomposition.md#t-7.

Relationships

Acceptance criteria

  • Unit: PKCEChallenge.generate() → verifier length ∈ [43, 128]; challenge == base64URL(SHA256(verifier.utf8))
  • Unit: state CSRF — differing state in callback → .stateMismatch
  • Unit: exchange mock 200 → success; 400 → .exchangeFailed(400); URLError → .networkError
  • Integration (with real Client ID from T-3): click Sign In → web auth → callback → token in Keychain; currentUser() returns real @username
  • signOut(login:) removes token from Keychain; credentialStore.currentAccount() == nil
  • com.relay.github.auth log stream: zero substrings matching token, code, code_verifier, state values (verified in T-13 manual run)
  • Integration tests SKIPPED (not failed) if GitHubOAuthConfig.clientID == "placeholder" — documented in test file

Complexity

L

Suggested agent

developer-workflow:swift-engineer

Module / Layer

GitHubIntegrationClient / Auth

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions