Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy_development_app_engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy_production_app_engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy_staging_app_engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 75 additions & 21 deletions booking-app/lib/server/nyuApiAuth.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -22,38 +39,75 @@ export async function getNYUToken(): Promise<string | null> {
}
}

async function refreshNYUToken(): Promise<string | null> {
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}`,
"Content-Type": "application/x-www-form-urlencoded",
},
cache: "no-store",
body: params.toString(),
});
},
};
}

async function refreshNYUToken(): Promise<string | null> {
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);
Expand Down
150 changes: 150 additions & 0 deletions booking-app/tests/unit/nyuApiAuth.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading