Add authentication
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user