Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f86e969
auth logic based on kmp oidc lib
AhmedNMahran Jan 15, 2026
4052780
update AuthenticationManager.kt with correct url scheme, and use plat…
AhmedNMahran Jan 15, 2026
2312a58
fix: replace the invalid .filled button style with the standard Swift…
AhmedNMahran Jan 15, 2026
4b1052e
fix import, concurrency, and Kotlin-to-Swift/Obj-C naming errors
AhmedNMahran Jan 15, 2026
7dc2142
update AuthViewModel.kt and MainActivity.kt wo use sharedPrefs
AhmedNMahran Jan 15, 2026
9943f98
organize code and remove unneeded platform-specific code
AhmedNMahran Jan 17, 2026
1c82c99
move auth logic out of the viewmodel with to AuthRepository.kt and Au…
AhmedNMahran Jan 17, 2026
df30a17
set default userPreProduction to false
AhmedNMahran Jan 17, 2026
58c5cec
fix import in AuthScreen
AhmedNMahran Jan 17, 2026
53247f0
fix swift/kotlin compatibility
AhmedNMahran Jan 17, 2026
8c11e9d
depend on shared AuthViewModel.kt and remove the swift one
AhmedNMahran Jan 17, 2026
5597abf
replace NotificationCenter with native SwiftUI URL handling
AhmedNMahran Jan 17, 2026
d5e03e2
refactor: use oidc lib's auth handling and remove custom code.
AhmedNMahran Jan 18, 2026
ddf2f0f
Merge pull request #93 from quran/task/auth-read-user-data
AhmedNMahran Jan 18, 2026
5c0feb5
call user endpoint and load user data
AhmedNMahran Jan 19, 2026
9e0228f
Merge branch 'auth' of https://github.com/quran/mobile-sync into task…
AhmedNMahran Jan 19, 2026
80e7d23
Merge pull request #94 from quran/task/user-data-handling
AhmedNMahran Jan 19, 2026
195f6d4
chore: code clean up and architecture enhancement
AhmedNMahran Jan 19, 2026
ea336c8
refactor OidcAuthRepository and introduce shared HttpClient in AuthCo…
AhmedNMahran Jan 20, 2026
a92d22c
add README.md
AhmedNMahran Jan 20, 2026
9945c4e
add getAuthHeaders in AuthRepository.kt and OidcAuthRepository.kt to …
AhmedNMahran Jan 20, 2026
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
147 changes: 147 additions & 0 deletions auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Quran Auth Module 🔐

A production-grade **Kotlin Multiplatform (KMP)** library for handling authentication with Quran.com. This module leverages **OpenID Connect (OIDC)** with PKCE and provides a seamless, secure authentication experience across Android and iOS.

---

## 🏗 Architecture

The module follows a layered architecture designed for testability, separation of concerns, and platform independence.

### Design Diagram
```mermaid
graph TD
subgraph "UI Layer (Shared)"
VM[AuthViewModel]
end

subgraph "Domain/Repository Layer (Shared)"
Repo[OidcAuthRepository]
end

subgraph "Data Layer (Shared)"
NDS[AuthNetworkDataSource]
Storage[AuthStorage]
end

subgraph "External Dependencies"
Ktor[Ktor Client]
Settings[Multiplatform Settings]
OIDC[Kalinjul OIDC Lib]
end

VM --> Repo
Repo --> NDS
Repo --> Storage
Repo --> OIDC
NDS --> Ktor
Storage --> Settings
```

### 1. **UI Layer (`AuthViewModel`)**
Platform-agnostic ViewModel using `androidx.lifecycle.ViewModel`. It manages `AuthState` (Idle, Loading, Success, Error) and exposes them via `CommonStateFlow` for easy consumption in both Jetpack Compose and SwiftUI.

### 2. **Repository Layer (`OidcAuthRepository`)**
The brain of the module. It coordinates:
- **Token Lifecycle**: Automated refresh with a 5-minute safety margin.
- **Process Death Survival**: Persists OAuth state (`code_verifier`, `state`) to ensure login can continue even if the OS kills the app during browser interaction.
- **Data Coordination**: Prioritizes local storage for `UserInfo` while keeping it synced with the network.

### 3. **Network Layer (`AuthNetworkDataSource`)**
Uses **Ktor** with `ContentNegotiation` and `KotlinxSerialization`. It handles OIDC token exchanges and fetches extended user profiles from the Quran.com API.

### 4. **Persistence Layer (`AuthStorage`)**
Uses `multiplatform-settings` for platform-agnostic key-value storage:
- **Android**: `SharedPreferences`
- **iOS**: `NSUserDefaults`

---

## 🚀 Getting Started

### 1. 🔑 Registering your App
To use this module, you must register your application with **Quran Foundation**:
1. Visit the [Quran Foundation Developer Portal](https://quran.foundation) (or contact the team for access to the Pre-Production environment).
2. Create a new **OIDC Client**.
3. Set the **Redirect URI** to: `com.quran.oauth://callback` (default).
4. Note your **Client ID** and **Client Secret**.

### 2. 🛠 Configuration
This module uses `BuildKonfig` for secure credential management. Create or update your `local.properties` file in the project root:

```properties
OAUTH_CLIENT_ID=your_client_id_here
OAUTH_CLIENT_SECRET=your_client_secret_here
```

The `AuthConfig` class in the module is pre-configured to use these values and handles the internal endpoints for both production and pre-production environments.

### 3. 📦 Installation
Add the module to your project's `build.gradle.kts`:
```kotlin
implementation(project(":auth"))
```

### 4. ⚙️ Platform Initialization
Since OIDC requires browser redirection, you must initialize the `AuthFlowFactory` in your platform code.

#### **Android (`MainActivity.kt`)**
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val factory = AndroidCodeAuthFlowFactory(this)
AuthFlowFactoryProvider.initialize(factory)
}
```

#### **iOS (`AppDelegate.swift`)**
```swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) -> Bool {
AuthFlowFactoryProvider.shared.doInitialize(factory: IosCodeAuthFlowFactory())
return true
}
```

---

## 💻 Usage

### In Shared Code (Compose/SwiftUI)
The `AuthViewModel` handles the heavy lifting.

```kotlin
val viewModel = AuthViewModel()

// Initiate Login
viewModel.login()

// Observe State
val authState by viewModel.authState.collectAsState()

when (val state = authState) {
is AuthState.Success -> println("Welcome, ${state.user.displayName}")
is AuthState.Loading -> ShowSpinner()
is AuthState.Error -> ShowError(state.exception.message)
else -> ShowLoginButton()
}
```

---

## ✨ Features
- ✅ **OIDC + PKCE**: Modern, secure authentication flow.
- ✅ **Automatic Refresh**: Proactively refreshes tokens 5 minutes before expiration.
- ✅ **JWT Decoder**: Can extract user info from tokens even when offline.
- ✅ **Clock Skew Resistance**: Resilient to device clock mismatches.
- ✅ **KMP Logging**: Integrated with `Kermit` for transparent error tracking.
- ✅ **Process Death Survival**: Handles Android/iOS lifecycle transitions gracefully.

---

## 🛠 Tech Stack
- **Kotlin Multiplatform**
- **Ktor Client** (Network)
- **Kotlinx Serialization** (JSON)
- **Multiplatform Settings** (Persistence)
- **Kermit** (Logging)
- **Kalinjul OIDC** (Core Auth Logic)
112 changes: 112 additions & 0 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties

// 1. Load the local.properties file
val localProperties = Properties().apply {
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { load(it) }
}
}

// 2. Configure BuildKonfig
buildkonfig {
packageName = "com.quran.shared.auth"

defaultConfigs {
// Read from local.properties, provide a fallback for CI/CD environments
val clientId = localProperties.getProperty("OAUTH_CLIENT_ID") ?: ""
val clientSecret = localProperties.getProperty("OAUTH_CLIENT_SECRET") ?: ""

buildConfigField(com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING, "CLIENT_ID", clientId)
buildConfigField(com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING, "CLIENT_SECRET", clientSecret)
}
}

plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library)
alias(libs.plugins.vanniktech.maven.publish)
alias(libs.plugins.buildkonfig)
alias(libs.plugins.kotlin.serialization)
}

kotlin {
iosX64()
iosArm64()
iosSimulatorArm64()


androidTarget {
publishLibraryVariants("release")
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.oidc.appsupport)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.sha2)
implementation(libs.multiplatform.settings.no.arg)
implementation(libs.kermit)
implementation(libs.kotlinx.serialization.json)
api(libs.androidx.lifecycle.viewmodel) // using `api` for better access from swift code

}

commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}

androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}

// No explicit iOS dependencies needed for oidc-appsupport unless specific override
// But we need a ktor engine for iOS if we pass a client.
val appleMain by creating {
dependsOn(commonMain.get())
dependencies {
implementation(libs.ktor.client.darwin)
}
}

iosX64Main.get().dependsOn(appleMain)
iosArm64Main.get().dependsOn(appleMain)
iosSimulatorArm64Main.get().dependsOn(appleMain)
}
}

android {
namespace = "com.quran.shared.auth"
compileSdk = libs.versions.android.compile.sdk.get().toInt()

defaultConfig {
minSdk = libs.versions.android.min.sdk.get().toInt()
}

compileOptions {
sourceCompatibility = JavaVersion.valueOf("VERSION_${libs.versions.android.java.version.get()}")
targetCompatibility = JavaVersion.valueOf("VERSION_${libs.versions.android.java.version.get()}")
}
}

mavenPublishing {
publishToMavenCentral()
signAllPublications()
coordinates(libs.versions.project.group.get(), "auth", libs.versions.project.version.get())

pom {
name = "Quran.com Auth Layer"
description = "A library for authentication with Quran.com"
inceptionYear = libs.versions.project.inception.year.get()
url = libs.versions.project.url.get()
}
}
4 changes: 4 additions & 0 deletions auth/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.quran.shared.auth.di

import org.publicvalue.multiplatform.oidc.appsupport.IosCodeAuthFlowFactory

/**
* Extension to initialize the factory on iOS/Apple platforms.
* Can be called directly from Swift.
*/
fun AuthFlowFactoryProvider.doInitialize() {
this.initialize(IosCodeAuthFlowFactory())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.quran.shared.auth.di

import com.quran.shared.auth.BuildKonfig
import com.quran.shared.auth.model.AuthConfig
import com.quran.shared.auth.persistence.AuthStorage
import com.quran.shared.auth.repository.AuthRepository
import com.quran.shared.auth.repository.AuthNetworkDataSource
import com.quran.shared.auth.repository.OidcAuthRepository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.publicvalue.multiplatform.oidc.DefaultOpenIdConnectClient
import org.publicvalue.multiplatform.oidc.OpenIdConnectClient
import org.publicvalue.multiplatform.oidc.OpenIdConnectClientConfig
import org.publicvalue.multiplatform.oidc.types.CodeChallengeMethod

object AuthConfigFactory {

fun createDefaultAuthConfig(): AuthConfig {
return AuthConfig(
usePreProduction = true,
clientId = BuildKonfig.CLIENT_ID,
clientSecret = BuildKonfig.CLIENT_SECRET
)
}

private val json = Json {
explicitNulls = false
ignoreUnknownKeys = true
}

val sharedHttpClient: HttpClient by lazy {
HttpClient {
install(Logging) {
logger = object : Logger {
override fun log(message: String) = println("HTTP Client: $message")
}
level = LogLevel.ALL
}
install(ContentNegotiation) {
json(json)
}
}
}

val oidcClient: OpenIdConnectClient by lazy {
val config = createDefaultAuthConfig()
DefaultOpenIdConnectClient(
httpClient = sharedHttpClient,
config = OpenIdConnectClientConfig {
endpoints {
authorizationEndpoint = config.authorizationEndpoint
tokenEndpoint = config.tokenEndpoint
}
clientId = config.clientId
clientSecret = null //config.clientSecret IMPORTANT!, passing the config value even if config.clientSecret is null makes api call fail
scope = config.scopes.joinToString(" ")
redirectUri = config.redirectUri
codeChallengeMethod = CodeChallengeMethod.S256
disableNonce = true
}
)
}

// Singletons
val authStorage: AuthStorage by lazy { AuthStorage() }

val authNetworkDataSource: AuthNetworkDataSource by lazy {
AuthNetworkDataSource(
authConfig = createDefaultAuthConfig(),
httpClient = sharedHttpClient
)
}

val authRepository: AuthRepository by lazy {
OidcAuthRepository(
authConfig = createDefaultAuthConfig(),
authStorage = authStorage,
oidcClient = oidcClient,
networkDataSource = authNetworkDataSource
)
}
}
Loading
Loading