Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 166 additions & 10 deletions app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import ch.rhosys.email.BuildConfig
import ch.rhosys.email.data.log.AppLogger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -15,10 +16,14 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException

/**
* Port of loginClient.ts from @authress/login-react-native.
* Port of loginClient.ts from @authress/login-react-native, cross-checked
* against the more complete @authress/login (web) SDK for antiAbuseHash
* ordering and the session/device/profile endpoints the RN SDK also exposes.
*
* Authress is not a plain OAuth provider and this is deliberately not an
* authorize/token exchange. The flow the SDK implements is:
Expand All @@ -35,11 +40,18 @@ import org.json.JSONObject
*
* The session is then held in cookies rather than in a token pair —
* see [AuthressCookieJar].
*
* Every request carries an `antiAbuseHash` (a proof-of-work computed by
* [JwtManager.calculateAntiAbuseHash]) — Authress rejects `/authentication`
* and `/authentication/{id}/tokens` calls without one. The prop order passed
* into that hash matters (it's part of what's hashed), so each call site
* below mirrors the exact key order the web SDK uses for the equivalent call.
*/
class AuthressLoginClient(
private val context: Context,
private val cookieJar: AuthressCookieJar,
httpClient: OkHttpClient,
private val logger: AppLogger,
) {
/** The SDK's HttpClient appends /api to the origin; every path below is relative to it. */
private val loginUrl = "https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/api"
Expand Down Expand Up @@ -75,22 +87,59 @@ class AuthressLoginClient(
val authenticationRequestId: String,
)

/** Mirrors the web SDK's AuthenticationParameters that make sense for a native app (redirectUrl is fixed to the app's own deep link). */
data class AuthenticationOptions(
val connectionId: String? = null,
val tenantLookupIdentifier: String? = null,
val inviteId: String? = null,
val responseLocation: String? = null,
val flowType: String? = null,
val scopes: List<String>? = null,
val audiences: List<String>? = null,
val connectionProperties: Map<String, String>? = null,
val multiAccount: Boolean? = null,
)

data class Device(val deviceId: String, val name: String)

// ── authenticate ────────────────────────────────────────────────────────

/**
* Begins the login flow and opens the Authress-hosted login page. Returns
* once the browser has been launched; completion arrives via the deep link.
*/
suspend fun authenticate(connectionId: String? = null): Result<Unit> = runCatching {
suspend fun authenticate(options: AuthenticationOptions = AuthenticationOptions()): Result<Unit> = runCatching {
storage.setAuthenticationRequest(null)

val codes = JwtManager.getAuthCodes()
// Key order matches @authress/login's authenticate(): connectionId,
// tenantLookupIdentifier, inviteId, applicationId, audiences.
val antiAbuseHash = JwtManager.calculateAntiAbuseHash(
linkedMapOf(
"connectionId" to options.connectionId,
"tenantLookupIdentifier" to options.tenantLookupIdentifier,
"inviteId" to options.inviteId,
"applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID,
"audiences" to options.audiences,
),
)
val body = JSONObject()
.put("redirectUrl", redirectUri)
.put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID)
.put("codeChallenge", codes.codeChallenge)
.put("codeChallengeMethod", "S256")
.apply { connectionId?.let { put("connectionId", it) } }
.put("antiAbuseHash", antiAbuseHash)
.apply {
options.connectionId?.let { put("connectionId", it) }
options.tenantLookupIdentifier?.let { put("tenantLookupIdentifier", it) }
options.inviteId?.let { put("inviteId", it) }
options.responseLocation?.let { put("responseLocation", it) }
options.flowType?.let { put("flowType", it) }
options.scopes?.let { put("scopes", JSONArray(it)) }
options.audiences?.let { put("audiences", JSONArray(it)) }
options.connectionProperties?.let { put("connectionProperties", JSONObject(it)) }
options.multiAccount?.let { put("multiAccount", it) }
}

val response = post("/authentication", body)
val authenticationUrl = response.getString("authenticationUrl")
Expand All @@ -107,7 +156,7 @@ class AuthressLoginClient(
withContext(Dispatchers.Main) {
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(authenticationUrl))
}
}
}.onFailure { logger.error("Authress", "authenticate() failed", it) }

// ── completeAuthenticationRequest ───────────────────────────────────────

Expand All @@ -127,10 +176,20 @@ class AuthressLoginClient(
throw AuthressException("Authentication request mismatch")
}

// Key order matches @authress/login's token exchange: client_id
// (applicationId), authenticationRequestId, code.
val antiAbuseHash = JwtManager.calculateAntiAbuseHash(
linkedMapOf(
"applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID,
"authenticationRequestId" to authenticationRequestId,
"code" to code,
),
)
val body = JSONObject()
.put("code", code)
.put("codeVerifier", pending.codeVerifier)
.put("redirectUri", pending.redirectUrl)
.put("antiAbuseHash", antiAbuseHash)

try {
post("/authentication/$authenticationRequestId/tokens", body)
Expand All @@ -148,7 +207,7 @@ class AuthressLoginClient(
cookieJar.backupCookies()
storage.setAuthenticationRequest(null)
_sessionEstablished.value = getToken() != null
}
}.onFailure { logger.error("Authress", "completeAuthenticationRequest() failed", it) }

/** True when the redirect belongs to this client. */
fun isRedirect(uri: Uri?): Boolean =
Expand Down Expand Up @@ -219,8 +278,97 @@ class AuthressLoginClient(
_sessionEstablished.value = false
}

// ── linkIdentity ────────────────────────────────────────────────────────

/**
* Links a new identity to the currently signed-in user, following the same
* `/authentication` + deep-link flow as [authenticate] (the redirect lands
* back in [completeAuthenticationRequest]), but with `linkIdentity: true`.
* Requires an existing session. Mirrors the RN SDK's `linkIdentity`, plus
* the antiAbuseHash the web SDK sends for the same call.
*/
suspend fun linkIdentity(connectionId: String? = null, tenantLookupIdentifier: String? = null): Result<AuthenticationResponse> = runCatching {
if (connectionId == null && tenantLookupIdentifier == null) {
throw AuthressException("connectionId or tenantLookupIdentifier must be specified")
}
if (getToken() == null) throw AuthressException("Not logged in")

storage.setAuthenticationRequest(null)
val codes = JwtManager.getAuthCodes()
// Key order matches @authress/login's linkIdentity(): connectionId,
// tenantLookupIdentifier, applicationId.
val antiAbuseHash = JwtManager.calculateAntiAbuseHash(
linkedMapOf(
"connectionId" to connectionId,
"tenantLookupIdentifier" to tenantLookupIdentifier,
"applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID,
),
)
val body = JSONObject()
.put("redirectUrl", redirectUri)
.put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID)
.put("codeChallenge", codes.codeChallenge)
.put("codeChallengeMethod", "S256")
.put("linkIdentity", true)
.put("antiAbuseHash", antiAbuseHash)
.apply {
connectionId?.let { put("connectionId", it) }
tenantLookupIdentifier?.let { put("tenantLookupIdentifier", it) }
}

val response = post("/authentication", body)
val authenticationUrl = response.getString("authenticationUrl")
val authenticationRequestId = response.getString("authenticationRequestId")

storage.setAuthenticationRequest(
AuthStorageManager.PendingAuthentication(
codeVerifier = codes.codeVerifier,
authenticationRequestId = authenticationRequestId,
redirectUrl = redirectUri,
),
)

withContext(Dispatchers.Main) {
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(authenticationUrl))
}
AuthenticationResponse(authenticationUrl, authenticationRequestId)
}.onFailure { logger.error("Authress", "linkIdentity() failed", it) }

// ── profile & devices ──────────────────────────────────────────────────

/** The signed-in user's full profile, including linked identities. */
suspend fun getUserProfile(): Result<JSONObject> = runCatching {
if (getToken() == null) throw AuthressException("Not logged in")
get("/session/profile")
}.onFailure { logger.error("Authress", "getUserProfile() failed", it) }

/** MFA devices registered to the current user; empty (not an error) if none exist or the user is signed out. */
suspend fun getDevices(): Result<List<Device>> = runCatching {
if (getToken() == null) return@runCatching emptyList()
val response = try {
get("/session/devices")
} catch (e: AuthressException) {
if (e.status == 401 || e.status == 404) return@runCatching emptyList()
throw e
}
val devices = response.optJSONArray("devices") ?: JSONArray()
(0 until devices.length()).map { index ->
val device = devices.getJSONObject(index)
Device(deviceId = device.getString("deviceId"), name = device.optString("name"))
}
}.onFailure { logger.error("Authress", "getDevices() failed", it) }

/** Removes an MFA device from the current user's profile. */
suspend fun deleteDevice(deviceId: String): Result<Unit> = runCatching {
delete("/session/devices/$deviceId")
Unit
}.onFailure { logger.error("Authress", "deleteDevice() failed", it) }

// ── HTTP ────────────────────────────────────────────────────────────────

private suspend fun get(path: String): JSONObject =
execute(Request.Builder().url(loginUrl + path).get())

private suspend fun post(path: String, body: JSONObject): JSONObject =
execute(Request.Builder().url(loginUrl + path).post(body.toBody()))

Expand All @@ -238,12 +386,20 @@ class AuthressLoginClient(
.header("X-Powered-By", "Authress Login SDK; Android; ${BuildConfig.VERSION_NAME}")
.build()

http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful) {
val response = try {
http.newCall(request).execute()
} catch (e: IOException) {
logger.warn("Authress", "${request.method} ${request.url.encodedPath} network failure", e)
throw e
}

response.use {
val text = it.body?.string().orEmpty()
if (!it.isSuccessful) {
logger.warn("Authress", "${request.method} ${request.url.encodedPath} failed: ${it.code} $text")
throw AuthressException(
"Authress ${request.method} ${request.url.encodedPath} failed: ${response.code} $text",
status = response.code,
"Authress ${request.method} ${request.url.encodedPath} failed: ${it.code} $text",
status = it.code,
)
}
runCatching { JSONObject(text) }.getOrDefault(JSONObject())
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@ object JwtManager {
return AuthCodes(codeVerifier, Base64.encodeToString(digest, B64_URL))
}

/**
* Proof-of-work anti-abuse hash required on `/authentication` and
* `/authentication/{id}/tokens` calls, matching the SDK's `calculateAntiAbuseHash`:
* a fine-tuner is searched until base64url(SHA-256("timestamp;fineTuner;valueString"))
* starts with "00". `valueString` is the non-empty prop values joined with "|" —
* plain objects (maps) are flattened by their sorted keys' values joined with "-";
* everything else, including lists, stringifies the way JS would when a value
* falls through untouched into `Array.prototype.join('|')`: a list joins its
* elements with "," (no brackets, unlike Kotlin's default `List.toString()`).
*/
fun calculateAntiAbuseHash(props: Map<String, Any?>): String {
val timestamp = System.currentTimeMillis()
val valueString = props.values
.filterNot { it == null || it == "" || it == false }
.joinToString("|") { value ->
when (value) {
is Map<*, *> -> value.keys.map { it.toString() }.sorted()
.joinToString("-") { key -> value[key].toString() }
is List<*> -> value.joinToString(",")
else -> value.toString()
}
}

var fineTuner = 0
while (true) {
fineTuner++
val input = "$timestamp;$fineTuner;$valueString"
val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8))
val hash = Base64.encodeToString(digest, B64_URL)
if (hash.startsWith("00")) return "v2;$timestamp;$fineTuner;$hash"
}
}

/**
* Decodes a JWT payload without verifying the signature — the SDK does the same,
* because the token arrives over TLS from the issuer it is then checked against.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import ch.rhosys.email.data.local.dao.AccountDao
import ch.rhosys.email.data.local.dao.LabelDao
import ch.rhosys.email.data.local.dao.LogDao
import ch.rhosys.email.data.local.dao.RuleDao
import ch.rhosys.email.data.local.dao.SignalDao
import ch.rhosys.email.data.local.dao.TemplateDao
Expand All @@ -13,6 +14,7 @@ import ch.rhosys.email.data.local.dao.ViewDao
import ch.rhosys.email.data.local.entity.AccountEntity
import ch.rhosys.email.data.local.entity.AliasEntity
import ch.rhosys.email.data.local.entity.LabelEntity
import ch.rhosys.email.data.local.entity.LogEntryEntity
import ch.rhosys.email.data.local.entity.RuleEntity
import ch.rhosys.email.data.local.entity.SignalEntity
import ch.rhosys.email.data.local.entity.TemplateEntity
Expand All @@ -32,8 +34,9 @@ import ch.rhosys.email.data.local.entity.ViewEntity
entities = [
AccountEntity::class, AliasEntity::class, ThreadEntity::class, SignalEntity::class,
LabelEntity::class, RuleEntity::class, TemplateEntity::class, ViewEntity::class,
LogEntryEntity::class,
],
version = 2,
version = 3,
exportSchema = true,
)
@TypeConverters(Converters::class)
Expand All @@ -45,6 +48,7 @@ abstract class EmailDatabase : RoomDatabase() {
abstract fun ruleDao(): RuleDao
abstract fun templateDao(): TemplateDao
abstract fun viewDao(): ViewDao
abstract fun logDao(): LogDao

companion object {
const val NAME = "numaeel.db"
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/ch/rhosys/email/data/local/dao/LogDao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ch.rhosys.email.data.local.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import ch.rhosys.email.data.local.entity.LogEntryEntity
import kotlinx.coroutines.flow.Flow

@Dao
interface LogDao {
@Query("SELECT * FROM log_entries ORDER BY id DESC")
fun observeAll(): Flow<List<LogEntryEntity>>

@Insert
suspend fun insert(entry: LogEntryEntity)

/** Keeps the table bounded to the most recent [limit] entries. */
@Query(
"DELETE FROM log_entries WHERE id NOT IN (SELECT id FROM log_entries ORDER BY id DESC LIMIT :limit)",
)
suspend fun trimTo(limit: Int)

@Query("DELETE FROM log_entries")
suspend fun clear()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ch.rhosys.email.data.local.entity

import androidx.room.Entity
import androidx.room.PrimaryKey

/**
* A single application log entry, persisted so the user can review what
* happened (particularly around sign-in and the Authress session calls) and
* report it back to us from the Settings > Logs tab.
*/
@Entity(tableName = "log_entries")
data class LogEntryEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val timestamp: Long,
val level: String,
val tag: String,
val message: String,
val detail: String?,
)
Loading