2.0.4 • Published 2 months ago

@shane32/msoauth v2.0.4

Weekly downloads
-
License
Private
Repository
github
Last release
2 months ago

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

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):

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",
});
  1. Wrap your app with the AuthProvider component and add route handlers for the OAuth callback and logout:
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>;
}
  1. Create a strongly-typed useAuth hook for better TypeScript integration:
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;
  1. Configure your APIs to use the access tokens provided by AuthManager:

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

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:

// 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:

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

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.

PropertyTypeDescription
oidstringUnique identifier for the user
namestring \| undefinedDisplay name of the user
emailstring \| undefinedEmail address of the user
given_namestring \| undefinedUser's first name
family_namestring \| undefinedUser's last name
rolesstring[]Array of roles assigned to the user
[key: string]unknownAdditional 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:

ClaimDescription
emailThe addressable email for this user, if the user has one
family_nameProvides the last name, surname, or family name of the user as defined in the user object
given_nameProvides 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:

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

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>;
}

Policy-Based Authorization

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

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:

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 TypeDescription
loginEmitted when a user successfully logs in
logoutEmitted when a user logs out or is logged out
tokensChangedEmitted 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.

// 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

OptionTypeRequiredDescription
idstringNoUnique identifier for the provider (defaults to "default")
clientIdstringYesAzure AD application client ID
authoritystringYesAzure AD authority URL (e.g., https://login.microsoftonline.com/{tenant-id}/v2.0)
scopesstringYesSpace-separated list of required scopes
redirectUristringYesOAuth callback URI (must start with '/')
navigateCallback(path: string) => voidYesFunction to handle navigation after auth callbacks
policiesRecord<string, (roles: string[]) => boolean>YesPolicy functions for authorization
logoutRedirectUristringNoURI 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:

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
  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

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:

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:

// 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:

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

6. Environment Variables for your frontend

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

2 months ago

2.0.3

2 months ago

2.0.2

2 months ago

2.0.1

2 months ago

2.0.0

2 months ago

1.0.2

7 months ago

1.0.1

7 months ago

1.0.0

7 months ago