Implementing Google OAuth in Firebase Cloud Functions.
Everything is working but I have a weird issue. Everything is working, but I don't know how to identify the user to save the tokens to their user object in Firestore.
Using the google API nodejs library, I create an authURL using the OAuth2 client, set the scopes etc, then redirect the user to it. This works great.
const {google} = require('googleapis');
const oauth2Client = new google.auth.OAuth2(
YOUR_CLIENT_ID,
YOUR_CLIENT_SECRET,
YOUR_REDIRECT_URL
);
const scopes = [
'https://www.googleapis.com/auth/calendar'
];
const authorizationUrl = oauth2Client.generateAuthUrl({
// 'online' (default) or 'offline' (gets refresh_token)
access_type: 'offline',
state: 'state_parameter_gets_passed_back',
scope: scopes,
// Enable incremental authorization. Recommended as a best practice.
include_granted_scopes: true
});
console.log('created an authorizationUrl: ' + authorizationUrl);
res.redirect(authorizationUrl);
I then have an https Cloud Function endpoint set as the redirect URL, waiting for the response.
When it comes I get the code and request the tokens. This also works great, until I get to saveUserToken. Who is the user? My Cloud Function is just listening to responses
exports.recieveGoogleCodeFromResponseURL = functions.https.onRequest(async (req, res) => {
const code = req.query.code;
console.log('got a code, it is:' + code);
const url = require('url');
if (req.query.code != null) {
let userCredential;
console.log('we have a code, trading it for tokens');
let { tokens } = await oauth2Client.getToken(code);
console.log({ tokens });
oauth2Client.setCredentials(tokens);
//THIS IS THE PROBLEM HERE, who is the user to save the tokens to?
saveUserToken(tokens, uid); //saves to Firestore
}
res.json({result: `Got a response from Google`, code: code, scope: req.query.scope});
});
The response looks like this:
{
access_token: "longtoken",
expiry_date: 166...,
refresh_token: "anothertoken",
scope: "https://www.googleapis.com/auth/calendar",
token_type: "Bearer"
}
From what I understand neither the access_token or refresh_token is JWT token I could decode to get user info.
All of the Firebase Cloud Functions examples I have read from Google say something like 'In production you would save this token to a secure persistent DB', which I can do with Firestore. I just can't figure out how to ID the user the callback and code belongs to.
All the code samples that show OAuth with other services (Instagram, LinkedIn, Twitch) either the results come with the user id, or their API allows you to query the service with just the access_token and get the user.
For example in this Login with Instagram example the response comes with the user Id.
Code here > https://github.com/firebase/functions-samples/blob/main/instagram-auth/functions/index.js
const oauth2 = instagramOAuth2Client();
const results = await oauth2.authorizationCode.getToken({
code: req.query.code,
redirect_uri: OAUTH_REDIRECT_URI,
});
functions.logger.log('Auth code exchange result received:', results);
// We have an Instagram access token and the user identity now.
const accessToken = results.access_token;
const instagramUserID = results.user.id;
In this OAuth example from LinkedIn, once again they pass the access token to a LinkedIn endpoint to identify the user. Code here https://github.com/firebase/functions-samples/blob/main/linkedin-auth/functions/index.js
const linkedin = Linkedin.init(results.access_token);
linkedin.people.me(async (error, userResults) => {
if (error) {
throw error;
}
functions.logger.log(
'Auth code exchange result received:',
userResults
);
// We have a LinkedIn access token and the user identity now.
const linkedInUserID = userResults.id;
I can use this Google library to validate an ID token, but I am not getting an ID token back from the OAuth process.
Feels like I am missing something simple. Is there a Google API I can pass an access_token to to identify the user?
The access_token returned by Google OAuth is not a JWT. It's an opaque string that is only meaningful to Google, and that you can use to identify the user with Google APIs.
You can use the Google People API to get information about the user. With the access_token you can query the Google People API and get information about the user
Sh_ghosa's answer sent me down a misleading rabbit hole so I'm providing this answer to protect your time that I've spent for you.
The following code example uses the NodeJS Google Cloud SDK and therefore assumes that the GoogleAuth module is provided in the language you're using.
// libraries (npm)
const {google} = require('googleapis');
const jwt = require('jsonwebtoken');
// business logic
const auth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
const authClient = await auth.getClient();
const accessToken = await authClient.getAccessToken();
const decodedJwt = jwt.decode(accessToken?.res?.data?.id_token);
const principal = decodedJwt?.email; // <:: the principal
console.log('Who am I? ', principal);
Found a solution.
Ask for additional scopes
At first I was just asking for Google Calendar permissions.
const scopes = 'https://www.googleapis.com/auth/calendar'
The trick is to ask for email, and profile as well,
const scopes = 'https://www.googleapis.com/auth/calendar email profile'
If you ask for these additional scopes, Google sends back an id_token with the access_token and the refresh_token
You can get the user email and other information from that id_token.
You can decode the token locally since it came from a secure https session with Google and you check that the state variable passed back matches the one your system generated.
function decodeIdTokenLocally(token){
//we split the id_token at the period ., and just decode the 2nd part
let secondPart = token.split('.')[1];
let localUserJsonString = atob(secondPart);
let localUser = JSON.parse(localUserJsonString);
return localUser
}
Quick note - Asking for more than 1 scope at once gives this ugly checkbox interface where users have to manually click the checkboxes of the scope you are asking for.
Better to have them Login With Google first, which grants the email and profile scopes, then ask for Calendar scope or additional scopes one at a time which will present the nice one click to accept interface.
I haven't tried #sh_gosha suggestion of sending the access_token to the Google People API but I think that would work as well, but it would add an additional API call.
Related
I'm having troubles to authenticate from Firebase Cloud Functions to a custom Node server.
Background:
I use a custom node + express server to receive calls from authenticated clients coming from our website. I can successfully verify their tokens using something like this:
const bearerHeader = req.headers['authorization'];
const bearerToken = bearerHeader.split(' ')[1];
await admin.auth().verifyIdToken( bearerToken );
What I need now:
I need to call one of this endpoints but from a Cloud Function (not from an authenticated web client).
So, on the cloud function I'm calling:
const admin = require('firebase-admin');
admin.initializeApp(); // regular initialization
const token = await admin.app().options.credential?.getAccessToken();
const config = { headers: { Authorization: `Bearer ${token.id_token}` } };
await axios.post(url, body, config);
The problem:
The token.id_token field is missing from getAccessToken():
// token:
{
access_token: 'ya29. ... ... ',
expires_in: 3599,
token_type: 'Bearer'
}
When locally run with other credentials (my user credentials when running firebase functions:shell, for example, or when setting GOOGLE_APPLICATION_CREDENTIALS to a service account credentials file) I do get an extra property id_token that I can use to authenticate. But when deployed and run on the Cloud Function, id_token property is empty.
Any ideas?
Thanks in advance.
PS: an extra problem... the local test with a service account do include id_token, but when authenticating to the server getAccessToken() fails with:
Firebase ID token has incorrect "aud" (audience) claim. Expected "<project-id>" but got "<some-hash>.apps.googleusercontent.com".
Maybe both problems solve the same way. But the server does work properly to verify user tokens coming from a website.
EDIT:
I had to exchange the access_token for an id_token, for which I followed something similar to this, with the projectId as audience.
Now the problem is about the issuer (iss) instead of the audience (aud):
Firebase ID token has incorrect "iss" (issuer) claim. Expected "https://securetoken.google.com/<project id>" but got "https://accounts.google.com".
I guess I could verify the token on the server using the same library, but then my web clients would have the same issuer problem
I'm trying to authenticate an API call made to my server (on Cloud Run) from a Cloud Scheduler cron job.
I'm trying to use a service account to this.
Note: this is all happening inside the same Project.
References:
https://cloud.google.com/scheduler/docs/http-target-auth
This is what I'm doing:
STEP 1 - Create the service account
I went to https://console.cloud.google.com/apis/credentials and created a new service account. I've assigned the role as owner.
STEP 2 - Create the cron job.
I went to https://console.cloud.google.com/cloudscheduler to create the cron job like I always do.
In the service account field I've put my service account e-mail. In the Audience field, I've put my project id because at some point I got an error saying that it was expecting it to be the name of my project id.
This was the error:
Firebase ID token has incorrect "aud" (audience) claim. Expected "PROJECT_ID"
STEP 3 - Running the job and identify decoding the token:
This is the code on my server:
import * as admin from "firebase-admin";
admin.initializeApp({
credential: admin.credential.cert(
// THIS IS THE DEFAULT FIREBASE-ADMIN SERVICE ACCOUNT
// THAT IS AUTOMATICALLY CREATED BY FIREBASE
SERVICE_ACCOUNT as admin.ServiceAccount
)});
// THIS IS THE CODE THAT IS INSIDE MY SERVER TRYING TO VERIFY THE SERVICE ACCOUNT
try {
const authHeader = req.headers.authorization;
console.log(`authHeader: ${authHeader}`);
if (authHeader) {
const idToken = authHeader.split(" ")[1]; // GETS THE USER ID TOKEN
const decodedToken = await admin.auth().verifyIdToken(idToken);
console.log(`decodedToken: ${decodedToken}`);
}
}
And this is the error I'm currently getting:
Firebase ID token has incorrect "iss" (issuer) claim. Expected "https://securetoken.google.com/"my-project-id" but got "https://accounts.google.com". Make sure the ID token comes from the same Firebase project as the service account used to authenticate this SDK. See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token.
Is there anything wrong with the way I'm doing it? Should I not be using firebase-admin to this?
Should I be using google-auth-library to verify the token?
https://github.com/googleapis/google-auth-library-nodejs#verifying-id-tokens
https://developers.google.com/identity/sign-in/web/backend-auth
After a morning in hell trying to debug this, here is what I've found.
It seems that firebase-admin's admin.auth().verifyIdToken() only works for tokens generated from the firebase SDK.
I got it to work by using the google-auth-library directly.
I did the following.
NOTE: The rest of the code is the same (using the same service account as described in the question):
import { OAuth2Client } from "google-auth-library";
export const apiExpressRouteHandler: RequestHandler = async (req, res) => {
try {
const PROJECT_ID = process.env.PROJECT_ID;
const authHeader = req.headers.authorization;
if (authHeader) {
const client = new OAuth2Client(PROJECT_ID);
const ticket = await client.verifyIdToken({
idToken: authHeader.split(" ")[1],
audience: PROJECT_ID
});
// LOGGING ticket PROPERTIES
console.log(`userId: ${JSON.stringify(ticket.getUserId())}`);
console.log(`payload: ${JSON.stringify(ticket.getPayload())}`);
console.log(`envelope: ${JSON.stringify(ticket.getEnvelope())}`);
console.log(`attributes: ${JSON.stringify(ticket.getAttributes())}`);
}
// REST OF THE CODE
}
}
catch(err) {
// ...
}
I'm not sure if the PROJECT_ID is necessary to initialize the client with new OAuth2Client(PROJECT_ID); but it is working like this.
I have a callable function in which I want to access the Sheets API, but not with the service account, I want to impersonate the user. The problem is that I don't want to send the client an authorization URL. The user has already signed into the firebase app with google on the client-side and has consented permission to all the scopes I need (including the Sheets API scope), so I was wondering if it's possible to use the auth object in the context parameter of a callable function to authenticate with other APIs on behalf of the user.
function exportSpreadsheets(data, context)
{
const {google} = require('googleapis');
//How do I create an OAuth2 object I can use to access the API
//without having to send the user an authentication link?
//Maybe using the data in context.auth.token?
const auth = new google.auth.OAuth2();
const sheets = google.sheets({version: 'v4', auth});
sheets.spreadsheets.create()
.then(x =>
{
console.log(x);
});
}
I tried the above code and it doesn't work. I'm struggling a bit to understand all the OAuth2 process.
Thanks!
I figured it out!
The correct way to do this is to send the client's access token as a parameter to the function and use it to access the API on behalf of the user. Like this:
function exportSpreadsheet(data, context)
{
const oauth = new google.auth.OAuth2({
clientId: '<your-apps-client-id>',
clientSecret: '<your-apps-client-secret>',
});
oauth.setCredentials(data);
const sheets = google.sheets({version: 'v4', auth: oauth});
//Use the API
Also, you have to send the provider's access token (which in this case is Google), not Firebase's access token.
I am super hopeful someone can help me - I'm kind of stuck.
I'm happily using firebase auth with Microsoft AD. My AuthProvider is firebase.auth.OAuthProvider('microsoft.com').
When I call firebase.auth().signInWithPopup() with that provider, everything works GREAT. I can pick out the accessToken from the resulting UserCredential and access Microsoft Graph api's no problem (yay!).
Firebase persists and renews the authentication and my app gets the expected callback via onAuthStateChanged with the new firebase.User when the user returns to my SPA later (also yay!).
The bad news (where I'm stuck) is: how do I get the Microsoft Graph accessToken in this flow (e.g. when the user returns to my app later)? I don't want them to have to re-authenticate with another popup (yech).
Basically, how do I go from a valid firebase.User to a MS Graph accessToken when the user returns?
Thanks so so much for any help!
Firebase Auth only focuses on authentication only. They will return the OAuth access token on sign in success via UserCredential but will discard the Microsoft OAuth refresh token and not store any OAuth credential associated with the provider. So you have no way to get a new access token afterwards. If you have a good reason for Firebase Auth to manage OAuth access tokens, please file an official feature request.
UPDATE/answer: so it turns out to be simpler than I thought:
The basic idea is to authenticate (re-authenticate) using firebase and use the same clientID for silent microsoft authentication. However, you must supply a loginHint
parameter to the microsoft auth, even if you were previously authorized. loginHint can
be the email address for the firebase user...
In that scenario, the authentication is shared and you won't need to popup a second sign-in for the "microsoft half" of the process - the firebase auth works fine.
I ended up using microsoft's MSAL library (https://github.com/AzureAD/microsoft-authentication-library-for-js)... something like this:
const graphDebug = false;
const msalLogger = new Logger(msalLogCallback, { level: LogLevel.Error });
export async function graphClient(loginHint: string) {
const msal = new UserAgentApplication({
// gotcha: MUST set the redirectUri, otherwise get weird errors when msal
// tries to refresh an expired token.
auth: { clientId: CLIENT_ID, redirectUri: window.location.origin },
system: { logger: msalLogger },
// TODO: should we set cache location to session/cookie?
});
/**
* Create an authprovider for use in subsequent graph calls. Note that we use
* the `aquireTokenSilent` mechanism which works because firebase has already
* authenticated this user once, so we can share the single sign-on.
*
* In order for that to work, we must pass a `loginHint` with the user's
* email. Failure to do that is fatal.
*/
const authProvider: AuthProvider = callback => {
msal
.acquireTokenSilent({ scopes: SCOPES, loginHint })
.then(result => {
callback(null, result.accessToken);
})
.catch(err => callback(err, null));
};
const client = Client.init({
authProvider,
debugLogging: graphDebug,
});
return client;
}
When you are using signInWithPopup, the result object contains the credentials you are looking for.
firebase.auth().signInWithPopup(provider)
.then(function(result) {
// User is signed in.
// IdP data available in result.additionalUserInfo.profile.
// OAuth access token can also be retrieved:
// result.credential.accessToken
// OAuth ID token can also be retrieved:
// result.credential.idToken
})
.catch(function(error) {
// Handle error.
});
Hope this helps.
If you look deep enough you should find msal access token in firebase response under (firebaseAuth.currentUser as zzx).zzj()
We have an App that uses firebase auth. Now we would like to send the user from the app to our website in order for him to perform some actions.
Is it somehow possible to automatically login the user when he comes from the App? He has already provided his login credentials and we have the auth object from firebase. Can we pass a token or somthing to our website and have the firebase SDK on the site log him automatically?
The common way to do this right now is as follows:
After the user signs in in one website, get the ID token via currentUser.getIdToken()
Post the ID token to your backend to serve the destination website.
On your backend, using Admin SDK, get the ID token, verify it, check the auth_time to make sure it is a recent login to minimize any window of attack. If everything checks out, mint a custom token via admin sdk createCustomToken.
Return the custom token to the website.
On the client side, sign in with custom token: signInWithCustomToken and the same user is now signed in on the website.
I was able to do this a bit easier, without having my own backend server to create a custom token, as long as you are using Google auth. It might work for other providers but here's how I did it with Google auth on Android:
Android:
activity.startActivityForResult(
googleSignInClient.signInIntent,
object : ActivityWithResultListener.OnActivityResultListener {
override fun onActivityResult(resultCode: Int, data: Intent?) {
if (data != null) {
val credential = GoogleAuthProvider.getCredential(account.idToken!!, null)
idToken = account.idToken!!
// Then pass the token to your webview via a JS interface
}
)
Web:
const idTokenFromApp = AndroidBridge.getAuthToken(); // This is the idToken from the Android code above
if (idTokenFromApp) {
// from https://firebase.google.com/docs/auth/web/google-signin
// Build Firebase credential with the Google ID token.
// https://firebase.google.com/docs/reference/js/v8/firebase.auth.GoogleAuthProvider#static-credential
const credential = firebase.auth.GoogleAuthProvider.credential(idTokenFromApp);
// Sign in with credential from the Google user.
firebase.auth.signInWithCredential(credential).catch((error) => {
// Handle Errors here.
const errorCode = error.code;
const errorMessage = error.message;
logError(`Error logging in: ${errorCode} ${errorMessage} token: ${idTokenFromApp}`, error);
});
}