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
- Generate PKCE +
state (base64URLEncoded(32 random bytes)).
- 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.
- Wrap
ASWebAuthenticationSession(url:, callbackURLScheme: "relay") via withCheckedThrowingContinuation. prefersEphemeralWebBrowserSession = false (reuse Safari session).
presentationContextProvider — @MainActor wrapper for ASPresentationAnchor.
- User cancel →
.userCancelled.
- Parse callback URL: extract
code + state. Validate state matches → else .stateMismatch.
- 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).
- Build temp
GitHubClient(token: access_token), call validateToken() → GHAuthInfo with login.
credentialStore.save(token, for: login) + setCurrentAccount(login).
- 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
Complexity
L
Suggested agent
developer-workflow:swift-engineer
Module / Layer
GitHubIntegrationClient / Auth
Description
(1) PKCE helper —
PKCE.swiftSecRandomCopyBytesverifier = base64URLEncoded(bytes)(43–128 chars, no padding)challenge = base64URLEncoded(SHA256(verifier.utf8))(2)
GitHubAuthService.swift(3)
signInflowstate(base64URLEncoded(32 random bytes)).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.ASWebAuthenticationSession(url:, callbackURLScheme: "relay")viawithCheckedThrowingContinuation.prefersEphemeralWebBrowserSession = false(reuse Safari session).presentationContextProvider—@MainActorwrapper forASPresentationAnchor..userCancelled.code+state. Validatestatematches → else.stateMismatch.POST https://github.com/login/oauth/access_tokenform-encoded{client_id, code, code_verifier, redirect_uri}+Accept: application/json. Parse JSON{access_token, scope, token_type}. Non-2xx →.exchangeFailed(statusCode).GitHubClient(token: access_token), callvalidateToken()→GHAuthInfowithlogin.credentialStore.save(token, for: login)+setCurrentAccount(login).GHAuthInfo.(4) Errors
(5) Swift 6 concurrency
ASWebAuthenticationSessionis@MainActor. Actor methods dispatch UI presentation viaTask { @MainActor in ... }.@Sendableclosures for continuation resume. Log viaGitHubLog.auth— never logtoken,code,code_verifier,state.Spec reference
See
swarm-report/github-integration-decomposition.md#t-7.Relationships
Acceptance criteria
PKCEChallenge.generate()→ verifier length ∈ [43, 128];challenge == base64URL(SHA256(verifier.utf8))statein callback →.stateMismatch.exchangeFailed(400); URLError →.networkErrorcurrentUser()returns real@usernamesignOut(login:)removes token from Keychain;credentialStore.currentAccount() == nilcom.relay.github.authlog stream: zero substrings matchingtoken,code,code_verifier,statevalues (verified in T-13 manual run)GitHubOAuthConfig.clientID == "placeholder"— documented in test fileComplexity
L
Suggested agent
developer-workflow:swift-engineerModule / Layer
GitHubIntegrationClient/ Auth