Implementing Auth0 Authentication into a Chrome Extension
25 Sep 2021, 20:08:00Implementing Auth0 Authentication into a Chrome Extension
Updated on 2021-10-25: I have edited this to reflect a better security implementation that doesn't expose the Client Secret in the source code. Originally this wasn't a problem for my project, as it is strictly internal, but I thought it would be better to do so anyway.
For a project that involved building a Chrome Extension, I needed to implement a way of authenticating using Auth0 in order to access some APIs - a process I thought would be simple, it must have been solved officially already right? Well... sort of, but no.
There was once a community-built / Auth0-somewhat-endorsed project for integrating Auth0 into a Chrome Extension, but for whatever reason was abandoned and ultimately even deleted from GitHub (although it's still on npmjs.com). So, I figured out a way of getting it working without it.
Unfortunately, the Auth0 SPA SDK is also a no-go as it's loginWithPopup()
method would not work, either from the Extension Options page or the Browser Action Popup (semi-related GitHub issue).
What follows is a rough write-up with snippets of code that I hope can at least directly solve problems, or lead to you figuring out better solutions.
Getting a unique Extension ID
You'll need to get a unique Extension ID for your extension as it's needed for use in some URLs that Auth0 will use. This Stack Overflow answer helped a lot, and for me since the Chrome Extension I'm creating is purely for internal-use I did not need to worry about the Chrome Web Store.
The steps I used are:
- Go to
chrome://extensions/
and enable Developer mode. - Click on "Pack extension...", select your app/extension's directory and confirm.
Now you've got a .crx
file and a .pem
file. Back-up .pem
file. - Visit this online tool and select the
.crx
file you've just created (again: just open the console to see the "key" and extension ID).
Auth0 Application Setup
Next, head to Auth0 to create a Single Page Application and use the values below (replace "EXTENSION_ID" with your ID from above):
- Allowed Callback URLs:
https://EXTENSION_ID.chromiumapp.org/auth0
- Allowed Web Origins:
chrome-extension://EXTENSION_ID
- Allowed Origins (CORS):
chrome-extension://EXTENSION_ID
I'm also using Refresh Token Rotation with an Absolute Lifetime of 2592000.
Chrome Extension
My Chrome Extension is built using VueJS 2 and Vuex (with a Vuex plugin that persists certain modules as JSON with the Chrome storage.sync
API), but you could surely use the same theory and logical implementation with any library and get the same results.
Your Manifest File must have the "identity" permission added to it.
From the Auth0 SPA Application I added the Client ID as an Environment Variables I can reference in my project.
Within my Vue Component that powers the Extension Options page, I added a Login button that runs the following JavaScript.
async onLoginBtnClick() {
const redirectUrl = chrome.identity.getRedirectURL('auth0');
function base64URLEncode(str) {
return str.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest();
}
const codeVerifier = base64URLEncode(crypto.randomBytes(32));
const codeChallenge = base64URLEncode(sha256(codeVerifier));
this.$store.commit('settings/SET_CODE_VERIFIER', codeVerifier);
const options = {
client_id: process.env.VUE_APP_AUTH0_CLIENT_ID,
redirect_uri: redirectUrl,
response_type: 'code',
scope: 'offline_access openid profile email',
audience: process.env.VUE_APP_AUTH0_AUDIENCE,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}
const url = `https://${process.env.VUE_APP_AUTH0_DOMAIN}/authorize?${stringify(options)}`;
const resultUrl = await new Promise((resolve, reject) => {
chrome.identity.launchWebAuthFlow({
url,
interactive: true
}, callbackUrl => {
resolve(callbackUrl);
});
});
if (resultUrl) {
const code = parse(resultUrl.split('?')[1]).code;
const body = JSON.stringify({
redirect_uri: redirectUrl,
grant_type: 'authorization_code',
client_id: process.env.VUE_APP_AUTH0_CLIENT_ID,
code_verifier: codeVerifier,
code,
scope: 'offline_access openid profile email'
})
const result = await axios.post(`https://${process.env.VUE_APP_AUTH0_DOMAIN}/oauth/token`, body, {
headers: {
'Content-Type': 'application/json'
}
})
if (
result &&
result.data &&
result.data.access_token &&
result.data.expires_in &&
result.data.refresh_token
) {
this.$store.commit('settings/SET_ACCESS_TOKEN', result.data.access_token);
this.$store.commit('settings/SET_ACCESS_TOKEN_EXPIRES_AT', dayjs().add(result.data.expires_in, 'seconds').unix());
this.$store.commit('settings/SET_REFRESH_TOKEN', result.data.refresh_token);
this.$store.dispatch('ui/fetchCurrentUser');
} else {
this.appNotify({
type: 'error',
title: 'Error',
text: 'Auth0 Authentication Data was invalid'
});
}
} else {
this.appNotify({
type: 'error',
title: 'Error',
text: 'Auth0 Cancelled or error'
});
}
},
Notes:
I also have a Logout button that runs the following JavaScript.
onLogoutBtnClick() {
chrome.identity.clearAllCachedAuthTokens(() => {
this.$store.commit('settings/SET_ACCESS_TOKEN', null);
this.$store.commit('settings/SET_REFRESH_TOKEN', null);
this.$store.commit('ui/SET_CURRENT_USER', null);
});
}
Within my UI Vuex Module I have an action that calls a function within my Background Script to fetch User Info from Auth 0, which takes the form of the below JavaScript.
In my Background Script I create some Axios instances and attach request & response interceptors for them, so I can inject Authorization headers or if the token needs refreshing.
I'm also using a Vuex store re-hydration method to make sure I load in the latest changes from the Chrome storage.sync
API.
// Vuex Action
fetchCurrentUserAction({commit}, payload) {
commit('SET_CURRENT_USER_LOADING', true);
let apiResponse = browser.runtime.sendMessage({
data: {
message: 'auth0-get-user-info-request'
}
});
apiResponse.then(response => {
commit('SET_CURRENT_USER_LOADING', false);
commit('SET_CURRENT_USER', response.data ?? null);
}, error => {
commit('SET_CURRENT_USER_LOADING', false);
});
return apiResponse;
}
// Background Script
const myExtension = {
refreshAuth0AccessToken() {},
auth0Axios: axios.create({
baseURL: `https://${process.env.VUE_APP_AUTH0_DOMAIN}/`,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}),
apiAxios: axios.create({
baseURL: process.env.VUE_APP_API_URL,
withCredentials: true,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}),
};
store.restored.then(() => {
/**
* Auth0 Axios Instance
*/
myExtension.auth0Axios.interceptors.request.use(async config => {
await rehydrateStore();
if (store.getters['settings/accessToken'] && store.getters['settings/refreshToken']) {
config.headers.Authorization = `Bearer ${store.getters['settings/accessToken']}`;
}
return config;
}, error => {
console.error('Auth0 Axios Request Interceptor Error: ', error?.message ?? 'Unknown Error');
throw error;
});
myExtension.refreshAuth0AccessToken = () => {
if (store.getters['settings/refreshToken']) {
return myExtension.auth0Axios.post('oauth/token', {
grant_type: 'refresh_token',
client_id: process.env.VUE_APP_AUTH0_CLIENT_ID,
refresh_token: store.getters['settings/refreshToken']
})
.then(response => {
if (
response &&
response.data &&
response.data.access_token &&
response.data.expires_in &&
response.data.refresh_token
) {
store.commit('settings/SET_ACCESS_TOKEN', response.data.access_token);
store.commit('settings/SET_ACCESS_TOKEN_EXPIRES_AT', dayjs().add(response.data.expires_in, 'seconds').unix());
store.commit('settings/SET_REFRESH_TOKEN', response.data.refresh_token);
} else {
throw 'Auth0 Authentication Data was invalid';
}
})
.catch(error => {
console.error(error);
})
}
}
/**
* API Axios Instance
*/
myExtension.apiAxios.interceptors.request.use(async config => {
await rehydrateStore();
if (store.getters['settings/accessToken'] && store.getters['settings/refreshToken']) {
config.headers.Authorization = `Bearer ${store.getters['settings/accessToken']}`;
}
return config;
}, error => {
console.error('Axios Request Interceptor Error: ', error?.message ?? 'Unknown Error');
throw error;
});
myExtension.apiAxios.interceptors.response.use(async response => {
await rehydrateStore();
if (
store.getters['settings/accessToken'] &&
store.getters['settings/accessTokenExpiresAt'] &&
store.getters['settings/refreshToken']
) {
// If the token is within 7 days of expiring, refresh it
let accessTokenExpiresAt = dayjs.unix(store.getters['settings/accessTokenExpiresAt']);
let accessTokenExpiresAtCutoff = dayjs.unix(store.getters['settings/accessTokenExpiresAt']).subtract(7, 'days');
if (dayjs().isBetween(accessTokenExpiresAt, accessTokenExpiresAtCutoff, 'hour')) {
myExtension.refreshAccessToken();
}
}
return response;
}, error => {
if (! error.response) {
console.error('Axios Response Interceptor Error: Unknown Error', error);
throw error;
}
console.error('Axios Response Interceptor Error: ', error.response.data?.message ?? 'Unknown Error', error.response);
throw error.response.data ?? error;
});
/**
* Listen for messages
*/
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
switch (request.data.message) {
/**
* Auth0 Functions
*/
case 'auth0-get-user-info-request':
return myExtension.auth0Axios.get('userinfo');
case 'auth0-post-refresh-access-token-request':
return myExtension.refreshAccessToken();
/**
* API Functions
*/
// Example API Call
case 'api-get-something-request':
return myExtension.apiAxios({
method: 'GET',
url: `something/${request.data.data.id}/blah`
});
}
});
});
// Vuex Hydration
import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'
import deepmerge from 'deepmerge'
import settings from './modules/settings'
import ui from './modules/ui'
Vue.use(Vuex);
const isProd = process.env.NODE_ENV === 'production';
const vuexBrowserStorage = new VuexPersistence({
restoreState: (key) => {
var getState = browser.storage.sync.get(key);
return getState.then(state => { return state.vuex });
},
saveState: (key, state, storage) => browser.storage.sync.set({ [key]: state }),
modules: ['settings'],
asyncStorage: true
});
const store = new Vuex.Store({
modules: {
settings,
ui
},
plugins: [vuexBrowserStorage.plugin],
strict: false
});
const rehydrateStore = async () => {
const savedState = await vuexBrowserStorage.restoreState(vuexBrowserStorage.key, vuexBrowserStorage.storage);
store.replaceState(deepmerge(store.state, savedState || {}, {
arrayMerge: (destinationArray, sourceArray, options) => sourceArray
}))
}
export {
store,
rehydrateStore
};
I have also used the Chrome Alarms API to create a scheduled task that checks the expiration of the Access Token, and refreshes it if necessary.
I hope this information and these snippets of code can be useful to someone implementing Auth0 within a Chrome Extension. To get to this point, it took quite a few hours, so hopefully this saves you some time!