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

Reply via email to