From 3be58ed40a07e61de8f64b2b7b676a6cc1be3add Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Mon, 15 Jun 2026 12:37:32 -0400 Subject: [PATCH] feat(auth): add Entra ID client_credentials token mode for NYU API NYU IT is migrating API auth from WSO2 to Microsoft Entra ID. Add a dual-mode token path in nyuApiAuth: default keeps the WSO2 password grant; NYU_API_AUTH_PROVIDER=entra switches to the Entra client_credentials grant. Deploy workflows pass through the new provider flag and Entra client id/secret. Default behavior is unchanged until the flag is set. --- .../deploy_development_app_engine.yml | 3 + .../deploy_production_app_engine.yml | 3 + .../workflows/deploy_staging_app_engine.yml | 3 + booking-app/lib/server/nyuApiAuth.ts | 96 ++++++++--- .../tests/unit/nyuApiAuth.unit.test.ts | 150 ++++++++++++++++++ 5 files changed, 234 insertions(+), 21 deletions(-) create mode 100644 booking-app/tests/unit/nyuApiAuth.unit.test.ts diff --git a/.github/workflows/deploy_development_app_engine.yml b/.github/workflows/deploy_development_app_engine.yml index 9e0f04a8c..8f8be57a4 100644 --- a/.github/workflows/deploy_development_app_engine.yml +++ b/.github/workflows/deploy_development_app_engine.yml @@ -60,6 +60,9 @@ jobs: echo "NYU_API_USER_NAME=${{ secrets.NYU_API_USER_NAME }}" >> .env.production echo "NYU_API_PASSWORD=${{ secrets.NYU_API_PASSWORD }}" >> .env.production echo "NYU_API_ACCESS_ID=${{ secrets.NYU_API_ACCESS_ID }}" >> .env.production + echo "NYU_API_AUTH_PROVIDER=${{ vars.NYU_API_AUTH_PROVIDER }}" >> .env.production + echo "NYU_ENTRA_CLIENT_ID=${{ secrets.NYU_ENTRA_CLIENT_ID }}" >> .env.production + echo "NYU_ENTRA_CLIENT_SECRET=${{ secrets.NYU_ENTRA_CLIENT_SECRET }}" >> .env.production echo "OIDC_ISSUER=${{ vars.OIDC_ISSUER }}" >> .env.production echo "OIDC_CLIENT_ID=${{ vars.OIDC_CLIENT_ID }}" >> .env.production echo "OIDC_CLIENT_SECRET=${{ secrets.OIDC_CLIENT_SECRET }}" >> .env.production diff --git a/.github/workflows/deploy_production_app_engine.yml b/.github/workflows/deploy_production_app_engine.yml index 8e9cbffcc..a89c45e1f 100644 --- a/.github/workflows/deploy_production_app_engine.yml +++ b/.github/workflows/deploy_production_app_engine.yml @@ -60,6 +60,9 @@ jobs: echo "NYU_API_USER_NAME=${{ secrets.NYU_API_USER_NAME }}" >> .env.production echo "NYU_API_PASSWORD=${{ secrets.NYU_API_PASSWORD }}" >> .env.production echo "NYU_API_ACCESS_ID=${{ secrets.NYU_API_ACCESS_ID }}" >> .env.production + echo "NYU_API_AUTH_PROVIDER=${{ vars.NYU_API_AUTH_PROVIDER }}" >> .env.production + echo "NYU_ENTRA_CLIENT_ID=${{ secrets.NYU_ENTRA_CLIENT_ID }}" >> .env.production + echo "NYU_ENTRA_CLIENT_SECRET=${{ secrets.NYU_ENTRA_CLIENT_SECRET }}" >> .env.production echo "OIDC_ISSUER=${{ vars.OIDC_ISSUER }}" >> .env.production echo "OIDC_CLIENT_ID=${{ vars.OIDC_CLIENT_ID }}" >> .env.production echo "OIDC_CLIENT_SECRET=${{ secrets.OIDC_CLIENT_SECRET }}" >> .env.production diff --git a/.github/workflows/deploy_staging_app_engine.yml b/.github/workflows/deploy_staging_app_engine.yml index 3af02c5df..5a85fde94 100644 --- a/.github/workflows/deploy_staging_app_engine.yml +++ b/.github/workflows/deploy_staging_app_engine.yml @@ -60,6 +60,9 @@ jobs: echo "NYU_API_USER_NAME=${{ secrets.NYU_API_USER_NAME }}" >> .env.production echo "NYU_API_PASSWORD=${{ secrets.NYU_API_PASSWORD }}" >> .env.production echo "NYU_API_ACCESS_ID=${{ secrets.NYU_API_ACCESS_ID }}" >> .env.production + echo "NYU_API_AUTH_PROVIDER=${{ vars.NYU_API_AUTH_PROVIDER }}" >> .env.production + echo "NYU_ENTRA_CLIENT_ID=${{ secrets.NYU_ENTRA_CLIENT_ID }}" >> .env.production + echo "NYU_ENTRA_CLIENT_SECRET=${{ secrets.NYU_ENTRA_CLIENT_SECRET }}" >> .env.production echo "OIDC_ISSUER=${{ vars.OIDC_ISSUER }}" >> .env.production echo "OIDC_CLIENT_ID=${{ vars.OIDC_CLIENT_ID }}" >> .env.production echo "OIDC_CLIENT_SECRET=${{ secrets.OIDC_CLIENT_SECRET }}" >> .env.production diff --git a/booking-app/lib/server/nyuApiAuth.ts b/booking-app/lib/server/nyuApiAuth.ts index e27d9faac..5a974e84e 100644 --- a/booking-app/lib/server/nyuApiAuth.ts +++ b/booking-app/lib/server/nyuApiAuth.ts @@ -1,4 +1,21 @@ -const NYU_AUTH_URL = "https://auth.nyu.edu/oauth2/token"; +// NYU API OAuth token acquisition. +// +// NYU IT is migrating API auth from WSO2 to Microsoft Entra ID (WSO2 retires +// July 31). Only the token endpoint changes; API base URLs stay the same. +// Until our API's migration window, keep using WSO2. At cutover, set +// NYU_API_AUTH_PROVIDER=entra (plus NYU_ENTRA_CLIENT_ID / NYU_ENTRA_CLIENT_SECRET) +// and redeploy — no code change needed. + +const WSO2_AUTH_URL = "https://auth.nyu.edu/oauth2/token"; +// NYU's Entra tenant ID — constant. Verified against a live token request; +// the value in the migration PDF (3eda674c-…) is a placeholder that returns +// AADSTS700016. Overridable via env in case NYU IT publishes a different one. +const ENTRA_TENANT_ID = + process.env.NYU_ENTRA_TENANT_ID || "665be5ef-3fc6-401b-b6c4-401a9d9c7b72"; +const ENTRA_AUTH_URL = `https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token`; +const ENTRA_SCOPE = + process.env.NYU_ENTRA_SCOPE || "https://graph.microsoft.com/.default"; + export const NYU_API_BASE = "https://api.nyu.edu/identity-v2-sys"; // Cache the OAuth token in memory. Refresh 60s before actual expiry. @@ -22,30 +39,56 @@ export async function getNYUToken(): Promise { } } -async function refreshNYUToken(): Promise { +function buildEntraTokenRequest(): { url: string; init: RequestInit } { + const clientId = process.env.NYU_ENTRA_CLIENT_ID; + const clientSecret = process.env.NYU_ENTRA_CLIENT_SECRET; - try { - const clientId = process.env.NYU_API_CLIENT_ID; - const clientSecret = process.env.NYU_API_CLIENT_SECRET; - const username = process.env.NYU_API_USER_NAME; - const password = process.env.NYU_API_PASSWORD; + if (!clientId || !clientSecret) { + throw new Error("NYU Entra ID credentials not configured"); + } - if (!clientId || !clientSecret || !username || !password) { - throw new Error("NYU credentials not configured"); - } + const params = new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + scope: ENTRA_SCOPE, + }); - const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( - "base64", - ); + return { + url: ENTRA_AUTH_URL, + init: { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + cache: "no-store", + body: params.toString(), + }, + }; +} - const params = new URLSearchParams({ - grant_type: "password", - username, - password, - scope: "openid", - }); +function buildWso2TokenRequest(): { url: string; init: RequestInit } { + const clientId = process.env.NYU_API_CLIENT_ID; + const clientSecret = process.env.NYU_API_CLIENT_SECRET; + const username = process.env.NYU_API_USER_NAME; + const password = process.env.NYU_API_PASSWORD; - const response = await fetch(NYU_AUTH_URL, { + if (!clientId || !clientSecret || !username || !password) { + throw new Error("NYU credentials not configured"); + } + + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( + "base64", + ); + + const params = new URLSearchParams({ + grant_type: "password", + username, + password, + scope: "openid", + }); + + return { + url: WSO2_AUTH_URL, + init: { method: "POST", headers: { Authorization: `Basic ${basicAuth}`, @@ -53,7 +96,18 @@ async function refreshNYUToken(): Promise { }, cache: "no-store", body: params.toString(), - }); + }, + }; +} + +async function refreshNYUToken(): Promise { + try { + const { url, init } = + process.env.NYU_API_AUTH_PROVIDER === "entra" + ? buildEntraTokenRequest() + : buildWso2TokenRequest(); + + const response = await fetch(url, init); if (!response.ok) { console.log("Error response", response); diff --git a/booking-app/tests/unit/nyuApiAuth.unit.test.ts b/booking-app/tests/unit/nyuApiAuth.unit.test.ts new file mode 100644 index 000000000..f6b323b5d --- /dev/null +++ b/booking-app/tests/unit/nyuApiAuth.unit.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalFetch = globalThis.fetch; +const mockFetch = vi.fn(); + +function tokenResponse(token = "test-token", expiresIn = 3600) { + return { + ok: true, + json: async () => ({ access_token: token, expires_in: expiresIn }), + }; +} + +// nyuApiAuth caches the token and reads env at module scope, so each test +// re-imports a fresh module instance. +async function importFreshModule() { + vi.resetModules(); + return import("@/lib/server/nyuApiAuth"); +} + +beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + vi.stubEnv("NYU_API_CLIENT_ID", "wso2-client"); + vi.stubEnv("NYU_API_CLIENT_SECRET", "wso2-secret"); + vi.stubEnv("NYU_API_USER_NAME", "wso2-user"); + vi.stubEnv("NYU_API_PASSWORD", "wso2-pass"); + vi.stubEnv("NYU_API_AUTH_PROVIDER", ""); + vi.stubEnv("NYU_ENTRA_CLIENT_ID", ""); + vi.stubEnv("NYU_ENTRA_CLIENT_SECRET", ""); + vi.stubEnv("NYU_ENTRA_TENANT_ID", ""); + vi.stubEnv("NYU_ENTRA_SCOPE", ""); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.unstubAllEnvs(); +}); + +describe("getNYUToken — WSO2 (default provider)", () => { + it("posts a password grant with Basic auth to auth.nyu.edu", async () => { + mockFetch.mockResolvedValueOnce(tokenResponse("wso2-token")); + const { getNYUToken } = await importFreshModule(); + + const token = await getNYUToken(); + + expect(token).toBe("wso2-token"); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("https://auth.nyu.edu/oauth2/token"); + expect(init.headers.Authorization).toBe( + `Basic ${Buffer.from("wso2-client:wso2-secret").toString("base64")}`, + ); + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("password"); + expect(body.get("username")).toBe("wso2-user"); + expect(body.get("password")).toBe("wso2-pass"); + expect(body.get("scope")).toBe("openid"); + }); + + it("returns null when WSO2 credentials are missing", async () => { + vi.stubEnv("NYU_API_PASSWORD", ""); + const { getNYUToken } = await importFreshModule(); + + expect(await getNYUToken()).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("getNYUToken — Entra ID provider", () => { + beforeEach(() => { + vi.stubEnv("NYU_API_AUTH_PROVIDER", "entra"); + vi.stubEnv("NYU_ENTRA_CLIENT_ID", "entra-client"); + vi.stubEnv("NYU_ENTRA_CLIENT_SECRET", "entra-secret"); + }); + + it("posts a client_credentials grant to the Entra token endpoint", async () => { + mockFetch.mockResolvedValueOnce(tokenResponse("entra-token")); + const { getNYUToken } = await importFreshModule(); + + const token = await getNYUToken(); + + expect(token).toBe("entra-token"); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe( + "https://login.microsoftonline.com/665be5ef-3fc6-401b-b6c4-401a9d9c7b72/oauth2/v2.0/token", + ); + // Entra uses credentials in the body, not Basic auth + expect(init.headers.Authorization).toBeUndefined(); + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("client_id")).toBe("entra-client"); + expect(body.get("client_secret")).toBe("entra-secret"); + expect(body.get("scope")).toBe("https://graph.microsoft.com/.default"); + expect(body.get("username")).toBeNull(); + expect(body.get("password")).toBeNull(); + }); + + it("honors NYU_ENTRA_TENANT_ID and NYU_ENTRA_SCOPE overrides", async () => { + vi.stubEnv("NYU_ENTRA_TENANT_ID", "custom-tenant"); + vi.stubEnv("NYU_ENTRA_SCOPE", "api://custom/.default"); + mockFetch.mockResolvedValueOnce(tokenResponse()); + const { getNYUToken } = await importFreshModule(); + + await getNYUToken(); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe( + "https://login.microsoftonline.com/custom-tenant/oauth2/v2.0/token", + ); + expect(new URLSearchParams(init.body).get("scope")).toBe( + "api://custom/.default", + ); + }); + + it("returns null when Entra credentials are missing", async () => { + vi.stubEnv("NYU_ENTRA_CLIENT_SECRET", ""); + const { getNYUToken } = await importFreshModule(); + + expect(await getNYUToken()).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns null on a non-OK token response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + const { getNYUToken } = await importFreshModule(); + + expect(await getNYUToken()).toBeNull(); + }); +}); + +describe("getNYUToken — caching", () => { + it("reuses the cached token until expiry", async () => { + mockFetch.mockResolvedValue(tokenResponse("cached-token")); + const { getNYUToken } = await importFreshModule(); + + expect(await getNYUToken()).toBe("cached-token"); + expect(await getNYUToken()).toBe("cached-token"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("deduplicates concurrent refreshes", async () => { + mockFetch.mockResolvedValue(tokenResponse()); + const { getNYUToken } = await importFreshModule(); + + await Promise.all([getNYUToken(), getNYUToken(), getNYUToken()]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +});