Google Identity Service Oauth2 detect if consent pop-up is closed - javascript

👋 I am using Google Identity Services, and facing some problems. Have a look at the function below to loginUser and get the access_token:
const client = (window as any).google.accounts.oauth2.initTokenClient({
client_id: process.env.GOOGLE_CLIENT_ID,
scope: `profile email`,
callback: '' // defined at request time
});
const loginUser = async () => {
const tokenResponse = await new Promise<TokenResponse>((resolve, reject) => {
try {
// Settle this promise in the response callback for requestAccessToken()
client.callback = (resp) => {
if (resp.error !== undefined) {
reject(resp);
}
resolve(resp);
};
// requesting access token
client.requestAccessToken({ prompt: 'consent' });
} catch (err) {
console.log(err)
}
});
return tokenResponse;
}
Invoking loginUser() causes a new pop-up.
If the user selects an account, I get the tokenResponse (which contains access_token). Works great. 🚀
But if the user closes the pop-up, the Promise never resolves, since we are waiting for the callback to fire, which never happens. 😥
Is there a way we could detect if the user has closed the pop-up?

I think you can do something in the "error_callback". You can find details at: Handle Errors
const client = google.accounts.oauth2.initCodeClient({
client_id: 'YOUR_GOOGLE_CLIENT_ID',
scope: 'https://www.googleapis.com/auth/calendar.readonly',
ux_mode: 'popup',
callback: myCallback,
error_callback: myErrorCallback // You can do something when popup window closed
});

(Update) Prospective Solution
It looks like the google developers have added the error handlers now into the new Google Identity Services. :)
Checkout the documentation at https://developers.google.com/identity/oauth2/web/guides/error.
(I still haven't tested it. Hence putting it as a prospective solution). Happy coding!
Original Answer
Here are the two solutions which you can consider if you're facing this issue.
Solution 1
Go back to the old gapi based login. (Not recommended, as it will be deprecated soon). For more details, on deprecation, refer to this blog by Google.
Solution 2
We add a javascript focus event listener just after opening the popup. So, whenever the user closes the popup and returns to the parent window, we shall consider it as client_focused_back_to_window / pop_up_closed event.
The only edge case is when the user doesn't close the popup and directly returns to the window; the focus event listener will be fired. But I think that's okay because if the user again clicks on Sign In with Google button again, the same pop-up window gets reused (thanks to _blank parameter used by Google Identity services while creating the popUp window).
const client = (window as any).google.accounts.oauth2.initTokenClient({
client_id: process.env.GOOGLE_CLIENT_ID,
scope: `profile email`,
callback: '' // defined at request time
});
/**
* Function to login the user and return the tokenResponse
*
* It throws error if the login fails or the user cancels the login process
*/
const loginUser = async () => {
const tokenResponse = await new Promise<google.accounts.oauth2.TokenResponse>(
(resolve, reject) => {
const focusEventHandler = () => {
reject({
error: 'client_focused_back_to_window',
});
window.removeEventListener('focus', focusEventHandler); // removing the event listener to avoid memory leaks
};
// adding an event listener to detect if user is back to the webpage
// if the user "focus" back to window then we shall close the current auth session
window.addEventListener('focus', focusEventHandler);
// Settle this promise in the response callback for requestAccessToken()
client.callback = (resp) => {
if (resp.error) {
reject(resp);
}
resolve(resp);
};
// requesting access token
client.requestAccessToken({ prompt: 'consent' });
},
);
return tokenResponse;
}
PS: We've been using this solution in production, and so far, thousands, if not millions, of users have tried to log in via Google. Everything is working fine so far. 🙂

It appears that this is not working for the current version of GSI.
It did work for the old gapi version and if the popup were to be closed you would get a response with the error: {error: "popup_closed_by_user"}. As referenced in this answer: Google SSO login error: "popup_closed_by_user"
Hopefully adding the #google-oauth tag will allow someone at Google to see this and hopefully update this script.
Please see other referenced question: Google Oauth popup cancellation callback
This is referring to the documentation on https://developers.google.com/identity/oauth2/web/guides/use-code-model#trigger_oauth_20_code_flow and https://developers.google.com/identity/oauth2/web/guides/use-token-model#initialize_a_token_client
In fact the documentation states: Users may close the account chooser or sign-in windows, in which case your callback function will not be invoked..
Question for Google - how can we detect this?!

Related

Inserting ACL after creating Google Room Resource frequently throws error

I have a Google Cloud function which first creates a Google Room Resource using resources.calendars.insert method from the Google admin sdk,
and right after I try to insert an ACL using Acl: insert method from the google calendar api.
Similar to the following code:
const AdminService = google.admin({version: 'directory_v1'});
try {
const response = await AdminService.resources.calendars.insert(options); // options omitted
} catch (error) {
console.log(`Google Room Resource FAIL`);
console.error(error.message);
}
await new Promise((r) => setTimeout(r, 10000));
const CalendarService = google.calendar({version: 'v3'});
try {
const res = await CalendarService.acl.insert(option); // options omitted
console.log(res);
} catch (error) {
console.log(error);
throw new Error(error.message);
}
As for the authentication, I am using a service account with the correct scopes which impersionates an admin user with the correct permissions. This is how I generate the required JWT token:
const generateJWT = async (scope:string[])=>{
const jwtClient = new google.auth.JWT(
client_email, // service account
undefined,
private_key,
scope,
subject // admin user
);
return jwtClient;
}
In the options parameter for each api call I directly acquire the token for the auth attribute like this:
const option = {
'calendarId': acl.calendarId,
'auth': await generateJWT('https://www.googleapis.com/auth/calendar'),
'resource': {
'role': acl.role,
'scope': {
'type': acl.scopeType,
'value': acl.scopeValue,
},
},
};
Since I await all api calls, I thought that I will only get the response back when everything is already propagated in Google Workspace but when I do not use the setTimeout in between I always get an Error: Not Found back.
First I had the timeout set to 5 seconds which worked until it didn't so I moved it up to 10 seconds. This worked quite long but now I get again sometimes the Not Found error back...
I don't like the setTimeout hack...and even less if it does not work reliable, so how should I deal with this asynchronous behavior without spinning up any other infrastructure like queues or similar?
Working with Google Workspace Calendar Resource
As a Super Admin on my organization when creating a Calendar Resource, from the API or the Web interface, it could take up to 24 hours to correctly propagate the information of the Calendar for the organizations, which generally affect the time it would take for any application to gather the ID of the newly created calendar, which could explain why you are increasing the time out.
You have already implemented the await option which is one of the best things you can do. You can also review the option to apply exponential back off to your application or similar to Google App Script a Utitlies.sleep().
There are multiple articles and references on how to utilize it for the retry process needed when the Resource itself has not fully propagated correctly.
You can also review the official Calendar API documentation that suggests that the error "Not Found" is a 404 error:
https://developers.google.com/calendar/api/guides/errors#404_not_found
With a suggested action of reviewing the option to set up exponential backoff to the application.
References:
GASRetry - Exponential backoff JavaScript implementation for Google Apps Script
Exponential backoff

Attach event listener on geolocation permission granted

My Setup:
I have got some very simple code:
const onSuccess = () => {
console.log('success');
}
const onError = () => {
console.log('error');
}
navigator.geolocation.getCurrentPosition(onSuccess, onError);
My actual code contains a bit more logic. But for simplicity, I have posted only the required code.
I am asked for the permission when I first visit the page.
Let us assume that, I grant the access to use my geolocation.
success is printed to the console.
So far so good...
Actual Problem:
Everytime I refresh the page, I get success logged in the console.
Actually I want something different. I want to call my function only once, when user grants access to use geolocation.
Now, if user blocks the geolocation and if he again allows the browser to use geolocation, at that time also, success should be logged. But on every refresh, it should not log success.
So, I believe, there should be some kind of eventlistener on allow button, but I don't see it mentioned anywhere.
My research:
I am not sure if I am going in the right direction, but...
On MDN docs here, I can see that there is something called PermissionStatus.onchange.
But it is experimental and not supported on Internet Explorer, Safari and Safari iOS.
If any one of you have used any alternatives, then I would love to check your ideas.
Actual use case
I am developing a weather application. The requirement is:
When user lands on the site, he should be asked for geolocation permission (if not already granted)
Once user grants permission, He should be taken to the page where he can see weather details of his city.
Current problem is that everytime, I refresh the page, it detects the location of the user and takes the user to his city details page. This is bad. This should only happen once, only when user grants access to geolocation.
My actual tech stack
React
Typescript
Update:
I am getting some suggestions to store the user's permission status to local-storage.
But if I store the permission in local-storage then I have some problems.
Let me explain in detail by taking an example.
Part till when everything works fine:
If user grants access to read his location.
Then I take him to city details page.
Then I save in local-storage that user has granted the permission.
Now if user refreshes the page, then everything seems to work fine.
When I get problems:
Now user goes to chrome settings and delete the location permissions for my site.
Then user refreshes my site, he will see a popup asking him for location permission.
If he then allows the permission, then I won't have any way to know that user has again allowed the permission because in my local-storage, I already have location granted stored. So, I won't be able to take him to city details page.
After waiting for sometime, I did not get any solutions other than using local-storage. So, I took a mixed approach. In my approach, I use navigator.permissions on whichever browser has implemented it and for other browsers, I use localstorage.
I am using react, so my solution includes react code, but still the idea can be used for pure javascript or for any javascript framework.
I have created a custom hook for that:
useGeoLocation.ts
import { useState, useEffect } from 'react';
interface Coordinates {
latitude: number;
longitude: number;
};
const useGeoLocation = (localStorageKey = 'is-location-granted') => {
const [shouldRequestLocation, setShouldRequestLocation] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<GeolocationPositionError>();
const [coords, setCoords] = useState<Coordinates>();
const onSuccess = (location: GeolocationPosition) => {
setLoading(false);
setCoords({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
});
if (!('permissions' in navigator)) {
localStorage.setItem(localStorageKey, JSON.stringify(true));
}
}
const onError = (error: GeolocationPositionError) => {
setLoading(false);
setError(error);
}
useEffect(() => {
if ('permissions' in navigator) {
navigator.permissions.query({ name: 'geolocation' }).then((permissionStatus) => {
if (permissionStatus.state !== 'granted') {
setShouldRequestLocation(true);
}
});
} else {
const isLocationGranted = JSON.parse(localStorage.getItem(localStorageKey) || 'false');
if (!isLocationGranted) {
setShouldRequestLocation(true);
}
}
}, []);
useEffect(() => {
if(!('geolocation' in navigator)) {
const geoError = new GeolocationPositionError();
setError({
...geoError,
message: 'Geolocation not supported'
});
} else if (shouldRequestLocation) {
setLoading(true);
navigator.geolocation.getCurrentPosition(
onSuccess,
onError,
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 18000000 },
);
}
}, [shouldRequestLocation]);
return { loading, error, coords };
};
export default useGeoLocation;
The code is preety straight forward, but if anyone finds any difficulties, please let me know and I will update my answer accordingly.
You can save the Permission Status to local storage and check at every page refresh if permissions have changed:
const onSuccess = () => {
console.log('success');
if (localStorage.getItem("permission_status") !== "ok") {
// fire permission onchange event
// update local storage
}
}
const onError = () => {
console.log('error');
if (localStorage.getItem("permission_status") === "ok") {
// fire permission onchange event
// update local storage
}
}
navigator.geolocation.getCurrentPosition(onSuccess, onError);
My suggestion is rather than saving the user's permission status, save the user's GeolocationPosition object in the success callback.
So you can compare the object instead of comparing permission status.
Since you can't use navigator.permissions because of macOS browsers' don't support it, the only solution is saving the coordination data itself for comparation.
So your code will look like this:
const onSuccess = (pos) => {
if (localStorage.getItem("latitude") !== pos.coords.latitude ||
localStorage.getItem("longitude") !== pos.coords.longitude)
{
// fire permission onchange event
// update local storage
console.log('success');
}
}
const onError = () => {
console.log('error');
}
navigator.geolocation.getCurrentPosition(onSuccess, onError);
EDIT
Just saw your comment with #Zeke Hernandez. This is permission independent solution, so I think it should work for you.
I don't know how you are rendering the user's city detail page, but I believe you will use the user's location data for rendering the page, and we can use the data that we are saving to local storage in the success callback.
I don't see other possibilities or alternative.s

Check if Firebase Facebook user exists without creating a user starting from anonymous user

In Firebase I need to check if a Facebook user exists without creating the user. Initially the user is anonymous, and they try to login with Facebook. I want this to fail if the Facebook account is not already linked to a user in my system. It won't be linked to the current user because they are anonymous,
If I use Auth.signInAndRetrieveDataWithCredential I expected a "auth/user-not-found" error, but instead the user is simply created. Is this a bug or expected?
let credential = firebase.auth.FacebookAuthProvider.credential(
event.authResponse.accessToken)
firebase.auth().signInAndRetrieveDataWithCredential(credential).then( (userCredential) => {
let user = userCredential.user
app.debug("DEBUG: Existing user signed in:"+user.uid)
this.loginSuccess(user)
}).catch( (err) => {
app.error("ERROR re-signing in:"+err.code)
$("#login_status_msg").text(err)
})
If I use User.reauthenticateAndRetrieveDataWithCredential instead I get the error "auth/user-mismatch" which makes sense because user is currently anonymous. However, I was expecting "auth/user-not-found" may be thrown instead if the credential doesn't exist, but that doesn't happen.
I don't see a way to take my anonymous user, have them login with Facebook and then see if another user is already linked to that Facebook credential without creating the user if it doesn't exist.
If you're wondering why? My scenario is:
The system allows anonymous users
A user logs in, then converts to a logged in user by registering with Facebook.
App uninstall
App reinstall
User starts up the app and is initially anonymous.
They try and login with Facebook again. At this point I want to stop them from creating a user if they don't have one already. If they have a user ID already, the code works fine and changes their anonymous account ID to the original user ID which is good.
I found a solution! It wasn't too hard to implement, but it does seem hacky.
So we know that when using signInAndRetrieveDataWithCredential(cred) for facebook login, the account is created even if it does not exist yet. To solve this, we need to make sure that we handle the following three things:
Detect if the account is new
Delete the current account that was created by firebase
Throw an error to get out of the current flow and return to wherever you were before.
I just implemented and tested this solution, and it seems to work great:
// ... do your stuff to do fb login, get credential, etc:
const userInfo = await firebase.auth().signInAndRetrieveDataWithCredential(credential)
// userInfo includes a property to check if the account is new:
const isNewUser = _.get(userInfo, 'additionalUserInfo.isNewUser', true)
// FIRST, delete the account we just made.
// SECOND, throw an error (or otherwise escape the current context.
if (isNewUser) {
firebase.auth().currentUser.delete()
throw new Error('Couldn\'t find an existing account.')
}
// If the user already exists, just handle normal login
return userInfo.user
The reason I did this was to ensure that users had to go through the "create account flow" in my app. Your case would be really easy to implement as well, something like the following:
let credential = firebase.auth.FacebookAuthProvider.credential(event.authResponse.accessToken)
firebase.auth().signInAndRetrieveDataWithCredential(credential)
.then(userCredential => {
const isNewUser = userCredential.additionalUserInfo.isNewUser
if (isNewUser) {
firebase.auth().currentUser.delete()
// The following error will be handled in your catch statement
throw new Error("Couldn't find an existing account.")
}
// Otherwise, handle login normally:
const user = userCredential.user
app.debug("DEBUG: Existing user signed in:"+user.uid)
this.loginSuccess(user)
}).catch( (err) => {
app.error("ERROR re-signing in:"+err.code)
$("#login_status_msg").text(err)
})
You can use linkAndRetrieveDataWithCredential:
let credential = firebase.auth.FacebookAuthProvider.credential(
event.authResponse.accessToken);
anonymousUser.linkAndRetrieveDataWithCredential(credential).then( (userCredential) => {
// Firebase Auth only allows linking a credential if it is not
// already linked to another account.
// Now the anonymous account is upgraded to a permanent Facebook account.
}).catch( (err) => {
// Check for code: auth/credential-already-in-use
// When this error is returned, it means the credential is already
// used by another account.
})
You can use the method fetchSignInMethodsForEmail to check if an specific email is already associated to an specific provider or not. Doing this you will be able to check if one if the SighInMethods of the email associated to your user contains Facebook.com or not.
I show you below an example about how I manage this cases on my application. I'm using an RxJavaWrapper on my code, but you will understand the point of how to manage it:
RxFirebaseAuth.fetchSignInMethodsForEmail(authInstance, email)
.flatMap { providerResult ->
if (!providerResult.signInMethods!!.contains(credential.provider)) {
return#flatMap Maybe.error<AuthResult>(ProviderNotLinkedException(credential.provider))
} else {
return#flatMap RxFirebaseAuth.signInWithCredential(authInstance, credential)
}
}
.subscribe({ authResult ->
//Manage success
}, { error ->
//Manage error
})
First I check the providers associated to the email of the user(You can retrieve it from the provider)
If the list of SignInMethods contains my credential provider, I throw an error, if not, I call my signInWithCredential method to create the user.
Continue your workflow.
What I did to solve this problem without relying on the call to linkAndRetrieveDataWithCredential to fail and using the catch block to sign in the already existing user is to save the userID field that getCurrentAccessToken returns.
const { userID } = data;
this.props.setFacebookId(userID); // saves the userID on the server
I can later check if this userID already exists next time the user signs up with facebook.

Stay logged in when using msal.js

I'm building a small JS app for my Microsoft ToDo tasks and use the msal.js library to accommodate the authentication process.
This works perfectly fine, I get a popup, I authenticate my profile, the popup closes and my tasks appear on my screen.
But: It doesn't seem to remember that I authenticated before; Every time I run my webpack app and the page is booted it shows the popup and asks for authentication. Once I've authenticated and just refresh my page, it just shows me the tasks without showing the popup again. I haven't tried waiting for an hour but I think it has something to do with not properly refreshing my access token. I'm not that involved with the Outlook/Microsoft API that I can really figure out if I'm using it correctly.
In short: How can I authenticate once so that the next time I start my app the tasks are shown without having to authenticate again?
My init function
this.userAgentApplication = new Msal.UserAgentApplication(microsoftTasksClientId, null, function (errorDes, token, error, tokenType) {
// this callback is called after loginRedirect OR acquireTokenRedirect (not used for loginPopup/aquireTokenPopup)
console.log(token)
})
let user = this.userAgentApplication.getUser()
if (!user) {
const self = this
// this.userAgentApplication = new Msal.UserAgentApplication(microsoftTasksClientId)
this.userAgentApplication.loginPopup([`${this.apiRootUrl}Tasks.readwrite`]).then(function (token) {
self.idToken = token
user = self.userAgentApplication.getUser()
if (user) {
self.getSilentToken()
}
}, function (error) {
console.log(error)
})
} else {
this.getSilentToken()
}
And my getSilentToken function
const self = this
this.userAgentApplication.acquireTokenSilent([`${this.apiRootUrl}Tasks.readwrite`]).then(function (token) {
console.log('ATS promise resolved', token)
self.accessToken = token
self.getTasks()
}, function (err) {
console.log(err)
})
Please not that my code isn't refactored AT ALL! ;-)
What version of MSAL are you using?
There is a problem in 0.1.1 version that storage is 'sessionStorage' by default and can't be really changed. In that case your login is saved just for currently opened window and you will be forced to relogin even when opened new browser window.
You should use 'localStorage' to achieve what you want and pass it as a constructor parameter for UserAgentApplication.
Here is a fix in their repo for this:
https://github.com/AzureAD/microsoft-authentication-library-for-js/commit/eba99927ce6c6d24943db90dfebc62b948355f19

How to detect login window close event on LinkedIn Javascript SDK?

I am using LinkedIn Javascript SDK to log my users in, and I need to detect if a user closes the login/auth window before they complete the login or authorization. Current SDK doesn't fire the login callback when the window is closed (I naturally expect it to be called with IN.User.isAuthorized() set to false just like in Facebook Javascript SDK).
How can I detect when the user closes the Login with LinkedIn window?
The LinkedIn API is a bit of a nightmare to deal with.
I had a similar issue where it was firing multiple requests if they opened the auth window more than once. I solved this by adding a count each time they opened the window and then ignoring everything if count > 1. My solution involves Angular and Promises so I'm not going to post the full solution.
For you, I would just add authTriggered and authComplete variables. The triggered gets set when they click they link/button to authorise with LinkedIn and the complete variable gets set in the auth callback.
Something like this perhaps?
var LinkedIn = LinkedIn || {};
LinkedIn = {
authTriggered: false,
authComplete: false,
authorise: function() {
IN.User.authorize(function() {
this.authComplete = true;
});
}
};
var authLink = document.getElementById('auth-link');
authLink.addEventListener('click', function(e) {
e.preventDefault();
LinkedIn.authTriggered = true;
LinkedIn.authorise();
});
Instead of IN.User.authorize() please use IN.UI.Authorize() as
var linkedin = IN.UI.Authorize().place();
linkedin.onWindowRemove.subscribe(function() {
// perform some action
});
thanks sanju for this answer
https://sanjutalks.wordpress.com/2017/10/04/linkedin-javascript-sdk-detecting-login-windows-close-event/

Categories