Pure Kotlin auth components for native Android apps. No React Native dependency.
Include the library modules from wherever you've placed them (local path, git submodule, etc.):
include ':google-auth-native-android'
project(':google-auth-native-android').projectDir = file('../simple-auth/packages/google-auth-native-android')
include ':simple-auth-native-android'
project(':simple-auth-native-android').projectDir = file('../simple-auth/packages/simple-auth-native-android')android {
compileSdk 34
defaultConfig {
minSdk 24
}
}
dependencies {
implementation project(':google-auth-native-android')
implementation project(':simple-auth-native-android')
}Requirements:
- Min SDK: 24
- Kotlin: 1.9.24+
- Compile SDK: 34
import dev.crown.simpleauth.googleauth.GoogleAuthClient
import dev.crown.simpleauth.googleauth.GoogleAuthConfig
val googleAuthClient = GoogleAuthClient(applicationContext)
googleAuthClient.configure(GoogleAuthConfig(
webClientId = "your-web-client-id.apps.googleusercontent.com",
scopes = listOf("openid", "email", "profile"), // optional — these are the defaults
))Sign-in is a two-step process because Google may require user interaction via an Activity
result. Most SDK methods are suspend and must be called from a coroutine:
import dev.crown.simpleauth.googleauth.GoogleAuthSignInStep
lifecycleScope.launch {
when (val step = googleAuthClient.beginSignIn(activity)) {
is GoogleAuthSignInStep.Completed -> {
// No user interaction needed — authCode is ready
val authCode = step.result.authCode
val grantedScopes = step.result.grantedScopes
}
is GoogleAuthSignInStep.RequiresResolution -> {
// Launch the Google consent UI
activityResultLauncher.launch(step.intentSenderRequest)
}
}
}val activityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
lifecycleScope.launch {
try {
val authResult = googleAuthClient.completeSignIn(result.resultCode, result.data)
// authResult.authCode — send to your server
// authResult.grantedScopes — scopes the user granted
} catch (e: GoogleAuthException) {
// Handle error (see error codes below)
}
}
}import dev.crown.simpleauth.googleauth.GoogleAuthScopeMode
lifecycleScope.launch {
// Request additional scopes (adds to existing)
val step = googleAuthClient.updateScopes(
activity = activity,
scopes = listOf("https://www.googleapis.com/auth/calendar.readonly"),
mode = GoogleAuthScopeMode.ADD,
)
// Handle the step the same way as beginSignIn
// Replace scopes (can remove previously granted)
googleAuthClient.updateScopes(
activity = activity,
scopes = listOf("openid", "email"),
mode = GoogleAuthScopeMode.REPLACE,
)
// Check currently granted scopes
val scopes = googleAuthClient.getGrantedScopes()
// Revoke all access
googleAuthClient.revokeAccess()
// Sign out (preserves granted scopes for next sign-in)
googleAuthClient.signOut()
}| Code | Description |
|---|---|
CONFIG_ERROR |
configure() not called or webClientId blank. |
SIGN_IN_IN_PROGRESS |
Another sign-in is already running. |
SIGN_IN_TIMEOUT |
Sign-in took longer than 60 seconds. |
SIGN_IN_CANCELED |
User dismissed the consent UI. |
ACTIVITY_ERROR |
Activity is finishing or null. |
AUTH_CODE_FAILED |
Google returned no server auth code. |
SIGN_IN_FAILED |
General sign-in failure. |
NOT_SIGNED_IN |
updateScopes called with no active session. |
NO_SCOPE_CHANGE_REQUIRED |
Requested scopes already match granted scopes. |
REVOKE_FAILED |
Failed to revoke Google access. |
SIGN_OUT_FAILED |
Failed to clear credentials. |
All errors are thrown as GoogleAuthException(errorCode, message, cause?).
Encrypted on-device token storage using Android's EncryptedSharedPreferences with
AES-256-GCM encryption.
import dev.crown.simpleauth.native.EncryptedSharedPreferencesTokenStore
val tokenStore = EncryptedSharedPreferencesTokenStore(
context = applicationContext,
prefsName = "simple_auth_secure_store", // optional — default shown
key = "tokens", // optional — default shown
)Implement this for custom storage backends:
import dev.crown.simpleauth.native.TokenStore
import dev.crown.simpleauth.native.StoredTokens
class MyTokenStore : TokenStore {
override suspend fun getTokens(): StoredTokens? { /* ... */ }
override suspend fun setTokens(tokens: StoredTokens) { /* ... */ }
override suspend fun clearTokens() { /* ... */ }
}data class StoredTokens(
val accessToken: String,
val refreshToken: String,
val expiresAtMs: Long, // absolute milliseconds since epoch
)HTTP client for calling your auth server. Uses OkHttp.
import dev.crown.simpleauth.native.SimpleAuthApiClient
val apiClient = SimpleAuthApiClient(
baseUrl = "https://api.example.com",
okHttpClient = OkHttpClient(), // optional — custom client
refreshPath = "/auth/refresh", // optional — default shown
googleOAuthPath = "/auth/oauth/google", // optional — default shown
)lifecycleScope.launch {
val tokens: AuthTokensResponse = apiClient.refresh(refreshToken)
// tokens.accessToken, tokens.refreshToken, tokens.expiresIn
}import dev.crown.simpleauth.native.OAuthResponse
lifecycleScope.launch {
when (val response = apiClient.exchangeGoogleAuthCode(authCode)) {
is OAuthResponse.Authenticated -> {
// response.user.id, response.user.email
// response.tokens — store and navigate to home
}
is OAuthResponse.NeedsPhone -> {
// response.sessionToken, response.email, response.flowType, response.maskedPhone
// Navigate to phone verification
}
is OAuthResponse.NeedsLinking -> {
// response.sessionToken, response.maskedEmail
// Navigate to OTP linking verification
}
}
}sealed interface OAuthResponse {
data class Authenticated(val user: SimpleAuthUser, val tokens: AuthTokensResponse) : OAuthResponse
data class NeedsPhone(val sessionToken: String, val email: String, val flowType: String, val maskedPhone: String?) : OAuthResponse
data class NeedsLinking(val sessionToken: String, val maskedEmail: String) : OAuthResponse
}Manages token lifecycle with automatic refresh and deduplication.
import dev.crown.simpleauth.native.TokenManager
val tokenManager = TokenManager(
store = tokenStore,
api = apiClient,
refreshLeewaySeconds = 30, // optional — default: 30
)Returns the current access token. Automatically refreshes if the token expires within
the leeway window. Returns null if no tokens are stored. Must be called from a coroutine.
lifecycleScope.launch {
val token = tokenManager.getAccessToken()
}Store tokens from a server auth response. Converts expiresIn (seconds) to an absolute
expiresAtMs timestamp. Must be called from a coroutine.
lifecycleScope.launch {
tokenManager.setTokensFromResponse(response.tokens)
}Remove all stored tokens (logout). Must be called from a coroutine.
lifecycleScope.launch {
tokenManager.clearTokens()
}Manually trigger a refresh. Concurrent callers share one in-flight request. Must be called from a coroutine.
lifecycleScope.launch {
val refreshed = tokenManager.refreshTokens()
}Minimal Activity showing the full Google sign-in flow:
class AuthActivity : AppCompatActivity() {
private lateinit var googleAuthClient: GoogleAuthClient
private lateinit var tokenManager: TokenManager
private lateinit var apiClient: SimpleAuthApiClient
private val signInLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
lifecycleScope.launch {
try {
val authResult = googleAuthClient.completeSignIn(result.resultCode, result.data)
handleAuthCode(authResult.authCode)
} catch (e: GoogleAuthException) {
showError(e.message ?: "Sign-in failed")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
googleAuthClient = GoogleAuthClient(this)
googleAuthClient.configure(GoogleAuthConfig(
webClientId = "your-web-client-id.apps.googleusercontent.com",
))
val tokenStore = EncryptedSharedPreferencesTokenStore(this)
apiClient = SimpleAuthApiClient(baseUrl = "https://api.example.com")
tokenManager = TokenManager(store = tokenStore, api = apiClient)
// Trigger sign-in (e.g., on button click)
findViewById<Button>(R.id.signInButton).setOnClickListener {
lifecycleScope.launch { startSignIn() }
}
}
private suspend fun startSignIn() {
try {
when (val step = googleAuthClient.beginSignIn(this)) {
is GoogleAuthSignInStep.Completed -> handleAuthCode(step.result.authCode)
is GoogleAuthSignInStep.RequiresResolution -> signInLauncher.launch(step.intentSenderRequest)
}
} catch (e: GoogleAuthException) {
showError(e.message ?: "Sign-in failed")
}
}
private suspend fun handleAuthCode(authCode: String) {
when (val response = apiClient.exchangeGoogleAuthCode(authCode)) {
is OAuthResponse.Authenticated -> {
tokenManager.setTokensFromResponse(response.tokens)
// Navigate to home
}
is OAuthResponse.NeedsPhone -> {
// Navigate to phone verification with response.sessionToken
}
is OAuthResponse.NeedsLinking -> {
// Navigate to OTP linking with response.sessionToken
}
}
}
private fun showError(message: String) {
// Show error to user
}
}