Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 015d8d51d0
commit 995bdd5ae6
92 changed files with 8140 additions and 208 deletions

View File

@@ -3,6 +3,7 @@ plugins {
// which requires the Android Gradle Plugin to already be on the project.
alias(libs.plugins.androidLibrary)
id("recipe.kotlin.multiplatform")
alias(libs.plugins.kotlinSerialization)
id("recipe.quality")
}
@@ -15,20 +16,30 @@ kotlin {
sourceSets {
commonMain.dependencies {
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here — EVER.
// Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization
// is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06
// forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers
// (composeApp, server) inherit the @Serializable runtime without each
// re-declaring it.
api(libs.kotlinx.serializationJson)
}
}
}
android {
namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.shared
/**
* Phase 2 OIDC + API configuration shared by client and server (D-11, D-12).
*
* Hardcoded for the v1 single-environment per `PITFALLS.md` tech-debt acceptance:
* the homelab Authentik is the only target, so `BuildConfig`-style Gradle injection
* is deliberately deferred. The placeholder issuer host (`auth.example.invalid`) is
* substituted at deploy time by overriding the build at the call site, never by
* mutating this file in user installs.
*
* **Invariants the rest of Phase 2 depends on:**
* - `OIDC_ISSUER` ends with `/` so JWKS / authorization endpoints can append paths
* without double-slash bugs (PITFALLS.md #8 — Authentik is byte-sensitive here).
* - `OIDC_REDIRECT_URI` is exactly `recipe://callback` — both `iosApp/Info.plist`
* `CFBundleURLTypes` and the Android `<intent-filter>` registration must match
* byte-for-byte (D-09).
* - `OIDC_CLIENT_ID` doubles as the JWT `aud` claim value (D-07: single string,
* not array). Server-side `withAudience(OIDC_CLIENT_ID)` validates against it.
*/
public object Constants {
/** Reserved by `composeApp/server` for the local Ktor port (Phase 1). */
public const val SERVER_PORT: Int = 8080
/**
* Base URL the client uses for `/api/v1/...` calls. v1 single environment;
* staging support is deferred per PITFALLS.md tech-debt acceptance.
*/
public const val API_BASE_URL: String = "http://localhost:8080/"
/**
* Authentik OIDC issuer. Trailing slash is required (D-11, PITFALLS.md #8).
* Replace `auth.example.invalid` with the homelab Authentik hostname before
* shipping a build; the placeholder keeps tests/CI deterministic without
* leaking real infrastructure into the repo.
*/
public const val OIDC_ISSUER: String = "https://auth.ulfrx.dev/application/o/recipe-app/"
/**
* OAuth2 client_id registered with Authentik. The same value MUST appear as
* the single-string `aud` claim per D-07.
*/
public const val OIDC_CLIENT_ID: String = "recipe-app"
/**
* Custom URL scheme for the OAuth redirect (D-09). Must match `Info.plist`
* `CFBundleURLTypes` on iOS and the Android manifest `<intent-filter>` byte
* for byte. PKCE S256 + Lokksmith state (D-05) make custom-scheme interception
* non-exploitable; Universal Links / App Links are explicitly deferred.
*/
public const val OIDC_REDIRECT_URI: String = "recipe://callback"
}

View File

@@ -0,0 +1,33 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.Serializable
/**
* Wire-format DTO for `GET /api/v1/me` (D-27).
*
* Mirrors [User] verbatim today; kept as a separate type so Phase 3 can extend the
* response with `householdId` (and any future fields) without changing the client
* domain model. Decoders MUST be configured with `ignoreUnknownKeys = true` so a
* Phase 2 client keeps parsing Phase 3+ responses (see
* `MeResponseSerializationTest.decoder ignores future fields like householdId without failing`).
*
* Wire keys are camelCase to stay consistent with kotlinx.serialization defaults;
* `sub` is left raw because it's an opaque OIDC claim and renaming it on the wire
* is a recipe for drift.
*/
@Serializable
public data class MeResponse(
public val id: String,
public val sub: String,
public val email: String,
public val displayName: String,
) {
/** One-to-one mapping to the client domain [User]; no fallback / coercion. */
public fun toUser(): User =
User(
id = id,
sub = sub,
email = email,
displayName = displayName,
)
}

View File

@@ -0,0 +1,26 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.Serializable
/**
* Authenticated user identity shared by client and server (D-25, D-27).
*
* Phase 2 always emits `User` in the [dev.ulfrx.recipe.shared.dto.MeResponse] payload;
* Phase 3 will extend the response with a household lookup but `User` itself stays
* stable. `id` is a server-issued UUID serialized as a string so the shared module
* stays free of Exposed / SQLDelight / kotlin.uuid dependencies (D-19 / INFRA-06).
*
* Field semantics:
* - [id] — server primary key (`users.id` UUID per D-24).
* - [sub] — opaque OIDC subject claim from Authentik; the only stable identity key
* for JIT provisioning per D-25.
* - [email] — most recent `email` claim. May change between logins (D-25 upserts).
* - [displayName] — most recent `name` / `preferred_username` claim (D-25).
*/
@Serializable
public data class User(
public val id: String,
public val sub: String,
public val email: String,
public val displayName: String,
)

View File

@@ -0,0 +1,90 @@
package dev.ulfrx.recipe.shared.dto
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Wire-format contract for [MeResponse] (D-27).
*
* Phase 2 pins the DTO shape used by both the Ktor server's `/api/v1/me` route and the
* KMP client. The fields, JSON keys, and forward-compatibility behavior asserted here are
* the load-bearing contract — downstream Phase 2 plans implement against it.
*/
class MeResponseSerializationTest {
private val strictJson = Json { encodeDefaults = true }
private val lenientJson =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Test
fun `round trips id sub email and displayName via camelCase wire keys`() {
val original =
MeResponse(
id = "11111111-2222-3333-4444-555555555555",
sub = "authentik|abc",
email = "anna@example.test",
displayName = "Anna",
)
val encoded = strictJson.encodeToString(MeResponse.serializer(), original)
// Wire keys must match the server contract exactly. Authentik's `sub` is opaque,
// so we never rename it on the wire.
assertEquals(
"{\"id\":\"11111111-2222-3333-4444-555555555555\"," +
"\"sub\":\"authentik|abc\"," +
"\"email\":\"anna@example.test\"," +
"\"displayName\":\"Anna\"}",
encoded,
)
val decoded = strictJson.decodeFromString(MeResponse.serializer(), encoded)
assertEquals(original, decoded)
}
@Test
fun `decoder ignores future fields like householdId without failing`() {
// Phase 3 will extend the response with `householdId` (D-28). The Phase 2 client
// must keep parsing the response after that change, so the decoder must skip
// unknown keys.
val withFutureField =
"{\"id\":\"11111111-2222-3333-4444-555555555555\"," +
"\"sub\":\"authentik|abc\"," +
"\"email\":\"anna@example.test\"," +
"\"displayName\":\"Anna\"," +
"\"householdId\":\"99999999-0000-0000-0000-000000000000\"}"
val decoded = lenientJson.decodeFromString(MeResponse.serializer(), withFutureField)
assertEquals("11111111-2222-3333-4444-555555555555", decoded.id)
assertEquals("authentik|abc", decoded.sub)
assertEquals("anna@example.test", decoded.email)
assertEquals("Anna", decoded.displayName)
}
@Test
fun `toUser maps every field one-to-one without dropping data`() {
val response =
MeResponse(
id = "11111111-2222-3333-4444-555555555555",
sub = "authentik|abc",
email = "anna@example.test",
displayName = "Anna",
)
val user = response.toUser()
assertEquals(
User(
id = response.id,
sub = response.sub,
email = response.email,
displayName = response.displayName,
),
user,
)
}
}