Open LukaFinzgar opened 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.
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!
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.
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)
}
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.
yes the authorization server (referenced by the redirect URI) is the place that you can capture the params requested in the initial request
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:
SignInWebViewDialogFragment
(or equivalent), add a JavascriptInterface
to it@SuppressLint("SetJavaScriptEnabled")
override fun onCreateView(...) {
(...)
val formInterceptorInterface = FormInterceptorInterface(authenticationAttempt.state)
webView.addJavascriptInterface(formInterceptorInterface, FormInterceptorInterface.NAME)
(...)
}
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()
}
}
/**
* 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!
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.
@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. π
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.
@pedrodanielcsantos Thanks for sharing this solution! did you manage to get the user attributes?
@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.
@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.
@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?
@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.
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?
@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.
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)
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.
@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
π
@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
@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
@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?
@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.
@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
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} } ) ()")
}
}
}
@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.
@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
@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.
@padwekar change the response_mode
to code id_token
@padwekar @erawhctim response_type
to code id_token
@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)
}
@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
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.
@hichamboushaba yes this works with @pedrodanielcsantos solution combined. Thanks for your help.
@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! π
@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.
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
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.
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.
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:
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'
When trying to sign i get this error:![Screenshot_1569846602](https://user-images.githubusercontent.com/45970522/65880503-31b22680-e392-11e9-9784-4c489f6adbea.png)
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