diff --git a/packages/api/src/auth/decoders/azureActiveDirectory.ts b/packages/api/src/auth/decoders/azureActiveDirectory.ts new file mode 100644 index 000000000000..cb2a4b313437 --- /dev/null +++ b/packages/api/src/auth/decoders/azureActiveDirectory.ts @@ -0,0 +1,49 @@ +import jwt from 'jsonwebtoken' +import jwksClient from 'jwks-rsa' + +const verifyAzureActiveDirectoryToken = ( + bearerToken: string +): Promise> => { + return new Promise((resolve, reject) => { + const { AZURE_ACTIVE_DIRECTORY_AUTHORITY } = process.env + if (!AZURE_ACTIVE_DIRECTORY_AUTHORITY) { + throw new Error( + '`AZURE_ACTIVE_DIRECTORY_AUTHORITY` env var is not set.' + ) + } + + /** @docs: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#sample-response */ + const client = jwksClient({ + jwksUri: `${AZURE_ACTIVE_DIRECTORY_AUTHORITY}/discovery/v2.0/keys`, + }) + + jwt.verify( + bearerToken, + (header, callback) => { + client.getSigningKey(header.kid as string, (error, key) => { + callback(error, key.getPublicKey()) + }) + }, + { + issuer: `${AZURE_ACTIVE_DIRECTORY_AUTHORITY}/v2.0`, + algorithms: ['RS256'], + }, + (verifyError, decoded) => { + if (verifyError) { + return reject(verifyError) + } + resolve( + typeof decoded === 'undefined' + ? null + : (decoded as Record) + ) + } + ) + }) +} + +export const azureActiveDirectory = async ( + token: string +): Promise> => { + return verifyAzureActiveDirectoryToken(token) +} diff --git a/packages/api/src/auth/decoders/index.ts b/packages/api/src/auth/decoders/index.ts index c11935fe2c2b..97324b13f52e 100644 --- a/packages/api/src/auth/decoders/index.ts +++ b/packages/api/src/auth/decoders/index.ts @@ -5,6 +5,7 @@ import type { SupportedAuthTypes } from '@redwoodjs/auth' import type { GlobalContext } from 'src/globalContext' import { auth0 } from './auth0' +import { azureActiveDirectory } from './azureActiveDirectory' import { netlify } from './netlify' import { supabase } from './supabase' const noop = (token: string) => token @@ -22,6 +23,7 @@ const typesToDecoders: Record< | ((token: string, req: Req) => Decoded | Promise) > = { auth0: auth0, + azureActiveDirectory: azureActiveDirectory, netlify: netlify, goTrue: netlify, magicLink: noop, diff --git a/packages/auth/README.md b/packages/auth/README.md index fc25bbc08fc2..59ee7142c16c 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -52,5 +52,13 @@ If you want to use the useAuth hook `Sign Up` with Auth0 to default the UI to th ```ts // authClients/index.ts -export type SupportedAuthClients = Auth0 | GoTrue | NetlifyIdentity | MagicLink +export type SupportedAuthClients = + | Auth0 + | AzureActiveDirectory + | GoTrue + | NetlifyIdentity + | MagicLink + | Firebase + | Supabase + | Custom ``` diff --git a/packages/auth/package.json b/packages/auth/package.json index b7cbf50182ad..48c4b3f5704c 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -16,6 +16,7 @@ "firebase-admin": "^9.1.1", "gotrue-js": "git://github.com/netlify/gotrue-js.git#28df09cfcac505feadcb1719714d2a9507cf68eb", "magic-sdk": "^2.5.0", + "msal": "^1.4.1", "netlify-identity-widget": "1.9.1", "react": "^16.13.1" }, diff --git a/packages/auth/src/authClients/azureActiveDirectory.ts b/packages/auth/src/authClients/azureActiveDirectory.ts new file mode 100644 index 000000000000..8d643f56d631 --- /dev/null +++ b/packages/auth/src/authClients/azureActiveDirectory.ts @@ -0,0 +1,19 @@ +import { UserAgentApplication as AzureActiveDirectory } from 'msal' + +export type { AzureActiveDirectory } +import type { AuthClient } from './' + +export type AzureActiveDirectoryClient = AzureActiveDirectory +export interface AzureActiveDirectoryUser {} + +export const azureActiveDirectory = (client: AzureActiveDirectoryClient): AuthClient => { + return { + type: 'azureActiveDirectory', + client, + login: async (options?) => await client.loginPopup(options), + logout: (options?) => client.logout(options), + signup: async (options?) => await client.loginPopup(options), + getToken: async () => sessionStorage.getItem('msal.idtoken'), + getUserMetadata: async () => (await client.getAccount()) || null, + } +} diff --git a/packages/auth/src/authClients/index.ts b/packages/auth/src/authClients/index.ts index ba32277ddaff..b8e7ba6bde45 100644 --- a/packages/auth/src/authClients/index.ts +++ b/packages/auth/src/authClients/index.ts @@ -1,5 +1,7 @@ import type { Auth0, Auth0User } from './auth0' import { auth0 } from './auth0' +import type { AzureActiveDirectory, AzureActiveDirectoryUser } from './azureActiveDirectory' +import { azureActiveDirectory } from './azureActiveDirectory' import type { Custom } from './custom' import { custom } from './custom' import type { Firebase } from './firebase' @@ -11,12 +13,12 @@ import { magicLink } from './magicLink' import type { NetlifyIdentity } from './netlify' import { netlify } from './netlify' import type { Supabase, SupabaseUser } from './supabase' -// import { supabase } from './supabase' const typesToClients = { netlify, auth0, + azureActiveDirectory, goTrue, magicLink, firebase, @@ -27,6 +29,7 @@ const typesToClients = { export type SupportedAuthClients = | Auth0 + | AzureActiveDirectory | GoTrue | NetlifyIdentity | MagicLink @@ -37,11 +40,13 @@ export type SupportedAuthClients = export type SupportedAuthTypes = keyof typeof typesToClients export type { Auth0User } +export type { AzureActiveDirectoryUser } export type { GoTrueUser } export type { MagicUser } export type { SupabaseUser } export type SupportedUserMetadata = | Auth0User + | AzureActiveDirectoryUser | GoTrueUser | MagicUser | SupabaseUser diff --git a/packages/cli/src/commands/generate/auth/providers/azureActiveDirectory.js b/packages/cli/src/commands/generate/auth/providers/azureActiveDirectory.js new file mode 100644 index 000000000000..381ecb07aa42 --- /dev/null +++ b/packages/cli/src/commands/generate/auth/providers/azureActiveDirectory.js @@ -0,0 +1,31 @@ +// the lines that need to be added to index.js +export const config = { + imports: [`import { UserAgentApplication } from 'msal'`], + init: `const azureActiveDirectoryClient = new UserAgentApplication({ + auth: { + clientId: process.env.AZURE_ACTIVE_DIRECTORY_CLIENT_ID, + authority: process.env.AZURE_ACTIVE_DIRECTORY_AUTHORITY, + redirectUri: process.env.AZURE_ACTIVE_DIRECTORY_REDIRECT_URI, + postLogoutRedirectUri: process.env.AZURE_ACTIVE_DIRECTORY_LOGOUT_REDIRECT_URI, + }, + })`, + authProvider: { + client: 'azureActiveDirectoryClient', + type: 'azureActiveDirectory', + }, +} + +// required packages to install +export const webPackages = ['msal'] +export const apiPackages = [] + +// any notes to print out when the job is done +export const notes = [ + 'You will need to create several environment variables with your Azure AD config options. Check out web/src/index.js for the variables you need to add.', + '\n', + 'RedwoodJS specific Documentation:', + 'https://redwoodjs.com/docs/authentication#azure-ad', + '\n', + 'MSAL.js Documentation:', + 'https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-initializing-client-applications' +] diff --git a/packages/cli/src/commands/generate/auth/templates/azureActiveDirectory.auth.js.template b/packages/cli/src/commands/generate/auth/templates/azureActiveDirectory.auth.js.template new file mode 100644 index 000000000000..ef7ced71b8f5 --- /dev/null +++ b/packages/cli/src/commands/generate/auth/templates/azureActiveDirectory.auth.js.template @@ -0,0 +1,61 @@ +// Define what you want `currentUser` to return throughout your app. For example, +// to return a real user from your database, you could do something like: +// +// export const getCurrentUser = async ({ email }) => { +// return await db.user.findOne({ where: { email } }) +// } + +import { AuthenticationError, ForbiddenError, parseJWT } from '@redwoodjs/api' + +export const getCurrentUser = async (decoded, { token, type }) => { + return { + email: decoded.preferred_username ?? null, + ...decoded, + roles: parseJWT({ decoded }).roles + } +} + +// Use this function in your services to check that a user is logged in, and +// optionally raise an error if they're not. + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param {string=} roles - An optional role or list of roles + * @param {string[]=} roles - An optional list of roles + + * @example + * + * // checks if currentUser is authenticated + * requireAuth() + * + * @example + * + * // checks if currentUser is authenticated and assigned one of the given roles + * requireAuth({ role: 'admin' }) + * requireAuth({ role: ['editor', 'author'] }) + * requireAuth({ role: ['publisher'] }) + */ +export const requireAuth = ({ role } = {}) => { + if (!context.currentUser) { + throw new AuthenticationError("You don't have permission to do that.") + } + + if ( + typeof role !== 'undefined' && + typeof role === 'string' && + !context.currentUser.roles?.includes(role) + ) { + throw new ForbiddenError("You don't have access to do that.") + } + + if ( + typeof role !== 'undefined' && + Array.isArray(role) && + !context.currentUser.roles?.some((r) => role.includes(r)) + ) { + throw new ForbiddenError("You don't have access to do that.") + } +}