Authentication - liferesearchapp/life-research-members-portal GitHub Wiki

Overview

This document relates to the files auth-config.ts, src/utils/api/get-account-from-request.ts, and src/services/headers/get-access-token.ts.

This application supports signing in using a Microsoft Account only. This means users need to Register a Microsoft Account if they do not have one.

To register a new user, the primary email of the microsoft account must be added to the database.

The primary email can be found in the user's Microsoft Profile.

MS Profile

However, users may use other email addresses / methods to sign in, such as Gmail or Github, by Linking Their Microsoft Account.

If the user signs in and their account is not registered, an error notification will appear. This notification contains their primary email which can be used for troubleshooting.

Not Registered

Design

The authentication protocol used is OAuth 2.0.

The authorization flow is as follows:

Login

  1. The user indicates they want to login (clicking on the login button).
  2. The application redirects to a Microsoft authentication server and the user enters their credentials.
  3. If successful, the browser redirects back to the application and saves some tokens (access token, refresh token, ID token) to the browser's storage (session storage or local storage depending on configuration).
  4. The application requests the user's data from the backend server by making an HTTP request with the access token as a header.
  5. The backend server requests the user's identity from the Microsoft Graph API via another HTTP request with the access token as a header.
  6. The API validates the token and returns the user's email and Microsoft ID.
  7. The backend receives the user's identity and fetches the user's data from the application's database.
  8. If the user was found in the database, the backend server returns their data to the frontend.

Protected Endpoints

  1. The signed in user makes an HTTP request to a protected endpoint (for example navigating to a private profile).
  2. The application makes the HTTP request with the access token as a header.
  3. The backend server requests the user's identity from the Microsoft Graph API via another HTTP request with the access token as a header.
  4. The API validates the token and returns the user's email and Microsoft ID.
  5. The backend receives the user's identity and fetches the user's data from the application's database, which includes their permissions.
  6. The backend checks the user's permissions to verify they have access to this data / functionality (ex. are they an admin or does the private data belong to their account?).
  7. If permissions are verified, the backend performs the necessary functions and / or returns some data (ex. get the private data from the database and return it to the frontend).

System Diagram

System Diagram

Pros

  • Easy to implement
  • Defers work to Microsoft servers
  • Application never sees passwords

Cons

  • Increased response time due to additional HTTP requests
  • Microsoft account is required
  • Reliance on Microsoft servers

Database

The database schema contains a single table related to authentication: account.

It has the following structure:

[id] [int] NOT NULL, /* Primary Key, Auto Incrementing */
[login_email] [nvarchar](255) NOT NULL, /* Unique Constraint */
[microsoft_id] [nvarchar](255) NULL,
[first_name] [nvarchar](255) NOT NULL,
[last_name] [nvarchar](255) NOT NULL,
[is_admin] [bit] NOT NULL, /* Default 0 */
[last_login] [date] NULL,

Adding a first_name, last_name, and login_email to this table is all that's needed to register a new account.

Azure Tenant

The application uses an Azure Active Directory Tenant to allow users to sign in using a Microsoft account.

To manage the tenant, or create your own, log in to the Azure Portal and select Azure Active Directory.

The application must be registered under App Registrations. In this case it should be registered as a Single Page Application (SPA).

All options can be left as default except for:

Redirect URIs:

  • http://localhost:3000 (for local development)

  • <The production URL> ex. https://life-research.herokuapp.com/ (for production)

These denote what URLs are allowed to be redirected from the MS authentication server. This is to prevent malicious users from hijacking access tokens.

Supported account types:

  • Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts

This allows emails from other tenants to sign in (ex. uottawa.ca, outlook.com).

After registering, note the Application (client) ID of the registered application. It is added to the frontend configuration to allow users to sign in.

Frontend

The frontend uses Microsoft Authentication Library (MSAL) for React to handle signing in users and managing tokens. Click the link to see the Microsoft documentation.

The npm package name is @azure/msal-react. Click the link to see it's documentation.

The configuration file for MSAL is located at auth-config.ts. This is where the client ID from the Azure Tenant is entered.

The currently signed in user, as well as the login and logout functions are held in a React Context at src/services/context/active-account-ctx.tsx

The active account context returns the following object:

{
  localAccount: AccountInfo | null; // User's information
  loading: boolean;  // Indicates initial loading
  refresh: () => void; // To retrieve the user's info again
  refreshing: boolean; // Indicates refreshing
  login: () => void; // Redirect to login server
  logout: () => void; // Logout and clear cache
  setLocalAccount: Dispatch<SetStateAction<AccountInfo | null>>; // Overwrites user's info - for updating using return value of a database call
}

To get the context within any React component (destructuring is used to get specific properties):

const { localAccount, loading, login } = useContext(ActiveAccountCtx);

This context will fetch the user's information from the database when the application first loads, provided an access token exists in storage. Otherwise localAccount will remain null and loading will be set to false. Redirecting to & from the authentication server reloads the application, triggering this mechanism.

MSAL also returns some identity information along with the access token in the form of an identity token. However, this information cannot be trusted as the client could easily modify it. Only the access token can be trusted as it is signed by a private key.

When making protected HTTP requests from the frontend, an authentication header containing the access token needs to be appended (truncated for brevity):

/src/services/use-private-member-info.ts

async function fetchMember(id: number) {
  const authHeader = await getAuthHeader();
  if (!authHeader) return;
  const res = await fetch(ApiRoutes.privateMemberInfo(id), {
    headers: authHeader,
  });
  setMember(await res.json());
}

/src/services/headers/auth-header.ts

export default async function getAuthHeader() {
  const accessToken = await getAccessToken();
  return accessToken ? { authorization: "Bearer " + accessToken } : null;
}

Backend

The utility function getAccountFromRequest(req, res) is used by the backend to:

  1. Extract the access token from the request header.
  2. Get the user's email and Microsoft ID from the Microsoft Graph API.
  3. Get the user's data / permissions from the database and return it.

The function will also send the appropriate HTTP response if an error is encountered along the way, and will then return null.

Here's an example of a protected backend endpoint, retrieving private member data (truncated for brevity):

/src/pages/api/member/[id]/private.ts

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<PrivateMemberDBRes | string>
) {
  const id = parseInt(req.query.id);

  const currentAccount = await getAccountFromRequest(req, res);
  if (!currentAccount) return; // getAccountFromRequest will have already sent the response

  // Must be an admin or own the member account
  const authorized =
    currentAccount.is_admin ||
    (currentAccount.member && currentAccount.member.id === id);

  // Not Authorized 😡 Respond with 401, don't forget to return!
  if (!authorized)
    return res
      .status(401)
      .send(
        "You are not authorized to view this member's private information."
      );

  // Authorized 😄 Get data from database
  const member = await getPrivateMemberInfo(id);
  return res.status(200).send(member);
}

getAccountFromRequest will return the first user in the database that matches the ID OR email returned by the MS Graph API. This is because of two possible cases:

  1. The admin has just registered this account, so the ID is not present in the database.
  2. The user has changed their primary email address, so the ID will match but the email will not.

On matching either an email OR an id, the function checks to ensure that BOTH the ID and email match. If one is mismatched, it will be updated in the database.