This is an automated email from the ASF dual-hosted git repository. lahirujayathilake pushed a commit to branch auth-endpoints-web in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 423ac6159a1e0fe187b220da74e42ad1dc4c4806 Author: lahiruj <[email protected]> AuthorDate: Wed Jun 17 00:32:06 2026 -0400 Swap NextAuth Keycloak provider for generic OIDC --- web/.env.example | 10 ++--- web/src/lib/env.ts | 48 +++++++------------- web/src/mocks/handlers/privileges.ts | 15 ++++++- web/src/shared/auth/__tests__/auth.test.ts | 37 ---------------- web/src/shared/auth/auth.ts | 70 +++++------------------------- web/src/shared/auth/devLevels.ts | 26 ----------- 6 files changed, 44 insertions(+), 162 deletions(-) diff --git a/web/.env.example b/web/.env.example index b7ec9a8e9..63cd8f33f 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,9 +1,8 @@ -# Copy to .env.local and fill in for OIDC mode. Dev defaults boot fine without -# this file present (MSW + dev credentials). +# Copy to .env.local and fill in. All OIDC values are required at boot; +# parseServer() in src/lib/env.ts throws if anything is missing. # Server-side # NODE_ENV=development -# PORTAL_AUTH_MODE=dev # dev | oidc # NEXTAUTH_SECRET= # NEXTAUTH_URL= @@ -12,11 +11,12 @@ # CUSTOS_ADMIN_CLIENT_ID= # CUSTOS_ADMIN_CLIENT_SECRET= -# OIDC (required when PORTAL_AUTH_MODE=oidc) +# OIDC (required) # OIDC_ISSUER_URL= # OIDC_CLIENT_ID= # OIDC_CLIENT_SECRET= +# OIDC_SCOPES=openid profile email org.cilogon.userinfo # Client-side -# NEXT_PUBLIC_PORTAL_USE_MSW=true +# NEXT_PUBLIC_PORTAL_USE_MSW=false # NEXT_PUBLIC_PORTAL_BUILD_SHA=dev diff --git a/web/src/lib/env.ts b/web/src/lib/env.ts index 1d3919cc5..35f966d0c 100644 --- a/web/src/lib/env.ts +++ b/web/src/lib/env.ts @@ -1,40 +1,22 @@ import { z } from "zod"; -export const serverSchema = z - .object({ - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - PORTAL_AUTH_MODE: z.enum(["dev", "oidc"]).default("dev"), - NEXTAUTH_SECRET: z.string().min(8).default("dev-secret-do-not-use-in-prod"), - NEXTAUTH_URL: z.string().url().optional(), +export const serverSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + NEXTAUTH_SECRET: z.string().min(8).default("dev-secret-do-not-use-in-prod"), + NEXTAUTH_URL: z.string().url().optional(), - CUSTOS_CORE_API_BASE_URL: z.string().url().default("http://localhost:8080"), - CUSTOS_ADMIN_CLIENT_ID: z.string().optional(), - CUSTOS_ADMIN_CLIENT_SECRET: z.string().optional(), + CUSTOS_CORE_API_BASE_URL: z.string().url().default("http://localhost:8080"), + CUSTOS_ADMIN_CLIENT_ID: z.string().optional(), + CUSTOS_ADMIN_CLIENT_SECRET: z.string().optional(), - OIDC_ISSUER_URL: z.string().url().optional(), - OIDC_CLIENT_ID: z.string().optional(), - OIDC_CLIENT_SECRET: z.string().optional(), - }) - .superRefine((env, ctx) => { - // OIDC mode demands these — otherwise NextAuth boots a misconfigured - // provider that silently fails at sign-in. - if (env.PORTAL_AUTH_MODE === "oidc") { - const required: Array<["OIDC_ISSUER_URL" | "OIDC_CLIENT_ID" | "OIDC_CLIENT_SECRET", string | undefined]> = [ - ["OIDC_ISSUER_URL", env.OIDC_ISSUER_URL], - ["OIDC_CLIENT_ID", env.OIDC_CLIENT_ID], - ["OIDC_CLIENT_SECRET", env.OIDC_CLIENT_SECRET], - ]; - for (const [name, value] of required) { - if (!value || value.trim().length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [name], - message: `${name} is required when PORTAL_AUTH_MODE='oidc'`, - }); - } - } - } - }); + OIDC_ISSUER_URL: z.string().url(), + OIDC_CLIENT_ID: z.string().min(1), + OIDC_CLIENT_SECRET: z.string().min(1), + OIDC_SCOPES: z + .string() + .min(1) + .default("openid profile email org.cilogon.userinfo"), +}); const clientSchema = z.object({ NEXT_PUBLIC_PORTAL_USE_MSW: z.enum(["true", "false"]).default("false"), diff --git a/web/src/mocks/handlers/privileges.ts b/web/src/mocks/handlers/privileges.ts index ffd9019cb..2078288dc 100644 --- a/web/src/mocks/handlers/privileges.ts +++ b/web/src/mocks/handlers/privileges.ts @@ -1,10 +1,21 @@ import { http, HttpResponse } from "msw"; -import { DEV_LEVEL_PRIVILEGES } from "@/shared/auth/devLevels"; +import type { Privilege } from "@/features/core/identity/types"; // Default to admin-grade so MSW-only browsing exercises the full UI; tests // override per-case via server.use(). +const adminPrivileges: Privilege[] = [ + "amie:read", + "amie:write", + "hpc:read", + "hpc:write", + "signer:read", + "signer:write", + "privileges:grant", + "roles:manage", +]; + export const privilegesHandlers = [ http.get("*/api/v1/user/privileges", () => - HttpResponse.json({ privileges: DEV_LEVEL_PRIVILEGES.admin }), + HttpResponse.json({ privileges: adminPrivileges }), ), ]; diff --git a/web/src/shared/auth/__tests__/auth.test.ts b/web/src/shared/auth/__tests__/auth.test.ts deleted file mode 100644 index 47d8ecb20..000000000 --- a/web/src/shared/auth/__tests__/auth.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { zPrivilegeKey } from "@/generated/core/zod.gen"; -import { DEV_LEVEL_PRIVILEGES, DEV_LEVEL_NAMES, type DevLevel } from "../devLevels"; - -describe("DEV_LEVEL_PRIVILEGES", () => { - it("viewer is read-only HPC", () => { - expect(DEV_LEVEL_PRIVILEGES.viewer).toEqual(["hpc:read"]); - }); - - it("manager carries HPC write + AMIE read", () => { - expect(DEV_LEVEL_PRIVILEGES.manager).toEqual( - expect.arrayContaining(["hpc:read", "hpc:write", "amie:read"]), - ); - expect(DEV_LEVEL_PRIVILEGES.manager).not.toContain("privileges:grant"); - }); - - it("admin covers the full spec enum", () => { - const adminSet = new Set(DEV_LEVEL_PRIVILEGES.admin); - for (const key of zPrivilegeKey.options) { - expect(adminSet.has(key)).toBe(true); - } - }); - - it("every level's privileges parse against the OpenAPI enum", () => { - for (const level of Object.keys(DEV_LEVEL_PRIVILEGES) as DevLevel[]) { - for (const p of DEV_LEVEL_PRIVILEGES[level]) { - expect(() => zPrivilegeKey.parse(p)).not.toThrow(); - } - } - }); - - it("exposes a name per level", () => { - for (const level of Object.keys(DEV_LEVEL_PRIVILEGES) as DevLevel[]) { - expect(DEV_LEVEL_NAMES[level]).toMatch(/Dev/); - } - }); -}); diff --git a/web/src/shared/auth/auth.ts b/web/src/shared/auth/auth.ts index 8d05a79e3..8e2ffd0ca 100644 --- a/web/src/shared/auth/auth.ts +++ b/web/src/shared/auth/auth.ts @@ -1,78 +1,30 @@ import NextAuth, { type NextAuthConfig } from "next-auth"; -import Credentials from "next-auth/providers/credentials"; -import Keycloak from "next-auth/providers/keycloak"; -import { z } from "zod"; import { serverEnv } from "@/lib/env"; import type { Privilege } from "@/features/core/identity/types"; -import { DEV_LEVEL_NAMES, DEV_LEVEL_PRIVILEGES } from "./devLevels"; -export { DEV_LEVEL_PRIVILEGES, DEV_LEVEL_NAMES, type DevLevel } from "./devLevels"; - -const credentialsSchema = z.object({ - level: z.enum(["viewer", "manager", "admin"]), -}); - -const oidcEnabled = serverEnv.PORTAL_AUTH_MODE === "oidc"; -const devEnabled = serverEnv.PORTAL_AUTH_MODE === "dev"; - -// Map dev levels to the seeded backend user IDs in -// dev-ops/compose/seeds/dev_users_and_roles.sql so X-Custos-User-Id resolves. -const DEV_LEVEL_BACKEND_USER_ID: Record<"viewer" | "manager" | "admin", string> = { - viewer: "dev-researcher", - manager: "dev-operator", - admin: "dev-admin", +const oidcProvider = { + id: "oidc", + name: "Sign in", + type: "oidc" as const, + issuer: serverEnv.OIDC_ISSUER_URL, + clientId: serverEnv.OIDC_CLIENT_ID, + clientSecret: serverEnv.OIDC_CLIENT_SECRET, + authorization: { params: { scope: serverEnv.OIDC_SCOPES } }, }; -const credentialsProvider = Credentials({ - id: "credentials", - name: "Dev credentials", - credentials: { level: { label: "Level", type: "text" } }, - authorize: async (raw) => { - const parsed = credentialsSchema.safeParse(raw); - if (!parsed.success) return null; - const level = parsed.data.level; - const email = `${level}@custos.local`; - return { - id: DEV_LEVEL_BACKEND_USER_ID[level], - email, - name: DEV_LEVEL_NAMES[level], - privileges: DEV_LEVEL_PRIVILEGES[level], - }; - }, -}); - -const providers: NextAuthConfig["providers"] = [ - ...(devEnabled ? [credentialsProvider] : []), - ...(oidcEnabled - ? [ - Keycloak({ - id: "oidc", - issuer: serverEnv.OIDC_ISSUER_URL ?? "", - clientId: serverEnv.OIDC_CLIENT_ID ?? "", - clientSecret: serverEnv.OIDC_CLIENT_SECRET ?? "", - authorization: { params: { scope: "openid email profile" } }, - }), - ] - : []), -]; - export const authConfig: NextAuthConfig = { trustHost: true, secret: serverEnv.NEXTAUTH_SECRET, session: { strategy: "jwt" }, pages: { signIn: "/sign-in" }, - providers, + providers: [oidcProvider], callbacks: { async jwt({ token, user, account }) { if (user) { (token as { privileges?: Privilege[] }).privileges = user.privileges ?? []; } - if (account?.access_token) { - (token as { accessToken?: string }).accessToken = account.access_token; - } else if (!token.accessToken && devEnabled) { - // Dev mode has no upstream token; the proxy uses this as a sentinel - // so user-path forwarding succeeds without an IdP. - (token as { accessToken?: string }).accessToken = "dev-token"; + if (account?.id_token) { + (token as { accessToken?: string }).accessToken = account.id_token; } return token; }, diff --git a/web/src/shared/auth/devLevels.ts b/web/src/shared/auth/devLevels.ts deleted file mode 100644 index 9e424d9c1..000000000 --- a/web/src/shared/auth/devLevels.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Privilege } from "@/features/core/identity/types"; - -export type DevLevel = "viewer" | "manager" | "admin"; - -// Dev-mode privilege bundles. Levels are coarser than the spec roles so the -// dropdown stays simple; admin covers the full PrivilegeKey enum. -export const DEV_LEVEL_PRIVILEGES: Record<DevLevel, Privilege[]> = { - viewer: ["hpc:read"], - manager: ["hpc:read", "hpc:write", "amie:read"], - admin: [ - "amie:read", - "amie:write", - "hpc:read", - "hpc:write", - "signer:read", - "signer:write", - "privileges:grant", - "roles:manage", - ], -}; - -export const DEV_LEVEL_NAMES: Record<DevLevel, string> = { - viewer: "Dev Viewer", - manager: "Dev Manager", - admin: "Dev Admin", -};
