Forked from Nek-12/AppleCredentialLauncher.native.kt
Created
September 26, 2024 10:06
-
-
Save amal/bb4688055b4dc41986ea8dca8d82aaa1 to your computer and use it in GitHub Desktop.
How to implement Apple Sign in in Kotlin Multiplatform
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package pro.respawn.app.feature.account.thirdparty | |
import androidx.compose.runtime.Composable | |
import co.touchlab.kermit.Logger | |
import kotlinx.cinterop.ExperimentalForeignApi | |
import kotlinx.cinterop.cstr | |
import kotlinx.cinterop.memScoped | |
import kotlinx.cinterop.reinterpret | |
import kotlinx.coroutines.CancellationException | |
import kotlinx.coroutines.suspendCancellableCoroutine | |
import okio.ByteString.Companion.toByteString | |
import org.koin.compose.koinInject | |
import platform.AuthenticationServices.ASAuthorization | |
import platform.AuthenticationServices.ASAuthorizationAppleIDCredential | |
import platform.AuthenticationServices.ASAuthorizationAppleIDProvider | |
import platform.AuthenticationServices.ASAuthorizationController | |
import platform.AuthenticationServices.ASAuthorizationControllerDelegateProtocol | |
import platform.AuthenticationServices.ASAuthorizationErrorCanceled | |
import platform.AuthenticationServices.ASAuthorizationErrorFailed | |
import platform.AuthenticationServices.ASAuthorizationErrorInvalidResponse | |
import platform.AuthenticationServices.ASAuthorizationErrorNotHandled | |
import platform.AuthenticationServices.ASAuthorizationErrorUnknown | |
import platform.AuthenticationServices.ASAuthorizationPasswordProvider | |
import platform.AuthenticationServices.ASAuthorizationScopeEmail | |
import platform.AuthenticationServices.ASAuthorizationScopeFullName | |
import platform.AuthenticationServices.ASPasswordCredential | |
import platform.AuthenticationServices.ASPresentationAnchor | |
import platform.AuthenticationServices.ASUserDetectionStatus.ASUserDetectionStatusLikelyReal | |
import platform.Foundation.NSError | |
import platform.Security.SecAddSharedWebCredential | |
import platform.UIKit.UIApplication | |
import platform.darwin.NSObject | |
import pro.respawn.apiresult.ApiResult | |
import pro.respawn.apiresult.asResult | |
import pro.respawn.apiresult.rethrowCancellation | |
import pro.respawn.app.domain.util.BuildFlags | |
import pro.respawn.app.domain.util.NativeException | |
import pro.respawn.app.domain.util.asException | |
import pro.respawn.app.domain.util.log | |
import pro.respawn.kmmutils.common.fastLazy | |
import pro.respawn.kmmutils.common.requireNotNull | |
import kotlin.coroutines.resume | |
import kotlin.coroutines.resumeWithException | |
import kotlin.coroutines.suspendCoroutine | |
import platform.AuthenticationServices.ASAuthorizationControllerPresentationContextProvidingProtocol as PresentationContext | |
sealed interface SignInCredential { | |
data class Google( | |
val idToken: String, | |
val name: String?, | |
val email: String?, | |
val profilePictureUri: String? | |
) : SignInCredential | |
data class Password( | |
val email: String, | |
val password: String, | |
) : SignInCredential | |
data class Apple( | |
val token: String, | |
val authorizationCode: String, | |
val id: String, | |
val email: String?, | |
val name: String?, | |
val likelyRealPerson: Boolean, | |
) : SignInCredential | |
} | |
// this interface can be easily reused for Google Authentication too on both platforms | |
interface CredentialLauncher { | |
suspend fun signIn(userRequested: Boolean): ApiResult<SignInCredential> | |
suspend fun saveCredentals(email: String, password: String): ApiResult<Unit> | |
suspend fun signOut(): ApiResult<Unit> | |
/** | |
* Whether this manager supports arbitrary credentials, not just social sign in | |
*/ | |
val areCredentialsSupported: Boolean | |
} | |
// MUST not be an object, or the compiler will crash with AssertionError during intrinsics transformation | |
private val UiContext = object : PresentationContext, NSObject() { | |
override fun presentationAnchorForAuthorizationController( | |
controller: ASAuthorizationController | |
): ASPresentationAnchor = UIApplication.sharedApplication.keyWindow.requireNotNull() | |
} | |
@Composable | |
internal actual fun rememberAppleCredentialsLauncher(): CredentialLauncher = koinInject<AppleCredentialManager>() | |
internal class AppleCredentialManager : CredentialLauncher { | |
private val appleProvider by fastLazy { ASAuthorizationAppleIDProvider() } | |
private val passwordProvider by fastLazy { ASAuthorizationPasswordProvider() } | |
private val scopes = listOf(ASAuthorizationScopeEmail, ASAuthorizationScopeFullName) | |
override val areCredentialsSupported = true | |
override suspend fun signIn(userRequested: Boolean) = buildList { | |
// we MUST use only one type of request here, otherwise Apple will never ask the user to sign UP and will | |
// throw an error | |
if (userRequested) add(appleProvider.createRequest().apply { requestedScopes = scopes }) | |
else add(passwordProvider.createRequest()) | |
} | |
.let { ASAuthorizationController(it) } | |
.performSignIn() | |
.log("AppleSignIn") | |
.rethrowCancellation() | |
override suspend fun saveCredentals( | |
email: String, | |
password: String | |
) = ApiResult { saveCredentials(email, password) } | |
override suspend fun signOut() = ApiResult() // no-op for Apple | |
} | |
// pass null for a given email to delete saved credentials | |
@OptIn(ExperimentalForeignApi::class) | |
private suspend fun saveCredentials(email: String, password: String?) = suspendCoroutine { continuation -> | |
memScoped { | |
// should be super careful with these pointers to ensure they are deallocated correctly. | |
// TODO: Not sure how these are even deallocated and where | |
val cfDomain = CFStringCreateWithCString(null, BuildFlags.DeeplinkDomain, kCFStringEncodingUTF8) | |
val cfEmail = CFStringCreateWithCString(null, email, kCFStringEncodingUTF8) | |
val cfPassword = CFStringCreateWithCString(null, password, kCFStringEncodingUTF8) | |
SecAddSharedWebCredential( | |
fqdn = cfDomain, | |
account = cfEmail, | |
password = cfPassword, | |
) handler@{ error -> | |
// can't seem to be able to extract the NSError from this pointer | |
if (error != null) return@handler continuation.resumeWithException( | |
Misconfigured(NativeException(0, "error pointer is not null", null)) | |
) | |
continuation.resume(Unit) | |
} | |
} | |
} | |
private suspend fun ASAuthorizationController.performSignIn() = suspendCancellableCoroutine coroutine@{ cont -> | |
// we MUST store a strong reference to the object or is fill be cleared | |
val delegate = AuthorizationDelegate { cont.resume(it) } | |
cont.invokeOnCancellation { | |
Logger.i { "canceled ASAuthorizationController" } | |
// we MUST keep a strong reference until full completion because cinterop ASAuthorizationController.delegate | |
// is a weak reference. If you remove this (redundant) call, it will stop working. | |
delegate.onComplete = {} | |
cancel() | |
} | |
presentationContextProvider = UiContext | |
this.delegate = delegate | |
performRequests() | |
} | |
private class AuthorizationDelegate( | |
var onComplete: (ApiResult<SignInCredential>) -> Unit, | |
) : NSObject(), ASAuthorizationControllerDelegateProtocol { | |
private fun error(value: String) = onComplete( | |
ApiResult.Error( | |
ThirdPartyError(NullPointerException("Value of $value is null")) | |
) | |
) | |
override fun authorizationController( | |
controller: ASAuthorizationController, | |
didCompleteWithAuthorization: ASAuthorization | |
) { | |
when (val cred = didCompleteWithAuthorization.credential) { | |
is ASAuthorizationAppleIDCredential -> SignInCredential.Apple( | |
id = cred.user, | |
token = cred.identityToken | |
?.toByteString() | |
?.utf8() | |
?: return error("identityToken"), | |
authorizationCode = cred.authorizationCode | |
?.toByteString() | |
?.utf8() | |
?: return error("authorizationCode"), | |
email = cred.email, | |
likelyRealPerson = cred.realUserStatus == ASUserDetectionStatusLikelyReal, | |
name = cred.fullName?.givenName ?: cred.fullName?.nickname | |
).asResult | |
is ASPasswordCredential -> SignInCredential.Password( | |
email = cred.user, | |
password = cred.password | |
).asResult | |
else -> ApiResult.Error(UnsupportedProvider(ClassCastException("Unrecognized type: $cred"))) | |
}.let { onComplete(it) } | |
} | |
override fun authorizationController( | |
controller: ASAuthorizationController, | |
didCompleteWithError: NSError | |
) { | |
val e = didCompleteWithError.asException() | |
when (e.code) { | |
ASAuthorizationErrorCanceled -> CancellationException(e) | |
ASAuthorizationErrorUnknown, | |
ASAuthorizationErrorInvalidResponse -> ThirdPartyError(e) | |
ASAuthorizationErrorFailed, | |
ASAuthorizationErrorNotHandled -> Misconfigured(e) | |
else -> e | |
}.let { onComplete(ApiResult.Error(it)) } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment