Last active
July 2, 2021 13:38
-
-
Save bpicolo/d339a4fb586090b22b71be917765ae21 to your computer and use it in GitHub Desktop.
Data-race free Auth0 in any Javascript framework
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* I'm not a fan of Auth0's suggested implementation for Javascript presentation frameworks - | |
* they tie in too directly (using Vue lifecycle hooks, React useEffect). | |
* This leads to having to restructure your entire app around your authentication implementation | |
* | |
* React and Vue primarily serve as presentation layers, but your application domain logic often | |
* can and should live outside of your presentation layer. This makes authenticating with Vue lifecycle hooks | |
* or useEffect an antipattern for me, because it leads to either parameter drilling down to your business logic, | |
* or race conditions between your business logic and view lifecycles. | |
* | |
* This abstraction is agnostic of JS framework and guarantees we can manage | |
* the whole auth lifecycle without race conditions | |
* | |
* This is a fairly thin wrapper, which mostly exists for the sake of helping you manage | |
* the fact that `createAuth0Client` is an async function. Having a synchronous constructor | |
* which wraps that makes it easier to manage auth usage across the app | |
*/ | |
import createAuth0Client, { Auth0Client } from "@auth0/auth0-spa-js"; | |
const domain = process.env.VUE_APP_AUTH0_CLIENT_DOMAIN; // or wherever your env vars are located (REACT_APP_...) | |
const clientId = process.env.VUE_APP_AUTH0_CLIENT_ID; | |
const audience = process.env.VUE_APP_AUTH0_AUDIENCE; | |
export class AuthenticatedUser { | |
// first name and last name may be terrible naming choices | |
// depending on your target countries/audience | |
public firstName: string; | |
public lastName: string; | |
public email: string; | |
constructor(email: string, firstName: string, lastName: string) { | |
this.email = email; | |
this.firstName = firstName; | |
this.lastName = lastName; | |
} | |
} | |
class AuthenticationService { | |
private domain: string; | |
private clientId: string; | |
private redirectUri: string; | |
private audience: string; | |
constructor( | |
domain: string, | |
clientId: string, | |
audience: string, | |
redirectUri: string | |
) { | |
this.domain = domain; | |
this.clientId = clientId; | |
this.redirectUri = redirectUri; | |
this.audience = audience; | |
} | |
/** | |
* Guarantee we only instantiate a single, shared client (nice) | |
* that's impossible to access any other way. | |
*/ | |
private getClient = ((): (() => Promise<Auth0Client>) => { | |
let _clientPromise: Promise<Auth0Client> | null = null; | |
return (): Promise<Auth0Client> => { | |
if (!_clientPromise) { | |
_clientPromise = createAuth0Client({ | |
domain: this.domain, | |
client_id: this.clientId, | |
redirect_uri: this.redirectUri, | |
audience: this.audience, | |
}); | |
} | |
return _clientPromise; | |
}; | |
})(); | |
async getUser(): Promise<AuthenticatedUser | null> { | |
const user = await (await this.getClient()).getUser(); | |
return user | |
? new AuthenticatedUser( | |
user.email || "", | |
user.given_name || "", | |
user.family_name || "" | |
) | |
: null; | |
} | |
async isAuthenticated(): Promise<boolean> { | |
const client = await this.getClient(); | |
return client.isAuthenticated(); | |
} | |
async handleRedirectCallbackIfNeeded(): Promise<{ | |
appState?: unknown; | |
} | null> { | |
if ( | |
window.location.search.includes("code=") && | |
window.location.search.includes("state=") | |
) { | |
const client = await this.getClient(); | |
return await client.handleRedirectCallback(); | |
} | |
return null; | |
} | |
async getAccessToken(): Promise<string | null> { | |
const client = await this.getClient(); | |
const token = await client.getTokenSilently(); | |
return Promise.resolve(token); | |
} | |
async login(): Promise<void> { | |
const client = await this.getClient(); | |
return client.loginWithRedirect(); | |
} | |
async logout() { | |
const client = await this.getClient(); | |
return client.logout(); | |
} | |
export default new AuthenticationService( | |
domain, | |
clientId, | |
audience, | |
window.location.origin | |
); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Usage with axios | |
import authenticationService from "@/services/authentication-service"; | |
import axios from "axios"; | |
const instance = axios.create({ | |
baseURL: process.env.VUE_APP_API_HOST, // or REACT_APP_ / wherever you keep your env vars | |
}); | |
instance.interceptors.request.use(async (config) => { | |
const token = await authenticationService.getAccessToken(); | |
if (token) { | |
config.headers["Authorization"] = `Bearer ${token}`; | |
} | |
return config; | |
}); | |
export default instance; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// usage with vue-router | |
import Vue from "vue"; | |
import VueRouter, { RouteConfig } from "vue-router"; | |
import Home from "@/views/Home.vue"; | |
import authenticationService from "@/services/authentication-service"; | |
Vue.use(VueRouter); | |
const routes: Array<RouteConfig> = [ | |
{ | |
path: "/", | |
name: "Home", | |
component: Home, | |
}, | |
{ | |
path: "/app", | |
name: "App", | |
meta: { | |
requiresAuth: true, | |
}, | |
component: () => | |
import(/* webpackChunkName: "about" */ "../views/app/App.vue"), | |
children: [], | |
}, | |
]; | |
const router = new VueRouter({ | |
mode: "history", | |
base: process.env.BASE_URL, | |
routes, | |
}); | |
router.beforeEach(async (to, from, next) => { | |
if (to.matched.some((record) => record.meta.requiresAuth)) { | |
if (await authenticationService.isAuthenticated()) { | |
return next(); | |
} | |
return next({ | |
path: "/login", | |
}); | |
} | |
return next(); | |
}); | |
export default router; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment