Since the default valid time for an access token is 1 hour, I am trying to get refresh tokens to work in my application. I have been stuck with this problem for a couple of weeks now, and cannot seem to fix it. I have verified my refreshAccessToken(accessToken) function to work (where accessToken is an object with the expired token, the refresh token and some other stuff).
I pinpointed the issue to the async session() function. While in the async jwt() function, refreshAccessToken gets called, the async session() function still results in an error, because the parameter token is undefined. This results in the error cannot read property accessToken from undefined (since token.accessToken is on the first line). How could I solve this, and somehow make the function async session() wait for the access token to refresh, before sending it to the client, together with all other information (groups, username etc.)?
/**
* All requests to /api/auth/* (signIn, callback, signOut, etc.) will automatically be handled by NextAuth.js.
*/
import NextAuth from "next-auth"
import AzureAD from "next-auth/providers/azure-ad";
async function refreshAccessToken(accessToken) {
try {
const url = "https://login.microsoftonline.com/02cd5db4-6c31-4cb1-881d-2c79631437e8/oauth2/v2.0/token"
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `grant_type=refresh_token`
+ `&client_secret=${process.env.AZURE_AD_CLIENT_SECRET}`
+ `&refresh_token=${accessToken.refreshToken}`
+ `&client_id=${process.env.AZURE_AD_CLIENT_ID}`
}).then(res => res.json())
.then(res => {
return {
...accessToken,
accessToken: res.access_token,
accessTokenExpires: Date.now() + res.expires_in * 1000,
refreshToken: res.refresh_token ?? accessToken.refreshToken, // Fall backto old refresh token
}
})
} catch (error) {
console.log(error)
return {
...accessToken,
error: "RefreshAccessTokenError",
}
}
}
export const authOptions = {
providers: [
AzureAD({
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
tenantId: process.env.AZURE_AD_TENANT_ID,
authorization: {
params: {
scope: "offline_access openid profile email Application.ReadWrite.All Directory.ReadWrite.All " +
"Group.ReadWrite.All GroupMember.ReadWrite.All User.Read User.ReadWrite.All"
}
}
}),
],
callbacks: {
async jwt({token, account, profile}) {
// Persist the OAuth access_token and or the user id to the token right after signin
if (account && profile) {
token.accessToken = account.access_token;
token.accessTokenExpires = account.expires_at * 1000;
token.refreshToken = account.refresh_token;
token.id = profile.oid; // For convenience, the user's OID is called ID.
token.groups = profile.groups;
token.username = profile.preferred_username;
}
if (Date.now() < token.accessTokenExpires) {
return token;
}
return refreshAccessToken(token);
},
async session({session, token}) {
// Send properties to the client, like an access_token and user id from a provider.
session.accessToken = token.accessToken;
session.user.id = token.id;
session.user.groups = token.groups;
session.user.username = token.username;
const splittedName = session.user.name.split(" ");
session.user.firstName = splittedName.length > 0 ? splittedName[0] : null;
session.user.lastName = splittedName.length > 1 ? splittedName[1] : null;
return session;
},
},
pages: {
signIn: '/login',
}
}
export default NextAuth(authOptions)
I tried searching everywhere online, but it is hard to even find people using Azure AD with NextAuth at all (NOT the B2C version, but the B2B/organisations version).
TLDR: Using the refreshToken to get a new accessToken works, but NextAuth does not pass this token to the frontend, and instead throws an error, since in async session(), token is undefined.
In the provided code, I made the mistake of mixing async with promises (credits to #balazsorban44 on GitHub). This means that the return statement in the .then returns the value to where the fetch request was initiated, instead of it returning a value from the function refreshAccessToken() as a whole. I moved away from using promises and only used an async function:
const req = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `grant_type=refresh_token`
+ `&client_secret=${process.env.AZURE_AD_CLIENT_SECRET}`
+ `&refresh_token=${accessToken.refreshToken}`
+ `&client_id=${process.env.AZURE_AD_CLIENT_ID}`
})
const res = await req.json();
Related
I am trying to log in from Azure Active Directory, and do get a token in return. How do I now verify said token and log in?
I am also trying to get access_token and get userinfo, but get an error when adding token to response_type.
My client code is:
export function AzureLogin(){
useEffect(async () => {
const { authorization_endpoint } = await fetchJSON(
"https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration"
);
const parameters = {
client_id: "my client id",
response_type: "id_token (id_token%20token when trying to get access_token)",
scope: "openid",
nonce: "123",
redirect_uri: window.location.origin + "/login/azure/callback"
}
window.location.href =
authorization_endpoint + "?" + new URLSearchParams(parameters)
}, [])
}
export function AzureCallback(){
useEffect(async () => {
const { id_token } = Object.fromEntries(
new URLSearchParams(window.location.hash.substring(1))
);
await fetch("/api/login/azure", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ id_token }),
})
})
}
And my server code is:
router.post("/azure", async (req, res) => {
const { id_token } = req.body;
res.cookie("id_token", id_token, {signed: true, maxAge: 2 * 60 * 60 * 1000});
const {userinfo_endpoint} = await fetchJSON(
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"
);
const userinfo = await fetchJSON(userinfo_endpoint, {
headers: {
Authorization: `Bearer ${id_token}`,
},
})
console.log(userinfo)
const name = userinfo.name;
const id = userinfo.sub;
const email = userinfo.email;
const picture = userinfo.picture;
db stuff
res.sendStatus(200);
})
To answer your question in your comment.
The purpose of the ID-token is to tell the client details about how the user authenticated. It is typically only used to create the local "session" in the client and then thrown away.
The ID-token typically have a very short lifetime (like 5 minutes in some setups) and it is not supposed to be sent to external services/APIs.
The client is not supposed to look inside the access-token and it doesn't even need to validate it. It is the job of the API that later receives the token to validate the signature of the access-token against the public signing key in AzureAD.
I am using strapi as backend and react in the front-end. So the use case is that the user will signup and that signup will be done using auth0. I have defined some roles for the users signing up as shown on auth0
Roles based on plan taken by user
const _ = require("lodash");
const axios = require("axios");
const jwt = require("../jwt");
module.exports = async (ctx, next) => {
let role;
if (ctx.state.user) {
// request is already authenticated in a different way
return next();
}
try {
const tokenInfo = await axios({ method: "post",
url: `${process.env.AUTH0_URL}/userinfo`,
headers: { Authorization: ctx.request.header.authorization,
},
});
let user_id = tokenInfo.data.sub;
var config = { method: "get",
url: `${process.env.AUTH0_URL}/api/v2/users/${user_id}/roles`,
headers: {Authorization: `Bearer ${jwt.jwtSecret}`,
},
};
axios(config).then(function (response) {
ctx.state.roles = response.data[0].name; // This part does not work in the next policy as ctx.state.role gives undefined in route specific policy
}).catch(function (error) {
console.log(error);
});
// console.log(tokenInfo.data, "tokeninfo");
if (tokenInfo && tokenInfo.data) {
return await next();
}
} catch (err) {
console.log(err.message);
return handleErrors(ctx, err, "unauthorized");
}
Currently these will be managed here only. Now I have a collection which has some research articles which can only be accessed depending upon the plan user has been assigned. In order to protect the route and strapi access I have installed user-permissions plugin in strapi and managing userinfo using a global policy as shown
Project Structure
. So here is the code through which I am checking the user info on every route
Now there are two ways in which I tried solving my problem. First I read the tokenInfo data from userInfo route but unfortunately auth0 is not returning roles assigned. It is only returning standard data like
"name": "ansh5#gmail.com",
"nickname": "ansh5",
"picture": "https://s.gravatar.com/avatar/6fdb83f10321dd7712ac2457b11ea34e?
s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fan.png",
"updated_at": "2021-07-19T08:03:50.461Z",
"user_id": "auth0|60ec0b3721224b0078ac95f4",
So in order to get user role I used the other API and configured it with my auth0 account.
${process.env.AUTH0_URL}/api/v2/users/${user_id}/roles
I am getting the correct response but when I am doing this assignment.
ctx.state.roles = response.data[0].name;
I am getting undefined in my ctx.state.roles in my route specific policy. Does anybody have idea how we manage strapi and auth0 together.
Yes, it's because the axios calls are asynchronous in nature. So as per your code, axios will try to get the user information over a network call, but strapi will not really wait for the response. Instead it will just move forward to the next policy, hence resulting in an undefined user role. To fix this, you need to await for the api response from axios. Try the code below:
const axios = require("axios");
const jwt = require("../jwt");
module.exports = async (ctx, next) => {
let role;
if (ctx.state.user) {
// request is already authenticated in a different way
return next();
}
try {
const tokenInfo = await axios({
method: "post",
url: `${process.env.AUTH0_URL}/userinfo`,
headers: {
Authorization: ctx.request.header.authorization,
},
});
let user_id = tokenInfo.data.sub;
var config = {
method: "get",
url: `${process.env.AUTH0_URL}/api/v2/users/${user_id}/roles`,
headers: {
Authorization: `Bearer ${jwt.jwtSecret}`,
},
};
const resp = await axios(config);
ctx.state.roles = response.data[0].name;
console.log(ctx.state.roles);
// console.log(tokenInfo.data, "tokeninfo");
if (tokenInfo && tokenInfo.data) {
return await next();
}
} catch (err) {
console.log(err.message);
return handleErrors(ctx, err, "unauthorized");
}
}
I am having problems getting the correct token for triggering my cloud function.
When testing through POSTMAN I get the token by running the following command:
gcloud auth print-identity-token
and my functions works correctly.
But on my server I am using the following code. I also do see the token but I get 401 with this token.
// Constants------------
const metadataServerTokenURL = 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=';
async function gToken(){
let token='';
try{
// Fetch the token
const tokenResponse = await fetch(metadataServerTokenURL + 'https://'+process.env.CLOUD_URL, { //URL WITHOUT THE PATH
headers: {
'Metadata-Flavor': 'Google',
},
});
token = await tokenResponse.text();
} catch (err){
console.log(err);
}
return token;
}
---------EDIT-------
The calling function::
app.get("/", async function(req , res){
try {
const token = await getToken();
console.log(`Token: ${token}`);
const functionResponse = await fetch('https://'+process.env.CLOUD_URL+process.env.PATH_TO_FUNC, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`},
});
console.log(`Status: ${await functionResponse.status}`);
res.sendStatus(200);
} catch (err){
console.log(err);
res.status(400).send('Something went wrong')
}
})
My server is my NodeJS code running on AppEngine.
What am I doing wrong please?
----------EDIT 2--------------
I entered the two tokens received using two different ways, they show different information for some reason. Please see below::
Token from the server
Token using gcloud command locally (which works)::
Server code and cloud functions are both hosted in the same region, and are a part of the same project.
process.env.CLOUD_URL > "e****-***2-c******-e******2.cloudfunctions.net"
What #Charles and #John mentioned in the comment is correct. You should include the name of the receiving function in the audience:
As mentioned in the docs:
In the calling function, you'll need to create a Google-signed OAuth ID token with the audience (aud) set to the URL of the receiving function.
const metadataServerTokenURL = 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=';
...
// Fetch the token
const tokenResponse = await fetch(metadataServerTokenURL + `https://${process.env.CLOUD_URL}/${FUNCTION_NAME}`, {
headers: {
'Metadata-Flavor': 'Google',
}
The audience should look like your HTTP trigger URL. If you decode your JWT ID token, aud looks like this:
{
"aud": "https://[REGION]-[PROJECT_ID].cloudfunctions.net/func-post",
"azp": "117513711437850867551",
"exp": 1614653346,
"iat": 1614649746,
"iss": "https://accounts.google.com",
"sub": "117513711437850867551"
}
I am trying to setup a restricted firebase function that can be called from another client application that runs outside GCP. So far I failed to setup the client application authentication to get passed the restricted access on the firebase funciton.
Here is what I did and tried:
I created and deployed a simple helloWorld firebase function and verified that the function could be called from the client application with the default public access.
I removed allUsers from the helloWorld permissions on GCP and verified that the function could no longer be called from the client application (I get "403 Forbidden" in the response).
I created a new service account and added it as a member of "Cloud functions invoker" in the permissions panel of helloWorld on the GCP.
I created a new private json key file for this service account.
Then I followed the documentation to setup the client application authentication (see code below).
const fetch = require('node-fetch');
const jwt = require('jsonwebtoken');
async function main(){
// get unix timestamp in seconds
const current_time = Math.floor(Date.now() / 1000)
// get the service account key file
const service_account = require('./service_account.json');
// create the jwt body
const token_body = {
"iss": service_account.client_email,
"scope": "https://www.googleapis.com/auth/cloud-platform",
"aud": "https://oauth2.googleapis.com/token",
"exp": current_time + 3600,
"iat": current_time
}
// sign the token with the private key
const signed_token = jwt.sign(
token_body, service_account.private_key, { algorithm: 'RS256' }
)
// get an access token from the authentication server
const access_token = await fetch(
'https://oauth2.googleapis.com/token',
{
method: 'POST',
body: ''
+ 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer'
+ '&'
+ 'assertion=' + signed_token,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
).then(res => res.json()).then(body => body.access_token)
// call the firebase function with the Authorization header
return fetch(
url_hello_world, { headers: { 'Authorization': 'Bearer ' + access_token } }
).then(res => res.text()).then(console.log)
}
main().catch(console.error)
Unfortunately when I run the previous code I get "401 Unauthorize" with the following header:
www-authenticate: Bearer error="invalid_token" error_description="The access token could not be verified"
After that I tried another approach with the following tutorial (see code below).
const fetch = require('node-fetch');
const util = require('util');
const exec = util.promisify(require("child_process").exec)
async function main(){
// activate a service account with a key file
await exec('gcloud auth activate-service-account --key-file=' + key_file)
// retrieve an access token for the activated service account
const {stdout, stderr} = await exec("gcloud auth print-identity-token")
// get the access token from stdout and remove the new line character at the
// end of the string
const access_token = stdout.slice(0,-1)
// call the firebase function with the Authorization header
const response = await fetch(
url_hello_world,
{ headers: { 'Authorization': 'Bearer ' + access_token } }
)
// print the response
console.log(await response.text())
}
main().catch(console.error)
When I run this code, I get the expected response "Hello World" so the previous code can call the firebase function with the service account permission.
However, the client application that I target cannot rely on the gcloud cli and I am stuck to the point where I tried to understand what does not work in the first version above and what I need to change to make it works.
As you're using a Service Account JSON file, the following code can be helpful.
package.json
{
"name": "sample-call",
"version": "0.0.1",
"dependencies": {
"googleapis": "^62.0.0",
"node-fetch": "^2.6.1"
}
}
index.js
var { google } = require('googleapis')
const fetch = require('node-fetch')
const fs = require('fs');
let privatekey = JSON.parse(fs.readFileSync('key.json'));
let url_hello_world = 'CLOUD_FUNCTION_URL';
let jwtClient = new google.auth.JWT(
privatekey.client_email,
null,
privatekey.private_key,
url_hello_world
)
async function main(){
jwtClient.authorize( async function(err, _token) {
if (err) {
console.log(err)
return err
} else {
const response = await fetch(
url_hello_world,
{
headers: { 'Authorization': 'Bearer ' + _token.id_token }
}
)
console.log(await response.text())
}
})
}
main().catch(console.error)
I'm using the oauth-1.0a Node package to implement OAuth with the Zotero API.
I'm using the code found in the zotero-oauth-example repo.
Running the code in the repo mentioned above works. I think my implementation is not working because I've split up the single function in the repo into two functions --> the example repo doesn't handle redirecting the client and handling the callback.
What I think the issue is:
I think that instantiating two different OAuth objects (one is step 1, the other in step 3) is breaking something. Note that I'm temporarily persisting the hash produced in step 1's hash_function which is used in step 3.
I'm not familiar with OAuth. Do you have any suggestions on what I should try?
1) Request Token (server)
// omitting includes packages
const tokenRequestConfig = {
url: 'https://www.zotero.org/oauth/request',
method: 'POST',
data: {
oauth_callback: baseURL, // redirect to baseURL
},
},
initZoteroIntegration = async () => {
let oAuthHash;
const oauth = OAuth({
consumer: {
key: process.env.ZOTERO_APP_CLIENT_KEY,
secret: process.env.ZOTERO_APP_CLIENT_SECRET,
},
signature_method: 'HMAC-SHA1',
hash_function(base_string, key) {
oAuthHash = crypto.createHmac('sha1', key).update(base_string).digest('base64');
return oAuthHash;
},
}),
tokenRequestResponse = await fetch('https://www.zotero.org/oauth/request', {
headers: oauth.toHeader(oauth.authorize(tokenRequestConfig)),
method: 'post',
}),
tokenRequestData = await tokenRequestResponse.text(),
obj = {};
tokenRequestData.replace(/([^=&]+)=([^&]*)/g, (m, key, value) => {
obj[decodeURIComponent(key)] = decodeURIComponent(value);
});
const oAuthToken = obj.oauth_token,
oAuthTokenSecret = obj.oauth_token_secret,
url = `https://www.zotero.org/oauth/authorize?oauth_token=${oAuthToken}&library_access=1¬es_access=1&write_access=1&all_groups=write`;
/* the url should be returned to the client */
return ({
url,
oAuthToken,
oAuthTokenSecret,
oAuthHash,
});
};
2) Client is directed to Zotero app to log in in and authorize permissions.
3) Token Exchange (server)
// omitting includes packages
const confirmIntegration = async ({oAuthToken, oAuthTokenSecret, oAuthVerifier, oAuthHash}) => {
const oauth = OAuth({
consumer: {
key: process.env.ZOTERO_APP_CLIENT_KEY,
secret: process.env.ZOTERO_APP_CLIENT_SECRET,
},
signature_method: 'HMAC-SHA1',
hash_function() {
return oAuthHash;
},
}),
tokenExchangeConfig = {
url: `https://www.zotero.org/oauth/access?oauth_token=${oAuthToken}`,
method: 'POST',
data: {
oauth_verifier: oAuthVerifier,
oauth_callback: baseURL,
},
},
tokenExchangeResponse = await fetch(`https://www.zotero.org/oauth/access?oauth_token=${oAuthToken}`, {
headers: oauth.toHeader(oauth.authorize(tokenExchangeConfig, {
public: oAuthToken,
secret: oAuthTokenSecret,
})),
method: 'post',
}),
tokenExchangeData = await tokenExchangeResponse.text();
try {
const username = tokenExchangeData.match(/username=(\w+)/)[1],
userID = tokenExchangeData.match(/userID=([0-9]+)/)[1],
userAPIKey = tokenExchangeData.match(/oauth_token_secret=([a-zA-Z0-9]+)/)[1];
return {
username,
userID,
userAPIKey,
};
} catch (e) {
// TODO throw some error
return null;
}
};
On the 'Token Exchange' step I'm getting a response with a 401 status code ("Unauthorized").
tokenExchangeResponse.text() is returning oauth_problem=signature_invalid
Here's the raw output:
"https_sig_error=1&z_debug_sbs=POST&https%3A%2F%2Fwww.zotero.org%2Foauth%2Faccess&oauth_consumer_key%3D9a016199db19772cb220%26oauth_nonce%3DDs3iXBVWF4izl1qfX0mk0JXIvZkl7N5o%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1545238813%26oauth_token%3D6f6ade01f30625feeb36%26oauth_verifier%3D02469ed77305b02befd8%26oauth_version%3D1.0&oauth_problem=signature_invalid&debug_sbs=POST&https%3A%2F%2Fwww.zotero.org%2Foauth%2Faccess&oauth_consumer_key%3D9a016199db19772cb220%26oauth_nonce%3DDs3iXBVWF4izl1qfX0mk0JXIvZkl7N5o%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1545238813%26oauth_token%3D6f6ade01f30625feeb36%26oauth_verifier%3D02469ed77305b02befd8%26oauth_version%3D1.0"
*Fix
Ok, I found a solution. I'm using node-cache to cache the OAuth object for a limited time.
Thank you #tnajdek for clarifying that the above usage of OAuth was the issue.
Note that I'm temporarily persisting the hash produced in step 1's
hash_function which is used in step 3.
This will not work.
The hash_function argument to OAuth constructor takes a base_string (which is a serialized summary of your request) and a key to produce its result. Both the base_string and the key will differ between steps 1 and 3, however your hash_function returns a cached results from step 1 as a result for completely different arguments base_string and key in step 3.
I'm not sure how the rest of your app looks like but I'd create OAuth instance just once and re-use it in the initial request and within the callback request inside your server request handling routines. If that's not an option, you can recreate OAuth using the same arguments and it should work fine.