willowtreeapps / sign-in-with-apple-button-android

An Android library for Sign In with Apple
MIT License
282 stars 68 forks source link

Response mode must be form_post when name or email scope is requested #50

Open LukaFinzgar opened 4 years ago

LukaFinzgar commented 4 years ago

When trying to sign i get this error: Screenshot_1569846602

This is my config object (added asterisks for sensitive information): val configuration = SignInWithAppleConfiguration( clientId = "", redirectUri = "", scope = "email name" ) Has there been some recent changes to apple sign in that are not yet implemented in your library? Thanks

alecstwch commented 4 years ago

Hi all, Yes, it seems so. I have the same error here. The SignInWithAppleConfiguration and SignInWithAppleService.AuthenticationAttempt classes must be updated in order to add the 'response_mode' field too.

j-duggirala commented 4 years ago

If we use form_post as response Type does this library works? we need form_post as response type for scope name and email, but the response is going to be triggered as post values. Can we catch post response in webViewClient ? I personally implemented the same way in webview but form_post is kind of breaks everything now!

zarksalman commented 4 years ago
  SignInWithAppleConfiguration configuration = new SignInWithAppleConfiguration.Builder()
            .clientId("")
            .redirectUri("")
            .scope("name") / .scope("email") / .scope("name email")
            .build();

It is my configuration but always got the same page as mentioned above.

youssefhassan commented 4 years ago

Just add this line appendQueryParameter("response_mode", "form_post") around inside SignInWithAppleService.kt

fun create(
            configuration: SignInWithAppleConfiguration,
            state: String = UUID.randomUUID().toString()
        ): AuthenticationAttempt {
            val authenticationUri = Uri
                .parse("https://appleid.apple.com/auth/authorize")
                .buildUpon().apply {
                    appendQueryParameter("response_type", "code")
                    appendQueryParameter("v", "1.1.6")
                    appendQueryParameter("client_id", configuration.clientId)
                    appendQueryParameter("redirect_uri", configuration.redirectUri)
                    appendQueryParameter("scope", configuration.scope)
                    appendQueryParameter("state", state)
                    appendQueryParameter("response_mode", "form_post")
                }
                .build()
                .toString()

            return AuthenticationAttempt(authenticationUri, configuration.redirectUri, state)
        }
marchbold commented 4 years ago

Pretty sure you won't be able to catch the code if you change the response mode.

I have seen other approaches that utilise the server to capture and return the authorisation code to the app which i believe is the only way now Apple have restricted this usage.

m-trieu commented 4 years ago

yes the authorization server (referenced by the redirect URI) is the place that you can capture the params requested in the initial request

pedrodanielcsantos commented 4 years ago

Hi all,

For those who are still struggling with this issue, I'd like to leave you the solution that I found for myself. In the future I'd love to make a PR for this lib to add this but, for the sake of providing the solution quicker, I'll leave this comment here for now. If someone wants to implement this here first, please feel free. πŸ‘

Basically, using Apple's APIs, when you request any scopes (most common being email name), the API requires that you set the response_mode as form_post, otherwise you'll have an error and can't proceed with the authentication process. This means that the authorizationCode and state that apple will send to your redirect URL will be in the page's form_data, which android doesn't provide access to, instead of being query parameters in the URL, as the lib currently provides.

As I was required to use custom scopes for my app, I had to find a way to workaround this detail. The major issue is that Android, natively, doesn't provide any solution to read the form data of a page like it does, for instance, for getting the params value from a URL.

So, the solution I found was to inject a piece of javascript that gathers the form_data key,value pairs and forwards it to the app via a Javascript Interface in the webview.

Here's more or less the setup I've used:

When setting up the webview in SignInWebViewDialogFragment (or equivalent), add a JavascriptInterface to it

@SuppressLint("SetJavaScriptEnabled")
override fun onCreateView(...) {
    (...)
    val formInterceptorInterface = FormInterceptorInterface(authenticationAttempt.state)
    webView.addJavascriptInterface(formInterceptorInterface, FormInterceptorInterface.NAME)
    (...)
}

The FormInterceptorInterface class is where most of the juice is

/**
 * [JavascriptInterface] to be injected in a WebView (with name [NAME], see [WebView.addJavascriptInterface]) that
 * receives the "form_data" from a web page (triggered by [JS_TO_INJECT]) and analyzes the 2 expected fields from apple
 * authentication:
 * - [STATE] : a _nonce_ string set in [AuthenticationAttempt.create] that needs to match [expectedState];
 * - [CODE] : the authorization code that'll be used to authenticate the user.
 */
class FormInterceptorInterface(private val expectedState: String) {
    @JavascriptInterface
    fun processFormData(formData: String) {
        val values = formData.split(FORM_DATA_SEPARATOR)
        val codeEncoded = values.find { it.startsWith(CODE) }
        val stateEncoded = values.find { it.startsWith(STATE) }

        if (codeEncoded != null && stateEncoded != null) {
            val stateValue = stateEncoded.substringAfter(KEY_VALUE_SEPARATOR)
            val codeValue = codeEncoded.substringAfter(KEY_VALUE_SEPARATOR)

            if (stateValue == expectedState) {
                // Success, authorizationCode is codeValue
            } else {
                // Error, state doesn't match.
            }
        } else {
            // Error, couldn't find the required info.
        }
    }

    companion object {
        const val NAME = "FormInterceptorInterface"
        private const val STATE = "state"
        private const val CODE = "code"
        private const val FORM_DATA_SEPARATOR = "|"
        private const val KEY_VALUE_SEPARATOR = "="

        /**
         * This piece of Javascript code fetches all (key, value) attributes from the site's form data and concatenates
         * them in the form: "key [KEY_VALUE_SEPARATOR] value [FORM_DATA_SEPARATOR]".
         * Then, invokes the method [processFormData] on the app's side (that's exposed to Javascript) so that the form
         * data can be analyzed in the app's context.
         */
        val JS_TO_INJECT = """
        function parseForm(form){
            var values = '';
            for(var i=0 ; i< form.elements.length; i++){
                values += 
                    form.elements[i].name + 
                    '${KEY_VALUE_SEPARATOR}' + 
                    form.elements[i].value + 
                    '${FORM_DATA_SEPARATOR}'
            }
            window.${NAME}.processFormData(values);
        }

        for(var i=0 ; i< document.forms.length ; i++){
            parseForm(document.forms[i]);
        }
        """.trimIndent()
    }
}

Then, on your custom web view client, you inject this javascript code when the redirect URL will be invoked

/**
 * Custom web client that waits for [urlToIntercept] to be triggered and, when that happens, injects
 * [javascriptToInject] into the web view.
 */
internal class UrlInterceptorWebViewClient(
        private val urlToIntercept: String,
        private val javascriptToInject: String
) : WebViewClient() {
    override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
        if (url.contains(urlToIntercept)) {
            /*
             * At this point, we already got a response from apple with the "code" that we want to fetch to authenticate
             * the user and the "state" that we set in the initial request.
             * Still, that information is encoded as a "form_data" from the POST request that we sent.
             * As within the native code we can't access that POST's form_data, we inject a piece of Javascript code
             * that'll access the document's form_data, get the info and process it, so that it's available in "our"
             * code.
             */
            view.loadUrl("javascript: (function() { ${javascriptToInject} } ) ()")
        } else {
            super.onPageStarted(view, url, favicon)
        }
    }
}

Note that this WebViewClient is much simpler than SignInWebViewClient because it has less responsibilities. The way this client is setup in SignInWebViewDialogFragment is as follows:

override fun onCreateView(...) {
    (...)
    webView.webViewClient =
            UrlInterceptorWebViewClient(authenticationAttempt.redirectUri, FormInterceptorInterface.JS_TO_INJECT)
    (...)
}

With this setup, in FormInterceptorInterface you have the result you want. To communicate the result "upstream" you can use LiveData, a Callback (like it's done in the lib currently), etc. Please adapt this code to whatever suits you better.

I hope this explanation helps you understand the problem at hand and lay the foundation for a solution that can be used either in your particular apps or, hopefully, ported to the main library itself.

If you have any doubts or issues, feel free to ping me, I'll do my best to check this out from time to time to look for open questions.


Last, but certainly not least, huge kudos to the authors of the lib! It mostly works "out of the box" and it's a great starting point to whoever needs to implement apple sign in on Android! πŸ™ Congrats folks!

evant commented 4 years ago

Just a heads up, injecting javascript won't be possible if we ever implement opening a custom tab instead of using a webview, so this may be a blocker for that.

pedrodanielcsantos commented 4 years ago

@evant thanks for getting back to me! That said, your statement makes total sense!

My comment is applicable to the current implementation of the library and it intends to solve a possible major issue with the implementation (as, for instance, in our case, we explicitly needed custom scopes) that the library didn't cover.

If anyone else has other solutions, I'm all ears. But as this issue was already open for a long time without a proper solution, I though it'd be interesting to provide one. 😊

evant commented 4 years ago

Yep agreed, your solution makes sense to roll into the current implementation. Just making a note so we aren't caught by this in the future.

youssefhassan commented 4 years ago

@pedrodanielcsantos Thanks for sharing this solution! did you manage to get the user attributes?

pedrodanielcsantos commented 4 years ago

@youssefhassan with the solution I proposed above, I managed to fetch the correctly scoped code to then send to our own server (which then exchanges it with the required info).

That said, you should be able to fetch the user property from the JSON response. Disclaimer: I didn't try it, only tried to fetch the code field but, from looking at the documentation (https://developer.apple.com/documentation/signinwithapplejs/incorporating_sign_in_with_apple_into_other_platforms), you should be able to do it.

youssefhassan commented 4 years ago

@pedrodanielcsantos so I was able to get the user json but Apple only returns the user object on the first app authorization

Important Apple only returns the user object the first time the user authorizes the app. Persist this information > from your app; subsequent authorization requests won’t contain the user object.

matteinn commented 4 years ago

@pedrodanielcsantos Thank you so much for your proposed solution. Just out of curiosity what is your server returning in response of the POST request? I'm asking because I tried with a non existent endpoint (which resolves to a 404) and can't read document.forms in the onPageStarted callback, it's empty. Am I missing anything?

pedrodanielcsantos commented 4 years ago

@matteinn Thank you for the acknowledgement! πŸ™

Currently we're completely ignoring the response of our server when the redirect URL is invoked. In FormInterceptorInterface, once we get the value of state and authorizationCode, we dismiss the webview (hence the URL isn't exactly invoked, because we intercept the call right before it happens) and then proceed with our own in-app authentication process using the "regular" authentication API and passing authorizationCode to it.

I'm asking because I tried with a non existent endpoint

TBH I'm unsure about that scenario, as I've never tried it. Is that non-existing endpoint added as a valid redirectUrl in the Apple Developer Site? I'm thinking out loud here, but maybe Apple does some sort of verification to the URL and doesn't send the data if it's not "valid" (i.e. non-existing)? I don't know, just thinking out loud.

matteinn commented 4 years ago

General Request URL: {redacted} Request Method: POST Status Code: 404 Remote Address: {redacted} Referrer Policy: strict-origin-when-cross-origin

Response Headers content-length: 149 content-security-policy: default-src 'self' content-type: text/html; charset=utf-8 date: Fri, 06 Mar 2020 11:12:48 GMT server-timing: 0; dur=4.49; desc="Request" status: 404 ...

Request Headers :authority: {redacted} :method: POST :path: {redacted} :scheme: https accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.9 accept-encoding: gzip, deflate accept-language: en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7,es-ES;q=0.6,es;q=0.5 cache-control: max-age=0 content-length: 112 content-type: application/x-www-form-urlencoded origin: https://appleid.apple.com referer: https://appleid.apple.com/ sec-fetch-dest: document sec-fetch-mode: navigate sec-fetch-site: cross-site sec-fetch-user: ?1 upgrade-insecure-requests: 1 user-agent: Mozilla/5.0 (Linux; Android 10; ONEPLUS A6013 Build/QKQ1.190716.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/80.0.3987.132 Mobile Safari/537.36 x-requested-with: com.willowtreeapps.signinwithapple.sample

Form Data state: {redacted} code: {redacted}

@pedrodanielcsantos Adding more info. Using Chrome Dev Tools I am able to see the POST request being triggered and the form data is there, in this case you can see the code field but works the same for the id_token scope as well. onPageStarted is correctly triggered when the redirect happens, the javascript code is executed but it fails to read the form data because, as I mentioned above, document.forms is empty at this point. That's why I'm surprised it is working fine for you and I am wondering if it can be an implementation issue on the backend. The redirect url is correctly set as a valid a redirectUrl in the Apple Developer dashboard but the POST request on that endpoint returns a 404 since it's not implemented yet (and will never be?). Any thoughts?

pedrodanielcsantos commented 4 years ago

@matteinn thanks for adding additional info! Are you using the standard web view ? Or are you using chrome custom tabs or something? That's the only thing I can think of from the top of my head.

In all honesty, I have no idea of what might be the issue. πŸ€”During my experiments I never had any similar struggle, the solution presented above (reading the fields directly from the document's form_data) always worked more or less as expected.

AradiPatrik commented 4 years ago

I have the same issue as @matteinn , that the document.forms is empty when the script runs. Our servers are echoing back the sent request for us, but if I could use the POST Form Data I would rather use that. After the unsuccessful retrieval of the data, the page loads for me, and the code and id_token is displayed inside the browser (which was echoed back by the server), but even if I run the document.forms after the response has arrived in the chrome dev console, I still get empty array. (even though I can see the form data inside the network tab)

matteinn commented 4 years ago

FYI I ended up having the server echoing back the request just like @AradiPatrik , so that I can fetch the id_token. I have no idea how @pedrodanielcsantos 's solution may actually work (P.S. standard webview to answer your question) with that code snippet.

AradiPatrik commented 4 years ago

@pedrodanielcsantos maybe your server echos back the user info in forms, because I found that document.forms returns the form elements from the document, not the Form Data from the request Thanks by the way for the javascript interface idea, it is definitely useful even if, I am not getting the user info from the document.forms πŸ™‚

youssefhassan commented 4 years ago

@matteinn I got @pedrodanielcsantos 's code working on emulator document.forms size is 1. But when I tried on real device it didn't work. The device is Pixel 4 and using Android 10. The emulator Pixel 3 and using API 27. I'm trying to find out why still

youssefhassan commented 4 years ago

@AradiPatrik Were you able to get code and state in the forms on Android 10 device? For user data, apple sends this one only on the first time the user authorizes the app. I use https://appleid.apple.com/account/manage to deauthorize

Viraj7009 commented 4 years ago

@youssefhassan I'm facing similar issue. @pedrodanielcsantos solution works very well on emulator irrespective of OS but when I run the app on a real device the JS doesn't seems to work properly and its not triggering the processFormData function.

@youssefhassan Have you found any solution for this?

erawhctim commented 4 years ago

@youssefhassan I was able to get the code/state params properly on an Android 10 device as of this week - I needed to change the response_mode to "fragment" and parse them out of the URL fragment within WebView.shouldOverrideUrlLoading. Using "form_post" doesn't trigger any overrides in the webview, and the JS injection worked but didn't find the form properly.

youssefhassan commented 4 years ago

@Viraj7009 @erawhctim I decided to go with the echo server option. To have a server that echoes back the parameters from the redirect uri. And then use the JS injection to get these parameters. I put them in a variable in JS window.auth_params

padwekar commented 4 years ago

Thanks, @pedrodanielcsantos for the interception code. But for some reason, I am not able to get response even after trying to inject js as you suggested. Not sure what's going wrong.

       webView.addJavascriptInterface(formInterceptorInterface, FormInterceptorInterface.NAME)
       webView.webViewClient = object : WebViewClient() {

                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                    super.onPageStarted(view, url, favicon)

                    if(url!!.contains("accountapi-alpha")){
                        view!!.loadUrl("javascript: (function() { ${FormInterceptorInterface.JS_TO_INJECT} } ) ()")
                    }

                }

            }
erawhctim commented 4 years ago

@padwekar I was in the same boat. For some reason, my injected javascript would work, but it didn't find the correct form elements on the page. It was almost as if the form itself had been cleared by the time the JS was injected and executed; debugging and stepping through the JS code, all the forms were empty.

Viraj7009 commented 4 years ago

@youssefhassan @padwekar I was able to get it working with the response_mode as fragment and by not setting any scope

override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { url?.let { if (it.startsWith(urlToIntercept)) { signInWebViewClient.processFormData(url) } } return super.shouldOverrideUrlLoading(view, url) }

And the processFormData parses the url which has identity_token, code and you can decode (JWT) the identity token to get the email and unique code which are 'email' and 'sub' fields

padwekar commented 4 years ago

@Viraj7009 I removed the scope and changed the response mode to fragment. But I am only getting code & state in the redirect URL as response and not the identity_token. Like this:- https://some.domain.co/redirect/client/appleid#state=dummystate1131&code=cdummycode1131.

erawhctim commented 4 years ago

@padwekar change the response_mode to code id_token

Viraj7009 commented 4 years ago

@padwekar @erawhctim response_type to code id_token

padwekar commented 4 years ago

@Viraj7009 @erawhctim I ended up using firebase + apple sign in. The implementation is super easy with just few lines of code. It will use customtabs which is much faster than webview and will always return the name, email, and id token in the response. Thanks, everyone for your help.

val provider = OAuthProvider.newBuilder("apple.com")
provider.setScopes(arrayOf("email", "name"))

val auth = FirebaseAuth.getInstance()
auth.startActivityForSignInWithProvider(this, provider.build())
        .addOnSuccessListener { authResult ->
            // Sign-in successful!
            Log.d(TAG, "activitySignIn:onSuccess:${authResult.user}")
            val user = authResult.user
            val token = (authResult.credential as OAuthCredential).idToken
        }
        .addOnFailureListener { e ->
            Log.w(TAG, "activitySignIn:onFailure", e)
        }
Dhaval3344 commented 4 years ago

@Viraj7009 @erawhctim I ended up using firebase + apple sign in. The implementation is super easy with just few lines of code. It will use customtabs which is much faster than webview and will always return the name, email, and id token in the response. Thanks, everyone for your help.

val provider = OAuthProvider.newBuilder("apple.com")
provider.setScopes(arrayOf("email", "name"))

val auth = FirebaseAuth.getInstance()
auth.startActivityForSignInWithProvider(this, provider.build())
        .addOnSuccessListener { authResult ->
            // Sign-in successful!
            Log.d(TAG, "activitySignIn:onSuccess:${authResult.user}")
            val user = authResult.user
            val token = (authResult.credential as OAuthCredential).idToken
        }
        .addOnFailureListener { e ->
            Log.w(TAG, "activitySignIn:onFailure", e)
        }

@padwekar can you please help me understand for hosting in firebase, I try it but didn't get solution, so please help me with that.

Thanks

hichamboushaba commented 4 years ago

To all those who were struggling with @pedrodanielcsantos solution, I found a fix that's working for me, by injecting the javascript code earlier, on the function shouldInterceptRequest instead:

override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
if (request.url.toString().contains(urlToIntercept)) {
        runOnUiThread { view.loadUrl("javascript: (function() { ${javascriptToInject} } ) ()") }
}
return super.shouldInterceptRequest(view, request)
}

My webview is an inner class of the activity, so I'm calling directly runOnUiThread, but you can post the call to the ui thread using different ways depending on your solution.

This solution is working with a redirect_uri which is returning 404, and on real device running Android 10.

Viraj7009 commented 4 years ago

@hichamboushaba yes this works with @pedrodanielcsantos solution combined. Thanks for your help.

pedrodanielcsantos commented 4 years ago

@hichamboushaba thank you very much for coming up with a solution for this! πŸ™ 😌

I can confirm it works as expected. Thank you very much once again! πŸ™

kirvis250 commented 4 years ago

@hichamboushaba Your solution causes javascript function to be called twice.

i added additional override that prevent's that from happening

    override fun onLoadResource(view: WebView?, url: String?) {
        if (!url.toString().contains(urlToIntercept)) {
            super.onLoadResource(view, url)
        }
    }

Not sure if it's a safe option, but it worked for me.

zionun commented 4 years ago

Hello, I'm adding Sign in with Apple to my Android app and I stumbled upon the same problem with this library. Instead of injecting Javascript to the WebView, I decided to put some html+javascript in the return url. When the return url receives data in POST, it just outputs an empty div with a link with the format $my_return_url."?code=".$_POST['code']."&state=".$_POST['state'] then, on DOM Load there's simply a triggering of the click on that link. This way, the original WebView intercepts the parameters on the URL, closes the window and returns the data to the app with no need of modifying the library. Is it something you would suggest not to do for any reason? Thanks

Arrefelt commented 3 years ago

Hello,

I know this is pretty old now but I also stumbled upon this issue of not being able to catch authorization code in POST redirect since shouldOverrideUrlLoading in WebViewClient isn't triggered for POSTs. I think I've solved this like many others here with a backend redirect approach, this way this library works as is without injecting javascript. I thought I would share my approach since it took myself some time to grasp the apple login flow, mainly because user information often was missing. I of course also welcome feedback if you see any issues with this approach.

Redirect endpoint

Create a POST endpoint that Apple will redirect to when login flow is finished. This endpoint should accept application/x-www-form-urlencoded and will receive code, state and user data. The data structure can be seen here.

Here is actually the only time you will be able to receive user data such as first and last name and it is only included the very first time a user logs in with apple to your application. Therefore I chose to persist that user information here in a "pre auth" state which becomes validated upon full completion once the authorization code is given later on.

If you go to to https://appleid.apple.com/account/manage and remove your application access from your user the next login request will once again include user information in POST redirect which is good to know during development. Kudos to @youssefhassan for telling this in a reply above.

Once this logic is finished I then redirect to a GET version of the same endpoint url, including the code and state as query parameters. This will allow this library to pick them up as intended and that works great. You probably don't need to create this GET endpoint in your backend since this library will intercept it before it reaches there, however I chose to do it anyway just to avoid potential 404s.

Completing login

Once the flow described above finishes, this library will intercept the authorization code in the redirect from your POST endpoint in your Android app. Send this to your API to complete the authorization from previous step and return a session. Completing the authorization is a bit language dependent and is explained better in specific guides than I can do here but roughly comes down to:

  1. Use authorization code to get a token from Apples /auth/token using your clientId, scope, secret and grant_type "authorization_code"
  2. Validate the token. This is done by fetching the public key from /auth/keys and verifying the token signature. This endpoint can return multiple keys, select the one with matching "Kid" value.
  3. Once validated, use claim values in JWT received in step 1 to finish the user login. Remember that you will only be able to retrieve user email (real one or proxy) from this token as user info, no first or last name.
  4. Along with user information, I also persist access_token, refresh_token and the "sub" claim value which is the apple user id. Today I don't see any usage of these but hopefully later on we will able to use them to retrieve user information as indicated here. If so, the persisting logic in the POST redirect endpoint can probably be skipped.
marwia commented 2 years ago

Hi everyone, I have forked this repository and made the fix described by @pedrodanielcsantos with the suggestion of @hichamboushaba. The final callback gets intercepted correctly, so if you want to give it a try please go here. You should be able to try it quickly by importing it with: implementation 'com.github.marwia:sign-in-with-apple-button-android:0.3.1'