@shane32/msoauth
Version:
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
584 lines (462 loc) • 20.1 kB
Markdown
# 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 /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 `/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
```