Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save amal/bb4688055b4dc41986ea8dca8d82aaa1 to your computer and use it in GitHub Desktop.
Save amal/bb4688055b4dc41986ea8dca8d82aaa1 to your computer and use it in GitHub Desktop.
How to implement Apple Sign in in Kotlin Multiplatform
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