Reputation: 1689
I am using Firebase authentication for my application and using it to authenicate users to a back-end API using JWT tokens. On the API back-end I've configured the JWT-secret, which is the asymmetric keys pulled from this url:
https://www.googleapis.com/service_accounts/v1/jwk/[email protected]
This is all working fine. I recently needed to create a cloud function, which needs to call the API back-end as well. To do this, I'm using the functionality to create a Custom Token found here:
https://firebase.google.com/docs/auth/admin/create-custom-tokens
This creates my token with correct custom claims
let additionalClaims = {
'x-hasura-default-role': 'admin',
'x-hasura-allowed-roles': ['user', 'admin']
}
admin.auth().createCustomToken(userId,additionalClaims).then(function (customToken) {
console.log(customToken);
response.end(JSON.stringify({
token: customToken
}))
})
.catch(function (error) {
console.log('Error creating custom token:', error);
});
however, when I try to use it against the back-end API, I get the "JWTInvalidSignature" error. In my cloud function, I specify the service account that is in my firebase project, but it doesn't seem to help. When I view the two tokens decoded, they definitely appear coming from different services.
CustomToken
{
"aud":
"https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit",
"iat": 1573164629,
"exp": 1573168229,
"iss": "[email protected]",
"sub": "[email protected]",
"uid": "mikeuserid",
"claims": {
"x-hasura-default-role": "admin",
"x-hasura-allowed-roles": [
"user",
"admin"
]
}
}
TOKEN from FireBase Auth
{
"role": "webuser",
"schema": "customer1",
"userid": "15",
"claims": {
"x-hasura-default-role": "user",
"x-hasura-allowed-roles": [
"user",
"admin"
],
"x-hasura-user-id": "OS2T2rdkM5UlhfWLHEjNExZ71lq1",
"x-hasura-dbuserid": "15"
},
"iss": "https://securetoken.google.com/postgrest-b4c8c",
"aud": "postgrest-b4c8c",
"auth_time": 1573155319,
"user_id": "OS2T2rdkM5UlhfWLHEjNExZ71lq1",
"sub": "OS2T2rdkM5UlhfWLHEjNExZ71lq1",
"iat": 1573164629,
"exp": 1573168229,
"email": "[email protected]",
"email_verified": false,
"firebase": {
"identities": {
"email": [
"[email protected]"
]
},
"sign_in_provider": "password"
}
}
How can I get this customToken to work with the existing JWT secret keys I have configured??
Upvotes: 3
Views: 2073
Reputation: 26246
As documented in in Firebase Authentication: Users in Firebase Projects: Auth tokens, the tokens from Firebase Auth and the Admin SDK Custom Tokens are not the same, incompatible with each other and are verified differently.
As you are trying to identify the cloud functions instance as an authorative caller of your third-party API, you may use two approaches.
In both of the below methods, you would call your API using postToApi('/saveUserData', { ... });
in each example. You could probably also combine/support both server-side approaches.
For this version, we use a JSON Web Token to certify that the call is coming from a Cloud Functions instance. In this form of the code, the 'private.key'
file is deployed along with your function and it's public key kept on your third-party server. If you are calling your API very frequently, consider caching the 'private.key' file in memory rather than reading it each time.
If you ever wish to invalidate this key, you will have to redeploy all your functions that make use of it. Alternatively, you may modify the fileRead()
call and store it in Firebase Storage (secure it - readable by none, writable by backend-admin). Which will allow you to refresh the private key periodically by simply replacing the file.
const jwt = require('jsonwebtoken');
const rp = require('request-promise-native');
const functionsAdminId = 'cloud-functions-admin';
function getFunctionsAuthToken(jwtOptions) {
jwtOptions = jwtOptions || {};
return new Promise((resolve, reject) => {
// 'private.key' is deployed with function
fs.readFile('private.key', 'utf8', (err, keyData) => {
if (err) { return reject({src: 'fs', err: err}); }
jwt.sign('cloud-functions-admin', keyData, jwtOptions, (err, token) => {
if (err) { return reject({src: 'jwt', err: err}); }
resolve(token);
});
});
});
}
Example Usage:
function postToApi(endpoint, body) {
return getFunctionsAuthToken()
.then((token) => {
return rp({
uri: `https://your-domain.here${endpoint}`,
method: 'POST',
headers: {
Authorization: 'Bearer ' + token
},
body: body,
json: true
});
});
}
If you are using express on your server, you can make use of express-jwt
to deserialize the token. If configured correctly, req.user
will be 'cloud-functions-admin'
for requests from your Cloud Functions.
const jwt = require('express-jwt');
app.use(jwt({secret: publicKey});
An alternative is to avoid the public-private key by using Firebase Auth. This will have the tradeoff of potentally being slower.
const admin = require('firebase-admin');
const rp = require('request-promise-native');
const firebase = require('firebase');
const functionsAdminId = 'cloud-functions-admin';
function getFunctionsAuthToken() {
const fbAuth = firebase.auth();
if (fbAuth.currentUser && fbAuth.currentUser.uid == uid) {
// shortcut
return fbAuth.currentUser.getIdToken(true)
.catch((err) => {src: 'fb-token', err: err});
}
return admin.auth().createCustomToken(functionsAdminId)
.then(function(customToken) {
return fbAuth.signInWithCustomToken(token)
.then(() => {
return fbAuth.currentUser.getIdToken(false)
.catch((err) => {src: 'fb-token', err: err});
})
.catch((err) => {src: 'fb-login', err: err});
})
.catch((err) => {src: 'admin-newtoken', err: err});
}
Example Usage:
function postToApi(endpoint, body) {
return getFunctionsAuthToken()
.then((token) => {
return rp({
uri: `https://your-domain.here${endpoint}`,
method: 'POST',
headers: {
Authorization: 'Bearer ' + token
},
body: body,
json: true
});
});
}
On your server, you would use the following check:
// idToken comes from the received message
admin.auth().verifyIdToken(idToken)
.then(function(decodedToken) {
if (decodedToken.uid != 'cloud-functions-admin') {
throw 'not authorized';
}
}).catch(function(error) {
// Handle error
});
Or if using express, you could attach it to a middleware.
app.use(function handleFirebaseTokens(req, res, next) {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
var token = req.headers.authorization.split(' ')[1];
admin.auth().verifyIdToken(idToken)
.then((decodedToken) => {
req.user = decodedToken;
next();
}, (err) => {
//ignore bad tokens?
next();
});
} else {
next();
}
});
// later on: req.user.uid === 'cloud-functions-admin'
If your client uses Firebase Authentication from an SDK to log in and your server uses the Admin SDK, you can use the client's ID token on the cloud function to speak to your server to verify a user by essentially "passing the parcel".
firebase.auth().currentUser.getIdToken(/* forceRefresh */ true).then(function(idToken) {
// Send token to your cloud function
// ...
}).catch(function(error) {
// Handle error
});
// idToken comes from the client app
admin.auth().verifyIdToken(idToken) // optional (best-practice to 'fail-fast')
.then(function(decodedToken) {
// do something before talking to your third-party API
// e.g. get data from database/secret keys/etc.
// Send original idToken to your third-party API with new request data
}).catch(function(error) {
// Handle error
});
// idToken comes from the client app
admin.auth().verifyIdToken(idToken)
.then(function(decodedToken) {
// do something with verified user
}).catch(function(error) {
// Handle error
});
Upvotes: 3