npm package: @shane32/msoauth
Description: A React library for Azure AD authentication with PKCE (Proof Key for Code Exchange) flow support. This library provides a secure and easy-to-use solution for implementing Azure AD authentication in React applications, with support for both API and Microso
Author: Shane Krueger
Latest version: 3.0.1

# MSOAuth

A React library for Azure AD authentication with PKCE (Proof Key for Code Exchange) flow support. This library provides a secure and easy-to-use solution for implementing Azure AD authentication in React applications, with support for both API and Microsoft Graph access tokens. While this library can be used with other OAuth-compatible services, it is specifically designed to streamline integration with Azure Active Directory, ensuring developers can efficiently manage authentication flows, token acquisition, and user session management within their React applications.

## Features

- PKCE flow implementation for secure authentication
- Automatic token refresh handling
- Policy-based authorization
- Support for both API and Microsoft Graph access tokens
- React components for conditional rendering based on auth state
- TypeScript support

## Installation

```bash
npm install @shane32/msoauth
```

## Setup

1. Register your application in the Azure Portal and configure the following:
   - Redirect URI (e.g., `https://localhost:12345/oauth/callback`)
   - Logout URI (e.g., `https://localhost:12345/oauth/logout`)
   - Required API permissions
   - Enable implicit grant for access tokens

2. Create an MsAuthManager instance in your `main.tsx` with your Azure AD configuration (recommended for Azure AD as it automatically adds required Microsoft-specific scopes):

```typescript
import { MsAuthManager, Policies } from "@shane32/msoauth";

// Define your policies
export enum Policies {
  Admin = "Admin",
}

// Define policy functions
const policies: Record<keyof typeof Policies, (roles: string[]) => boolean> = {
  [Policies.Admin]: (roles) => roles.indexOf("All.Admin") >= 0,
};

// Initialize MsAuthManager
const authManager = new MsAuthManager({
  clientId: import.meta.env.VITE_AZURE_CLIENT_ID,
  authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID}/v2.0`,
  scopes: import.meta.env.VITE_AZURE_SCOPES,
  redirectUri: "/oauth/callback",
  navigateCallback: (path: string) => {
    // A navigate function that uses the browser's history API
    window.history.replaceState({}, "", path);
    // Dispatch a popstate event to trigger react-router navigation
    window.dispatchEvent(new PopStateEvent("popstate"));
  },
  policies,
  logoutRedirectUri: "/oauth/logout",
});
```

3. Wrap your app with the AuthProvider component and add route handlers for the OAuth callback and logout:

```typescript
root.render(
  <GraphQLContext.Provider value={{ client }}>
    <AuthProvider authManager={authManager}>
      <BrowserRouter>
        <Routes>
          <Route path="/oauth/callback" element={<OAuthCallback />} />
          <Route path="/oauth/logout" element={<OAuthLogout />} />
          <Route path="/*" element={<App />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  </GraphQLContext.Provider>
);

function OAuthCallback() {
  useEffect(() => {
    authManager.handleRedirect();
  }, []);
  return <div>Processing login...</div>;
}

function OAuthLogout() {
  useEffect(() => {
    authManager.handleLogoutRedirect();
  }, []);
  return <div>Processing logout...</div>;
}
```

4. Create a strongly-typed `useAuth` hook for better TypeScript integration:

```typescript
import { useContext } from "react";
import { AuthManager, AuthContext } from "@shane32/msoauth";
import { Policies } from "../main";

function useAuth(): AuthManager<keyof typeof Policies> {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

export default useAuth;
```

5. Configure your APIs to use the access tokens (or id tokens) provided by `AuthManager`:

Below is a sample of usage with the `@shane32/graphql` library:

```typescript
const client = new GraphQLClient({
  url: import.meta.env.VITE_GRAPHQL_URL,
  webSocketUrl: import.meta.env.VITE_GRAPHQL_WEBSOCKET_URL,
  sendDocumentIdAsQuery: true,
  transformRequest: async (config) => {
    try {
      const token = await authManager.getAccessToken();
      return {
        ...config,
        // eslint-disable-next-line @typescript-eslint/naming-convention
        headers: { ...config.headers, Authorization: `Bearer ${token}` },
      };
    } catch {
      return config;
    }
  },
  generatePayload: async () => {
    try {
      const token = await authManager.getAccessToken();
      return {
        // eslint-disable-next-line @typescript-eslint/naming-convention
        Authorization: `Bearer ${token}`,
      };
    } catch {
      return {};
    }
  },
  defaultFetchPolicy: "no-cache",
});

// Listen for auth events to reset GraphQL client store
authManager.addEventListener("login", () => client.resetStore());
authManager.addEventListener("logout", () => client.resetStore());
```

Below is a sample of GraphiQL configuration:

```typescript
// Fetcher function using async/await for token retrieval and request execution
const fetcher = async (graphQLParams: unknown) => {
  const token = await authContext.getAccessToken(); // Fetch the token asynchronously
  const response = await fetch(import.meta.env.VITE_GRAPHQL_URL, {
    method: "post",
    headers: {
      /* eslint-disable */
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      /* eslint-enable */
    },
    body: JSON.stringify(graphQLParams),
  });

  if (response.status >= 200 && response.status < 300) {
    return await response.json(); // Parse JSON response body
  } else {
    throw response; // Throw the response as an error if the status code is not OK
  }
};
```

Below is a sample call to a MS Graph API:

```typescript
const auth = useAuth();
const [users, setUsers] = useState<MSGraphUser[]>([]);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
  const fetchUsers = async () => {
    try {
      // Get MS Graph access token
      const token = await auth.getAccessToken("ms");

      // Fetch users from MS Graph API
      const response = await fetch("https://graph.microsoft.com/v1.0/users", {
        headers: {
          // eslint-disable-next-line @typescript-eslint/naming-convention
          Authorization: `Bearer ${token}`,
          // eslint-disable-next-line @typescript-eslint/naming-convention
          Accept: "application/json",
        },
      });

      if (!response.ok) {
        throw new Error(`Failed to fetch users: ${response.status} ${response.statusText}`);
      }

      const data = await response.json();
      setUsers(data.value);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to fetch users");
      console.error("Error fetching users:", err);
    }
  };

  fetchUsers();
}, [auth]);
```

## Usage

### User Information

```typescript
import useAuth from "../hooks/useAuth";

function UserProfile() {
  const userInfo = useAuth().userInfo;
  const name = userInfo?.given_name ?? userInfo?.name ?? userInfo?.email ?? "Unknown";

  return <div>Welcome, {name}</div>;
}
```

The `userInfo` property returns `null` when not authenticated or the contents of the ID token provided by Azure.

| Property        | Type                  | Description                         |
| --------------- | --------------------- | ----------------------------------- |
| `oid`           | `string`              | Unique identifier for the user      |
| `name`          | `string \| undefined` | Display name of the user            |
| `email`         | `string \| undefined` | Email address of the user           |
| `given_name`    | `string \| undefined` | User's first name                   |
| `family_name`   | `string \| undefined` | User's last name                    |
| `roles`         | `string[]`            | Array of roles assigned to the user |
| `[key: string]` | `unknown`             | Additional custom claims in the JWT |

In order for the user information to be populated correctly, please configure the token within your Azure App Registration to include these claims in the ID token:

| Claim         | Description                                                                               |
| ------------- | ----------------------------------------------------------------------------------------- |
| `email`       | The addressable email for this user, if the user has one                                  |
| `family_name` | Provides the last name, surname, or family name of the user as defined in the user object |
| `given_name`  | Provides the first or "given" name of the user, as set on the user object                 |

Other configured claims will also be provided through the `userInfo` object.

### Conditional Rendering

Use the provided template components to conditionally render content based on authentication state:

```typescript
import { AuthenticatedTemplate, UnauthenticatedTemplate } from "@shane32/msoauth";

function MyComponent() {
  return (
    <>
      <AuthenticatedTemplate>
        <div>This content is only visible when authenticated</div>
      </AuthenticatedTemplate>

      <UnauthenticatedTemplate>
        <div>This content is only visible when not authenticated</div>
      </UnauthenticatedTemplate>
    </>
  );
}
```

### Authentication Actions

```typescript
function LoginButton() {
  const auth = useAuth();

  const handleLogin = () => {
    auth.login();
  };

  const handleLogout = () => {
    auth.logout();
  };

  return auth.isAuthenticated() ? <button onClick={handleLogout}>Logout</button> : <button onClick={handleLogin}>Login</button>;
}
```

The `logout()` method clears local tokens and redirects to the authentication provider's logout endpoint (if configured). If you want to log out without redirecting to the provider (local logout only), use `localLogout()`:

```typescript
// Log out and redirect to provider's logout endpoint
await auth.logout();

// Log out locally without redirecting to provider
auth.localLogout();
```

### Policy-Based Authorization

```typescript
import useAuth from "../hooks/useAuth";
import { Policies } from "../main";

function AdminPanel() {
  const auth = useAuth();

  if (!auth.can(Policies.Admin)) {
    return <div>Access denied</div>;
  }

  return <div>Admin panel content</div>;
}
```

### Access Tokens

```typescript
import useAuth from "../hooks/useAuth";

async function fetchData() {
  const auth = useAuth();

  // Get token for your API
  const apiToken = await auth.getAccessToken();

  // Get token for Microsoft Graph
  const msToken = await auth.getAccessToken("ms");

  // Use tokens in API calls
  const response = await fetch("your-api-endpoint", {
    headers: {
      Authorization: `Bearer ${apiToken}`,
    },
  });
}
```

### Event Handlers

The AuthManager provides an event system that allows you to respond to authentication-related events. When using event handlers in React components, it's important to properly set up and clean up event listeners using the `useEffect` hook:

```typescript
import { useEffect } from "react";
import useAuth from "../hooks/useAuth";

const auth = useAuth();

useEffect(() => {
  // Event handler functions
  const handleLogin = () => {
    console.log("User logged in");
  };

  const handleLogout = () => {
    console.log("User logged out");
  };

  // Add event listeners
  auth.addEventListener("login", handleLogin);
  auth.addEventListener("logout", handleLogout);

  // Cleanup function to remove event listeners
  return () => {
    auth.removeEventListener("login", handleLogin);
    auth.removeEventListener("logout", handleLogout);
  };
}, [auth]); // Include auth in dependencies array
```

| Event Type      | Description                                                                               |
| --------------- | ----------------------------------------------------------------------------------------- |
| `login`         | Emitted when a user successfully logs in                                                  |
| `logout`        | Emitted when a user logs out or is logged out                                             |
| `tokensChanged` | Emitted when access tokens are refreshed or cleared, as user information may have changed |

### Multiple OAuth Providers

This library supports multiple OAuth providers, allowing you to configure and use different identity providers in your application. Use `MultiAuthProvider` to configure all your identity providers, and use the `useAuth` hook to login and handle redirects.

```typescript
// Use MultiAuthProvider instead of AuthProvider
root.render(
  <MultiAuthProvider authManagers={[azureProvider, googleProvider]}>
    <App />
  </MultiAuthProvider>
);

function LoginButtons() {
  const auth = useAuth(); // logged-in manager
  const azureAuth = useAuth("azure"); // azure manager
  const googleAuth = useAuth("google"); // google manager

  if (auth.isAuthenticated()) {
    return <button onClick={() => { auth.logout(); })}>Logout</button>;
  }

  return (
    <div>
      <button onClick={() => { azureAuth.login('/'); }}>Login with Microsoft</button>
      <button onClick={() => { googleAuth.login('/'); }}>Login with Google</button>
    </div>
  );
}

function AzureOAuthCallback() {
  const azureAuth = useAuth("azure");
  useEffect(() => {
    azureAuth.handleRedirect();
  }, [azureAuth]);
  return <div>Processing login...</div>;
}
```

## Configuration Options

| Option              | Type                                           | Required | Description                                                                         |
| ------------------- | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------- |
| `id`                | `string`                                       | No       | Unique identifier for the provider (defaults to "default")                          |
| `clientId`          | `string`                                       | Yes      | Azure AD application client ID                                                      |
| `authority`         | `string`                                       | Yes      | Azure AD authority URL (e.g., `https://login.microsoftonline.com/{tenant-id}/v2.0`) |
| `scopes`            | `string`                                       | Yes      | Space-separated list of required scopes                                             |
| `redirectUri`       | `string`                                       | Yes      | OAuth callback URI (must start with '/')                                            |
| `navigateCallback`  | `(path: string) => void`                       | Yes      | Function to handle navigation after auth callbacks                                  |
| `policies`          | `Record<string, (roles: string[]) => boolean>` | Yes      | Policy functions for authorization                                                  |
| `logoutRedirectUri` | `string`                                       | No       | URI to redirect to after logout (must start with '/')                               |

## Environment Variables

This library does not directly access environment variables, but for the examples above, you'll need to set up the following:

```env
VITE_AZURE_CLIENT_ID=your-client-id
VITE_AZURE_TENANT_ID=your-tenant-id
VITE_AZURE_SCOPES=api://your-api-scope User.Read.All
```

- Use `common` for the tenant ID if your Azure App Registration is configured to allow access from multiple tenants and/or personal accounts.
- Typically the API scope defaults to `api://your-client-id/scope-name` but you can customize this in the Azure App Registration

## Google OAuth Configuration

This library also supports Google OAuth authentication. Since Google requires a client secret for token exchange, which cannot be securely stored in client-side applications, you need to set up a proxy endpoint on your server to handle token requests.

### 1. Register your application in the Google Cloud Console

1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Navigate to "APIs & Services" > "Credentials"
4. Click "Create Credentials" > "OAuth client ID"
5. Select "Web application" as the application type
6. Add your authorized JavaScript origins (e.g., `https://localhost:12345`)
7. Add your authorized redirect URIs (e.g., `https://localhost:12345/oauth/callback`)
8. Note your Client ID and Client Secret

### 2. Create a GoogleAuthManager instance in your `main.tsx`

```typescript
import { GoogleAuthManager, Policies } from "@shane32/msoauth";

// Initialize GoogleAuthManager
const authManager = new GoogleAuthManager({
  clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
  authority: "https://accounts.google.com",
  scopes: "https://www.googleapis.com/auth/userinfo.email", // Add any additional scopes you need
  redirectUri: "/oauth/callback",
  navigateCallback: (path: string) => {
    window.history.replaceState({}, "", path);
    window.dispatchEvent(new PopStateEvent("popstate"));
  },
  policies,
  logoutRedirectUri: "/oauth/logout",
  proxyUrl: import.meta.env.VITE_GOOGLE_PROXY_URL, // URL to your proxy endpoint
});
```

### 3. Set up a proxy endpoint in your ASP.NET Core backend

Create a controller to handle token requests:

```csharp
using Microsoft.AspNetCore.Mvc;
using System.Net.Http;
using System.Threading.Tasks;

[ApiController]
[Route("api/[controller]")]
public class GoogleAuthProxyController : ControllerBase
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IConfiguration _configuration;

    public GoogleAuthProxyController(IHttpClientFactory httpClientFactory, IConfiguration configuration)
    {
        _httpClientFactory = httpClientFactory;
        _configuration = configuration;
    }

    [HttpPost]
    public async Task<IActionResult> ProxyTokenRequest()
    {
        // Read the form data from the request
        var formData = await Request.ReadFormAsync();

        // Create a dictionary from the form data
        var requestDict = formData.ToDictionary(x => x.Key, x => x.Value.ToString());

        // Add the client secret to the request body
        requestDict["client_secret"] = _configuration["Authentication:Google:ClientSecret"];

        // Create a new form collection to send to Google
        var requestContent = new FormUrlEncodedContent(requestDict);

        // Determine the token endpoint based on the grant type
        string tokenEndpoint = "https://oauth2.googleapis.com/token";

        // Create an HTTP client
        var client = _httpClientFactory.CreateClient();

        // Forward the request to Google
        var response = await client.PostAsync(tokenEndpoint, requestContent);

        // Read the response content
        var responseContent = await response.Content.ReadAsStringAsync();

        // Return the response with the same status code
        return new ContentResult
        {
            Content = responseContent,
            ContentType = "application/json",
            StatusCode = (int)response.StatusCode
        };
    }
}
```

### 4. Configure your ASP.NET Core application

Add the following to your `Program.cs` or `Startup.cs`:

```csharp
// Add HTTP client factory
builder.Services.AddHttpClient();

// Configure CORS to allow requests from your frontend
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins("https://localhost:12345") // Your frontend URL
              .AllowAnyMethod()
              .AllowAnyHeader();
    });
});

// In the Configure method or middleware section
app.UseCors("AllowFrontend");
```

### 5. Add the Google client secret to your configuration

In your `appsettings.json` or environment variables:

```json
{
  "Authentication": {
    "Google": {
      "ClientId": "your-client-id",
      "ClientSecret": "your-client-secret"
    }
  }
}
```

### 6. Environment Variables for your frontend

```env
VITE_GOOGLE_CLIENT_ID=your-client-id
VITE_GOOGLE_PROXY_URL=https://your-backend-url/api/GoogleAuthProxy
```