feat(02-03): define common auth contracts

- Add OIDC result and expect client seam with pinned native AppAuth semantics
- Add secure AuthState JSON store contract and JVM dev actuals for test compilation
This commit is contained in:
2026-04-28 13:48:25 +02:00
parent 3122fdaf37
commit edc2a1d4c8
5 changed files with 124 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Common seam for Authentik OIDC.
*
* Native Android/iOS actuals must use AppAuth (D-01) and bridge AppAuth callback
* APIs with `suspendCancellableCoroutine`, cancelling the underlying AppAuth
* request when the coroutine is cancelled (D-04). Login requests must be public
* PKCE-compatible OIDC requests with exactly these scopes:
* `openid profile email offline_access` (D-06). AppAuth owns state and nonce
* verification.
*
* Refresh must go through AppAuth fresh-token APIs such as
* `performActionWithFreshTokens`, then return the updated AuthState JSON for
* persistence (D-16). Logout must use AppAuth RP-initiated end-session APIs
* before local state is cleared; callers still clear local state if remote
* logout fails so users are never trapped in a stale session (D-19, D-20).
*/
expect class OidcClient() {
suspend fun login(): OidcResult
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String)
}

View File

@@ -0,0 +1,25 @@
package dev.ulfrx.recipe.auth
/**
* Result returned by platform OIDC clients.
*
* `authStateJson` is the opaque AppAuth AuthState JSON blob persisted by
* [SecureAuthStateStore]. Callers must not parse token values out of it directly.
*/
sealed interface OidcResult {
data class Success(
val authStateJson: String,
val accessToken: String,
val idToken: String?,
val expiresAtEpochMillis: Long,
) : OidcResult
data object Cancelled : OidcResult
data object NetworkError : OidcResult
data class AuthError(
val message: String,
val cause: Throwable? = null,
) : OidcResult
}

View File

@@ -0,0 +1,19 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
/**
* Persists the full AppAuth AuthState JSON blob for the current app install.
*
* Mobile actuals must use explicit secure platform storage for token material
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use
* no-arg or default insecure settings implementations for tokens. The stored
* blob is global to the install and must be deleted on logout (D-15).
*/
expect class SecureAuthStateStore() {
fun read(): String?
fun write(authStateJson: String)
fun clear()
}

View File

@@ -0,0 +1,36 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class OidcClient {
actual suspend fun login(): OidcResult {
val token = System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success(
authStateJson = "dev:$token",
accessToken = token,
idToken = null,
expiresAtEpochMillis = Long.MAX_VALUE,
)
}
actual suspend fun refresh(authStateJson: String): OidcResult {
val token = authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
?: System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success(
authStateJson = "dev:$token",
accessToken = token,
idToken = null,
expiresAtEpochMillis = Long.MAX_VALUE,
)
}
actual suspend fun logout(authStateJson: String) = Unit
private companion object {
const val DEV_AUTH_TOKEN = "DEV_AUTH_TOKEN"
}
}

View File

@@ -0,0 +1,17 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class SecureAuthStateStore {
private var authStateJson: String? = null
actual fun read(): String? = authStateJson
actual fun write(authStateJson: String) {
this.authStateJson = authStateJson
}
actual fun clear() {
authStateJson = null
}
}