diff --git a/Identity/build.gradle.kts b/Identity/build.gradle.kts index 126f627..b1d8c57 100644 --- a/Identity/build.gradle.kts +++ b/Identity/build.gradle.kts @@ -12,7 +12,7 @@ publishing { register("release") { groupId = "gr.indice" artifactId = "identity" - version = "0.0.1" + version = "0.0.2" afterEvaluate { from(components["release"]) @@ -37,7 +37,7 @@ publishing { android { namespace = "gr.indice.identity" - compileSdk = 34 + compileSdk = 36 defaultConfig { minSdk = 26 @@ -77,4 +77,5 @@ dependencies { api(libs.moshi.kotlin) implementation(libs.kotlinx.coroutines.core) + implementation(libs.gson) } \ No newline at end of file diff --git a/Identity/src/main/java/gr/indice/identity/adapters/Converter.kt b/Identity/src/main/java/gr/indice/identity/adapters/Converter.kt new file mode 100644 index 0000000..8faddbb --- /dev/null +++ b/Identity/src/main/java/gr/indice/identity/adapters/Converter.kt @@ -0,0 +1,9 @@ +package gr.indice.identity.adapters + +import gr.indice.identity.utils.Serializer +import okhttp3.ResponseBody + + +fun ResponseBody.toType(target: Class) = + Serializer.moshi.adapter(target) + .run { fromJson(source().peek()) } diff --git a/Identity/src/main/java/gr/indice/identity/client/services/AccountService.kt b/Identity/src/main/java/gr/indice/identity/client/services/AccountService.kt index 935b1b8..827ed7d 100644 --- a/Identity/src/main/java/gr/indice/identity/client/services/AccountService.kt +++ b/Identity/src/main/java/gr/indice/identity/client/services/AccountService.kt @@ -19,6 +19,9 @@ interface AccountService { /** Update the user's current email */ @Throws(ServiceErrorException::class) suspend fun updateEmail(email: String) + /** Confirm the user's current email */ + @Throws(ServiceErrorException::class) + suspend fun confirmEmail(token: String) /** Update the user's current password */ @Throws(ServiceErrorException::class) suspend fun updatePassword(password: UpdatePasswordRequest) @@ -51,6 +54,9 @@ internal class AccountServiceImpl( override suspend fun updateEmail(email: String) = load { accountRepository.update(UpdateEmailRequest(email = email, returnUrl = null)) } + override suspend fun confirmEmail(token: String) { + load { accountRepository.verifyEmail(OtpTokenRequest(token)) } + } override suspend fun updatePassword(password: UpdatePasswordRequest) = load { accountRepository.update(password) } diff --git a/Identity/src/main/java/gr/indice/identity/client/services/AuthorizationService.kt b/Identity/src/main/java/gr/indice/identity/client/services/AuthorizationService.kt index af2fb15..f188880 100644 --- a/Identity/src/main/java/gr/indice/identity/client/services/AuthorizationService.kt +++ b/Identity/src/main/java/gr/indice/identity/client/services/AuthorizationService.kt @@ -2,15 +2,18 @@ package gr.indice.identity.client.services import android.net.Uri import android.util.Base64 +import gr.indice.identity.adapters.toType import gr.indice.identity.apis.AuthRepositoryRepository import gr.indice.identity.apis.DevicesRepository import gr.indice.identity.apis.ThisDeviceRepository import gr.indice.identity.models.DeviceAuthentications +import gr.indice.identity.models.ProblemDetails import gr.indice.identity.models.TokenResponse import gr.indice.identity.models.extensions.AuthCodeGrant import gr.indice.identity.models.extensions.AuthRequest import gr.indice.identity.models.extensions.ClientCredentialsGrand import gr.indice.identity.models.extensions.DeviceAuthGrant +import gr.indice.identity.models.extensions.DeviceAuthGrant.Info.* import gr.indice.identity.models.extensions.PasswordGrant import gr.indice.identity.models.extensions.RefreshTokenGrant import gr.indice.identity.models.extensions.biometricAuth @@ -28,6 +31,11 @@ import java.security.Signature import java.util.concurrent.CancellationException interface AuthorizationService { + /** + * Create oAuth2Grand for biometric or pin. Also return the grand if need it. + */ + suspend fun generateGrand(type: DeviceAuthGrant.Info): OAuth2Grant + /** Try login with any grant */ @Throws(ServiceErrorException::class) suspend fun login(grand: OAuth2Grant) @@ -78,6 +86,57 @@ internal class AuthorizationServiceImpl( private val client: Client, private val configuration: IdentityConfig ): BaseService(), AuthorizationService { + override suspend fun generateGrand(type: DeviceAuthGrant.Info): OAuth2Grant { + return when(type) { + is Biometric -> { + try { + val codeVerifier = CryptoUtils.createCodeVerifier() + val verifierHash = CryptoUtils.sha256(codeVerifier) + + val authRequest = DeviceAuthentications.AuthorizationRequest.biometricAuth( + codeChallenge = verifierHash, deviceIds = thisDeviceRepository.ids, client = client + ) + + val challenge = load { devicesRepository.authorize(authRequest = authRequest) }.challenge!! + val signature = CryptoUtils.getSignature() + val key = CryptoUtils.getPrivateKey(CryptoUtils.KeyType.BIOMETRIC) + signature.initSign(key) + + val signed = type.signatureUnlock(signature).run { + update(challenge.toByteArray()) + sign().let { Base64.encodeToString(it, Base64.NO_WRAP) } + } + + val public = CryptoUtils.getPemFromKey(CryptoUtils.KeyType.BIOMETRIC) + + DeviceAuthGrant.biometric( + challenge = challenge, + codeSignature = signed, + verifier = codeVerifier, + deviceIds = thisDeviceRepository.ids, + publicKey = public, + client = client) + + } catch (e: Exception) { + if (e is ServiceErrorException) { + e.error.toType(ProblemDetails::class.java)?.let { error -> + if (error.detail == "Device is unknown" || error.title == "invalid_request") { + deviceService.removeRegistrationFingerprint() + //If device doesn't exist or is invalid request clear also pin registration + deviceService.removeRegistrationDevicePin() + } + } + } + throw e + } + } + is Pin -> { + val pinHash = CryptoUtils.createPinHash(type.value, thisDeviceRepository.ids.device) + DeviceAuthGrant.pin(pin = pinHash, deviceIds = thisDeviceRepository.ids, client = client) + } + } + } + override suspend fun login(grand: OAuth2Grant) { val tokenResponse = load { authRepositoryRepository.authorize(grand) } tokenStorage.parse(tokenResponse) @@ -89,8 +148,7 @@ internal class AuthorizationServiceImpl( override suspend fun login(pin: String) { try { - val pinHash = CryptoUtils.createPinHash(pin, thisDeviceRepository.ids.device) - login(DeviceAuthGrant.pin(pin = pinHash, deviceIds = thisDeviceRepository.ids, client = client)) + login(generateGrand(type = Pin(pin))) } catch (e: Exception) { throw e } @@ -98,35 +156,8 @@ internal class AuthorizationServiceImpl( override suspend fun loginBiometric(signatureUnlock: suspend (Signature) -> Signature) { - val codeVerifier = CryptoUtils.createCodeVerifier() - val verifierHash = CryptoUtils.sha256(codeVerifier) - - val authRequest = DeviceAuthentications.AuthorizationRequest.biometricAuth( - codeChallenge = verifierHash, deviceIds = thisDeviceRepository.ids, client = client - ) - - val challenge = load { devicesRepository.authorize(authRequest = authRequest) }.challenge!! - try { - val signature = CryptoUtils.getSignature() - val key = CryptoUtils.getPrivateKey(CryptoUtils.KeyType.BIOMETRIC) - signature.initSign(key) - - val signed = signatureUnlock(signature).run { - update(challenge.toByteArray()) - sign().let { Base64.encodeToString(it, Base64.NO_WRAP) } - } - - val public = CryptoUtils.getPemFromKey(CryptoUtils.KeyType.BIOMETRIC) - - login(grand = DeviceAuthGrant.biometric( - challenge = challenge, - codeSignature = signed, - verifier = codeVerifier, - deviceIds = thisDeviceRepository.ids, - publicKey = public, - client = client)) - + login(grand = generateGrand(Biometric(signatureUnlock))) } catch (e: Exception) { if (e is CancellationException) { // Canceled prompt by user throw e diff --git a/Identity/src/main/java/gr/indice/identity/client/services/DevicesService.kt b/Identity/src/main/java/gr/indice/identity/client/services/DevicesService.kt index 2131b14..46a2c9c 100644 --- a/Identity/src/main/java/gr/indice/identity/client/services/DevicesService.kt +++ b/Identity/src/main/java/gr/indice/identity/client/services/DevicesService.kt @@ -7,6 +7,7 @@ import gr.indice.identity.apis.ThisDeviceRepository import gr.indice.identity.client.IdentityClientOptions import gr.indice.identity.models.CreateDeviceRequest import gr.indice.identity.models.DeviceAuthentications +import gr.indice.identity.models.DeviceClientType import gr.indice.identity.models.DeviceInfo import gr.indice.identity.models.UpdateDeviceRequest import gr.indice.identity.models.extensions.biometric @@ -98,9 +99,9 @@ interface DevicesService { @Throws(ServiceErrorException::class) suspend fun registerDeviceFingerprint(signatureUnlock: suspend (Signature) -> Signature) : suspend (CallbackType.OtpResult) -> Unit /** Remove a device pin registration */ - suspend fun removeRegistrationDevicePin() + fun removeRegistrationDevicePin() /** Remove a fingerprint registration */ - suspend fun removeRegistrationFingerprint() + fun removeRegistrationFingerprint() /** Trigger enable current device's trust status */ @Throws(ServiceErrorException::class) suspend fun enableDeviceTrust(deviceSelection: DeviceSelection) @@ -289,13 +290,13 @@ internal class DevicesServiceImpl( } } - override suspend fun removeRegistrationDevicePin() { + override fun removeRegistrationDevicePin() { CryptoUtils.deleteKeyPair(CryptoUtils.KeyType.PIN) encryptedStorage.storeBoolean(StorageKey.devicePinKey, false) _hasDevicePin.value = false } - override suspend fun removeRegistrationFingerprint() { + override fun removeRegistrationFingerprint() { CryptoUtils.deleteKeyPair(CryptoUtils.KeyType.BIOMETRIC) encryptedStorage.storeBoolean(StorageKey.hasFingerPrint, false) _hasFingerPrint.value = false @@ -310,7 +311,7 @@ internal class DevicesServiceImpl( val devices = (devicesInfo.userDevices.value ?: emptyList()).filter { it.deviceId != ids.device } - val currentTrustedCount = devices.count { it.isTrusted == true } + val currentTrustedCount = devices.count { it.isTrusted == true && it.clientType != DeviceClientType.BROWSER } val swapDeviceId = if (currentTrustedCount >= identityOptions.maxTrustedDevicesCount) { when(val selection = deviceSelection(devices)) { diff --git a/Identity/src/main/java/gr/indice/identity/client/services/UserService.kt b/Identity/src/main/java/gr/indice/identity/client/services/UserService.kt index 50969a5..ee1ab28 100644 --- a/Identity/src/main/java/gr/indice/identity/client/services/UserService.kt +++ b/Identity/src/main/java/gr/indice/identity/client/services/UserService.kt @@ -12,6 +12,7 @@ interface UserService { val userInfo: StateFlow @Throws(ServiceErrorException::class) suspend fun refreshUserInfo() + fun clearUserInfo() } internal class UserServiceImpl(private val userInfoRepository: UserInfoRepository): BaseService(), UserService { @@ -22,4 +23,8 @@ internal class UserServiceImpl(private val userInfoRepository: UserInfoRepositor _userInfo.value = load { userInfoRepository.getUserInfo() } } + override fun clearUserInfo() { + _userInfo.value = null + } + } diff --git a/Identity/src/main/java/gr/indice/identity/models/ProblemDetails.kt b/Identity/src/main/java/gr/indice/identity/models/ProblemDetails.kt index b202964..d5ef627 100644 --- a/Identity/src/main/java/gr/indice/identity/models/ProblemDetails.kt +++ b/Identity/src/main/java/gr/indice/identity/models/ProblemDetails.kt @@ -14,7 +14,8 @@ data class ProblemDetails( @Json(name = "error_description") val errorDescription: String? = null, @Json(name = "authorization_details") - val authorizationDetails: Any? = null + val authorizationDetails: Any? = null, + val requiresOtp: Boolean? = null ) { val description : String get() { diff --git a/Identity/src/main/java/gr/indice/identity/models/extensions/OAuth2Grants.kt b/Identity/src/main/java/gr/indice/identity/models/extensions/OAuth2Grants.kt index b893de7..b00956f 100644 --- a/Identity/src/main/java/gr/indice/identity/models/extensions/OAuth2Grants.kt +++ b/Identity/src/main/java/gr/indice/identity/models/extensions/OAuth2Grants.kt @@ -4,6 +4,8 @@ import gr.indice.identity.apis.OpenIdApi import gr.indice.identity.apis.ThisDeviceIds import gr.indice.identity.protocols.Client import gr.indice.identity.protocols.OAuth2Grant +import java.net.URLEncoder +import java.security.Signature private fun Map.filterNulls() = @@ -38,6 +40,10 @@ data class PasswordGrant( "password" to password, "device_id" to deviceId ).filterNulls() + //Comment this because converts the scopes to urlEncode -> invalid_scope replaces the + with %2B + //.mapValues { entry -> + // URLEncoder.encode(entry.value,"UTF-8") + //} } //endregion Password grant @@ -89,6 +95,12 @@ data class DeviceAuthGrant( val client_id: String?, val scope: String?, ): OAuth2Grant { + + sealed interface Info { + data class Biometric(val signatureUnlock: suspend (Signature) -> Signature): Info + data class Pin(val value: String): Info + } + override val grantType = "device_authentication" override val params: Map get() = mapOf( diff --git a/Identity/src/main/java/gr/indice/identity/protocols/Grand.kt b/Identity/src/main/java/gr/indice/identity/protocols/Grand.kt index 2d32d02..0f34d5e 100644 --- a/Identity/src/main/java/gr/indice/identity/protocols/Grand.kt +++ b/Identity/src/main/java/gr/indice/identity/protocols/Grand.kt @@ -1,12 +1,14 @@ package gr.indice.identity.protocols +import com.google.gson.Gson + interface OAuth2Grant { val params: Map val grantType: String } fun OAuth2Grant.with(authorizationDetails: Any): OAuth2Grant { - val extras = "authorization_details" to authorizationDetails.toString() + val extras = "authorization_details" to Gson().toJson(authorizationDetails) return OAuthParamsWrapper(parent = this, extras = extras) } diff --git a/Identity/src/main/java/gr/indice/identity/utils/Exceptions.kt b/Identity/src/main/java/gr/indice/identity/utils/Exceptions.kt index 4ebc5d8..319f2c8 100644 --- a/Identity/src/main/java/gr/indice/identity/utils/Exceptions.kt +++ b/Identity/src/main/java/gr/indice/identity/utils/Exceptions.kt @@ -1,10 +1,11 @@ package gr.indice.identity.utils import okhttp3.ResponseBody +import java.io.IOException /** * Throw when something goes wrong with Identity. * @param code [Int] * @param error [ResponseBody] */ -class ServiceErrorException(val code: Int?, val error: ResponseBody): Exception() \ No newline at end of file +class ServiceErrorException(val code: Int?, val error: ResponseBody): IOException() \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7e5c93..9f1241e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,9 @@ [versions] agp = "8.3.2" kotlin = "1.9.23" +gson = "2.10.1" -kotlinxCoroutinesCore = "1.8.0" +kotlinxCoroutinesCore = "1.8.1" loggingInterceptor = "5.0.0-alpha.14" moshiKotlin = "1.15.1" retrofit = "2.11.0" @@ -13,6 +14,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshiKotlin" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } [plugins] androidLibrary = { id = "com.android.library", version.ref = "agp" }