How GitHub OAuth Authentication Is Implemented in the GSY GitHub App: A Complete Technical Guide
The app implements GitHub OAuth authentication through a layered architecture that uses an embedded WebView to capture the authorization code, a ViewModel to manage UI state, and a Repository layer to exchange the code for an access token and persist user credentials locally.
The carguo/gsygithubappcompose repository demonstrates a production-ready implementation of GitHub OAuth authentication using Jetpack Compose and modern Android architecture patterns. This Kotlin-based application separates concerns across UI, networking, and data persistence layers to create a secure and testable login flow.
Architecture Overview
The GitHub OAuth implementation follows a five-layer architecture that isolates responsibilities from user interaction to data persistence:
- UI Layer: Displays the OAuth web page and captures the authorization code
- WebView Component: Intercepts the custom scheme redirect from GitHub
- ViewModel: Manages authentication state and orchestrates the token exchange
- Repository: Handles the code-to-token exchange and local data persistence
- Network Layer: Defines Retrofit endpoints for GitHub's OAuth API
Step-by-Step Authentication Flow
The complete GitHub OAuth authentication flow proceeds through these distinct phases:
- User initiates login:
LoginViewModel.startOAuthFlow()setsshowOAuthWebView = true, triggering the UI to display the OAuth screen. - Authorization request: The app loads
https://github.com/login/oauth/authorizewith the client ID and requested scopes in an embedded WebView. - User consent: GitHub presents the authorization page; upon approval, GitHub redirects to the custom scheme
gsygithubapp://authed?code=XYZ. - Code interception:
OAuthWebViewdetects the custom scheme, extracts thecodequery parameter, and invokes the callback. - Token exchange:
LoginViewModel.handleOAuthCodeforwards the code, client ID, and client secret toUserRepository.loginWithOAuth. - Authentication completion: The repository exchanges the code for an access token, fetches the authenticated user profile, and persists credentials to
UserPreferencesDataStoreand the local database. - Navigation: On success, the ViewModel updates
isLoggedIn = true, triggering navigation to the home screen.
UI Layer: Triggering the OAuth Flow
In feature/login/src/main/java/com/shuyu/gsygithubappcompose/feature/login/LoginScreen.kt, the LoginScreen composable monitors uiState.showOAuthWebView to conditionally display the OAuthScreen. This separation keeps the initial login UI distinct from the WebView authorization interface.
When the user taps the GitHub OAuth button, the ViewModel flips the state boolean, causing the UI to render the OAuth screen with the necessary callbacks:
// In LoginScreen.kt
if (uiState.showOAuthWebView) {
OAuthScreen(
onCodeReceived = { code ->
viewModel.handleOAuthCode(
BuildConfig.CLIENT_ID,
BuildConfig.CLIENT_SECRET,
code
)
},
onCancel = { viewModel.cancelOAuthFlow() },
onError = { viewModel.cancelOAuthFlow() }
)
}
The OAuthScreen constructs the GitHub authorization URL using the client credentials stored in BuildConfig:
val clientId = BuildConfig.CLIENT_ID
val oauthUrl = "https://github.com/login/oauth/authorize?" +
"client_id=$clientId&state=app&scope=user,repo,gist,notifications," +
"read:org,workflow&redirect_uri=gsygithubapp://authed"
WebView Integration: Capturing the Authorization Code
The OAuthWebView.kt file contains the critical logic for intercepting the OAuth redirect. The custom WebViewClient monitors navigation events and detects when the URL matches the app's custom scheme gsygithubapp://authed.
When the redirect occurs, the WebView extracts the authorization code from the query parameters:
// OAuthWebView.kt
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?
): Boolean {
val uri = request?.url
if (uri != null && uri.toString().startsWith("gsygithubapp://authed")) {
val code = uri.getQueryParameter("code")
if (code != null) onCodeReceived(code) else onError()
return true // Prevent further loading
}
return false
}
This interception prevents the WebView from attempting to load the custom scheme as a web page, instead capturing the code and passing it back to the ViewModel through the onCodeReceived callback.
ViewModel: Managing Authentication State
The LoginViewModel located at feature/login/src/main/java/com/shuyu/gsygithubappcompose/feature/login/LoginViewModel.kt serves as the intermediary between the UI and repository layers. Its handleOAuthCode method initiates the token exchange within a coroutine scope, managing loading states and error handling:
// LoginViewModel.kt
fun handleOAuthCode(clientId: String, clientSecret: String, code: String) {
viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) }
val result = userRepository.loginWithOAuth(clientId, clientSecret, code)
result.onSuccess { user ->
_uiState.update {
it.copy(isLoggedIn = true, isLoading = false, currentUser = user)
}
}.onFailure { error ->
_uiState.update { it.copy(error = error.message, isLoading = false) }
}
} catch (e: Exception) {
_uiState.update { it.copy(error = e.message, isLoading = false) }
}
}
}
Repository: Token Exchange and Persistence
The UserRepository class in data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/UserRepository.kt encapsulates the complete authentication business logic. The loginWithOAuth function performs three critical operations: exchanging the authorization code for an access token, retrieving the authenticated user profile, and persisting the credentials locally.
// UserRepository.kt
suspend fun loginWithOAuth(
clientId: String,
clientSecret: String,
code: String
): Result<User> {
return try {
// 1️⃣ Exchange code for access token
val tokenResponse = apiService.getAccessToken(clientId, clientSecret, code)
val accessToken = tokenResponse.accessToken
// 2️⃣ Fetch authenticated user with bearer token
val authHeader = "token $accessToken"
val user = apiService.getAuthenticatedUser(authHeader)
// 3️⃣ Persist credentials to DataStore and local DB
preferencesDataStore.saveAuthToken(accessToken)
preferencesDataStore.saveUsername(user.login)
preferencesDataStore.saveUserId(user.id.toString())
userDao.insertUser(user.toEntity())
Result.success(user)
} catch (e: Exception) {
Result.failure(e)
}
}
This implementation uses UserPreferencesDataStore for secure token storage and a Room database (userDao) for caching the user entity, ensuring offline access to user information.
Network Layer: GitHub API Endpoints
The Retrofit service definition in core/network/src/main/java/com/shuyu/gsygithubappcompose/core/network/api/GitHubApiService.kt declares the OAuth token exchange endpoint. Unlike typical API methods that use relative paths, this endpoint requires the full GitHub URL:
// GitHubApiService.kt
@POST("https://github.com/login/oauth/access_token")
@Headers("Accept: application/json")
suspend fun getAccessToken(
@Query("client_id") clientId: String,
@Query("client_secret") clientSecret: String,
@Query("code") code: String
): AccessToken
The @Headers("Accept: application/json") annotation ensures GitHub returns a JSON response rather than the default form-encoded format, enabling automatic deserialization into the AccessToken data class.
Summary
- Layered Architecture: The implementation separates concerns across UI, ViewModel, Repository, and Network layers for maintainability and testability.
- Custom Scheme Handling: The WebView intercepts
gsygithubapp://authedredirects to extract the authorization code without external browser dependencies. - Secure Token Exchange: The Repository handles the server-to-server code exchange using Retrofit, never exposing the client secret to the UI layer.
- Local Persistence: Access tokens and user profiles are stored in DataStore and Room database, enabling persistent login sessions across app restarts.
- Configuration Management: Client credentials are injected via
BuildConfig, keeping secrets out of source control while allowing different configurations for debug and release builds.
Frequently Asked Questions
How does the app handle the OAuth redirect without leaving the application?
The app uses an embedded WebView with a custom WebViewClient that overrides shouldOverrideUrlLoading. When the URL starts with the custom scheme gsygithubapp://authed, the WebView intercepts the navigation, extracts the code parameter, and invokes the callback rather than attempting to load the URL. This keeps the entire authentication flow within the app's native interface.
Where are the GitHub client ID and secret stored securely?
The client credentials are stored in BuildConfig fields (BuildConfig.CLIENT_ID and BuildConfig.CLIENT_SECRET), which are typically injected through local.properties or environment variables during the build process. According to the source code in LoginScreen.kt, these values are passed to the ViewModel only when needed for the token exchange, and the Repository handles the actual API call, keeping sensitive operations away from the UI layer.
What scopes does the app request during GitHub authorization?
The authorization URL constructed in OAuthScreen requests the following scopes: user, repo, gist, notifications, read:org, and workflow. These permissions allow the app to access user profiles, repositories, gists, notifications, organization membership, and GitHub Actions workflows, providing full functionality for a comprehensive GitHub client application.
How is the access token persisted across app sessions?
After successful authentication, the UserRepository saves the access token to UserPreferencesDataStore using preferencesDataStore.saveAuthToken(accessToken). The username and user ID are also stored separately. This DataStore implementation provides asynchronous, transactional storage that survives app restarts, allowing the app to maintain authenticated sessions without requiring users to log in repeatedly.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →