From 0d15e9429ee3c1cda4fc21f605397c598b94fa0d Mon Sep 17 00:00:00 2001 From: Behnam Ousat Date: Mon, 27 Jul 2026 16:00:59 -0700 Subject: [PATCH 1/6] Switch GUI authentication to Microsoft Graph tokens --- frontend/src/auth/AuthProvider.test.tsx | 4 - frontend/src/auth/AuthProvider.tsx | 5 +- frontend/src/auth/msalConfig.test.ts | 26 +- frontend/src/auth/msalConfig.ts | 27 +- .../src/components/UserAccountButton.test.tsx | 4 +- frontend/src/components/UserAccountButton.tsx | 3 +- frontend/src/services/api.ts | 11 +- infra/DEPLOY_NEW_INSTANCE.md | 4 + infra/README.md | 75 ++-- infra/deploy_instance.py | 92 +--- infra/main.bicep | 16 +- pyrit/backend/main.py | 4 +- pyrit/backend/middleware/auth.py | 398 ++++++++++-------- tests/unit/backend/test_auth_middleware.py | 335 +++++++++++---- 14 files changed, 598 insertions(+), 406 deletions(-) diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx index e96e138087..add9f91fc0 100644 --- a/frontend/src/auth/AuthProvider.test.tsx +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -26,11 +26,8 @@ jest.mock("./msalConfig", () => ({ })); const mockSetMsalInstance = jest.fn(); -const mockSetClientId = jest.fn(); - jest.mock("../services/api", () => ({ setMsalInstance: (...args: unknown[]) => mockSetMsalInstance(...args), - setClientId: (...args: unknown[]) => mockSetClientId(...args), })); // MSAL browser mocks — PublicClientApplication instance methods @@ -200,7 +197,6 @@ describe("AuthProvider", () => { expect(mockInitialize).toHaveBeenCalled(); expect(mockHandleRedirectPromise).toHaveBeenCalled(); expect(mockSetMsalInstance).toHaveBeenCalled(); - expect(mockSetClientId).toHaveBeenCalledWith("test-client"); }); // Test 16: handleRedirectPromise returns account → setActiveAccount called diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index d619d03a48..38ffebdff1 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -24,7 +24,7 @@ import { } from '@azure/msal-react' import { fetchAuthConfig, buildMsalConfig, buildLoginRequest, type AuthConfig } from './msalConfig' import { AuthConfigContext, useAuthConfig } from './AuthConfigContext' -import { setMsalInstance as setApiMsalInstance, setClientId as setApiClientId } from '../services/api' +import { setMsalInstance as setApiMsalInstance } from '../services/api' function LoginRedirect() { const { instance } = useMsal() @@ -35,7 +35,7 @@ function LoginRedirect() { // after the login round-trip (see the redirect handling in initMsal). instance .loginRedirect({ - ...buildLoginRequest(config.clientId), + ...buildLoginRequest(), state: window.location.pathname + window.location.search, }) .catch((error) => { @@ -129,7 +129,6 @@ export function AuthProvider({ children }: AuthProviderProps) { // Wire MSAL into the API client BEFORE React re-render, // so child components' effects already have the token available. setApiMsalInstance(instance) - setApiClientId(config.clientId) setMsalInstance(instance) setAuthConfig(config) } diff --git a/frontend/src/auth/msalConfig.test.ts b/frontend/src/auth/msalConfig.test.ts index 2e36a518f8..bda21bf5fa 100644 --- a/frontend/src/auth/msalConfig.test.ts +++ b/frontend/src/auth/msalConfig.test.ts @@ -4,31 +4,19 @@ jest.mock("@azure/msal-browser", () => ({ LogLevel: { Warning: 3 }, })); -import { buildMsalConfig, getApiScopes, buildLoginRequest } from "./msalConfig"; +import { buildMsalConfig, getGraphScopes, buildLoginRequest } from "./msalConfig"; describe("msalConfig", () => { - // Tests 1-2: getApiScopes — two branches on the !clientId check (line 75) - describe("getApiScopes", () => { - it("returns default scopes when clientId is empty", () => { - expect(getApiScopes("")).toEqual(["openid", "profile", "email"]); - }); - - it("returns client-specific scope when clientId is provided", () => { - expect(getApiScopes("my-client-id")).toEqual(["api://my-client-id/access"]); + describe("getGraphScopes", () => { + it("returns the delegated Graph scope", () => { + expect(getGraphScopes()).toEqual(["User.Read"]); }); }); - // Tests 3-4: buildLoginRequest — wraps getApiScopes in { scopes } describe("buildLoginRequest", () => { - it("builds request with client-specific scopes", () => { - expect(buildLoginRequest("my-client-id")).toEqual({ - scopes: ["api://my-client-id/access"], - }); - }); - - it("builds request with default scopes when clientId is empty", () => { - expect(buildLoginRequest("")).toEqual({ - scopes: ["openid", "profile", "email"], + it("builds request with Graph scopes", () => { + expect(buildLoginRequest()).toEqual({ + scopes: ["User.Read"], }); }); }); diff --git a/frontend/src/auth/msalConfig.ts b/frontend/src/auth/msalConfig.ts index fa7d586bce..c63e3a68cf 100644 --- a/frontend/src/auth/msalConfig.ts +++ b/frontend/src/auth/msalConfig.ts @@ -8,12 +8,15 @@ * endpoint (served by the backend from environment variables). This avoids * hardcoding tenant-specific values in the frontend bundle. * - * Uses access tokens (not ID tokens) with an API-specific scope so that - * Entra ID includes the `groups` claim for group-based authorization. + * Uses a delegated Microsoft Graph access token. The backend forwards this + * token only to trusted Graph endpoints to authenticate the user and resolve + * group membership. */ import { type Configuration, LogLevel } from '@azure/msal-browser' +const GRAPH_USER_READ_SCOPE = 'User.Read' + export interface AuthConfig { clientId: string tenantId: string @@ -54,23 +57,13 @@ export function buildMsalConfig(authConfig: AuthConfig): Configuration { } } -/** - * Build the API scopes for token acquisition. - * - * Requests the app's custom `access` scope so the resulting access token has - * `aud` equal to the app's client ID. The explicit scope name avoids the - * `.default` shorthand, which resolves via `requiredResourceAccess` and - * triggers mandatory admin consent in the Microsoft corporate tenant. - * - * The `access` scope is defined under "Expose an API" on the app registration. - */ -export function getApiScopes(clientId: string): string[] { - if (!clientId) return ['openid', 'profile', 'email'] - return [`api://${clientId}/access`] +/** Build the delegated Microsoft Graph scopes used for authentication. */ +export function getGraphScopes(): string[] { + return [GRAPH_USER_READ_SCOPE] } -export function buildLoginRequest(clientId: string) { +export function buildLoginRequest(): { scopes: string[] } { return { - scopes: getApiScopes(clientId), + scopes: getGraphScopes(), } } diff --git a/frontend/src/components/UserAccountButton.test.tsx b/frontend/src/components/UserAccountButton.test.tsx index 73035e928d..f0fa8d755f 100644 --- a/frontend/src/components/UserAccountButton.test.tsx +++ b/frontend/src/components/UserAccountButton.test.tsx @@ -26,7 +26,7 @@ jest.mock('../auth/AuthConfigContext', () => ({ })) jest.mock('../auth/msalConfig', () => ({ - buildLoginRequest: (clientId: string) => ({ scopes: [`${clientId}/.default`] }), + buildLoginRequest: () => ({ scopes: ['User.Read'] }), })) const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -78,7 +78,7 @@ describe('UserAccountButton', () => { await user.click(screen.getByRole('button', { name: /log in/i })) - expect(mockLoginRedirect).toHaveBeenCalledWith({ scopes: ['test-client-id/.default'] }) + expect(mockLoginRedirect).toHaveBeenCalledWith({ scopes: ['User.Read'] }) }) it('renders user display name and Sign Out when account exists', () => { diff --git a/frontend/src/components/UserAccountButton.tsx b/frontend/src/components/UserAccountButton.tsx index 9d258e6239..4bdc59bdd9 100644 --- a/frontend/src/components/UserAccountButton.tsx +++ b/frontend/src/components/UserAccountButton.tsx @@ -19,7 +19,6 @@ import { useUserAccountButtonStyles } from './UserAccountButton.styles' function MsalAccountButton() { const styles = useUserAccountButtonStyles() const { instance, accounts } = useMsal() - const config = useAuthConfig() const account = instance.getActiveAccount() ?? accounts[0] if (!account) { @@ -28,7 +27,7 @@ function MsalAccountButton() {