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:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user