diff --git a/.gitignore b/.gitignore
index cae22d6..6b160ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -148,3 +148,6 @@ docs/
uploader/.env
uploader/staging/
geddes/k8s/01-secrets.yaml
+
+# SAML SP signing keypair (private key must never be committed)
+geddes/k8s/saml/
diff --git a/DEMO_DRY_RUN.md b/DEMO_DRY_RUN.md
index d723663..50d25e7 100644
--- a/DEMO_DRY_RUN.md
+++ b/DEMO_DRY_RUN.md
@@ -50,7 +50,7 @@ cookie is fresh, then sign out so the audience watches login from scratch.
### Beat 1: Login (~20 s)
-> "Everything you see is gated behind NanoHUB OAuth2. Anonymous users
+> "Everything you see is gated behind NanoHUB SAML. Anonymous users
> can't reach a single page or API route — let me show you."
1. Browser is at `https://dt-nanohub.geddes.rcac.purdue.edu/`.
@@ -257,7 +257,7 @@ curl -sS \
|---|---|
| "Where does the real equipment data come from?" | The PlasmaTherm logger writes into a Postgres database called `logger` (we call it Glance). PostgREST exposes it read-only. The Azure Function pulls from there, computes per-step features, merges FIMAP thickness, and pushes processed rows into the platform DB. The 8,566 historical real runs already in Glance are how the ML models were trained. |
| "Who can see what?" | Postgres row-level security. Every read connects as a non-privileged role and sets `app.current_user`. Project membership rows decide what each user can see. Admins use a superuser connection that bypasses RLS for moderation. Cross-PI isolation is verified by 18 automated end-to-end test phases. |
-| "How is auth enforced?" | NanoHUB OAuth2 → Auth.js JWT cookie → server-side `proxy.ts` rejects every protected route at the edge. Anonymous users get a 307 redirect, anonymous API calls get 401. The FastAPI backend independently requires `X-System-Token` plus user headers. Two layers, both server-side. |
+| "How is auth enforced?" | NanoHUB SAML → encrypted Auth.js-compatible JWT cookie → server-side `proxy.ts` rejects every protected route at the edge. Anonymous users get a 307 redirect, anonymous API calls get 401. The FastAPI backend independently requires `X-System-Token` plus user headers. Two layers, both server-side. |
| "What happens if Azure goes down?" | The Azure Function is fail-closed — if the POST to `/api/dataset/v2/runs/sync` fails, it does not advance `last_processed_time.txt`, so the next successful run picks up from where it left off. No silent data loss. |
| "Is this production?" | Yes. Live URL `https://dt-nanohub.geddes.rcac.purdue.edu`. The 17/17 (or 18/18 once the auth-aware Phase 17 lands) E2E suite runs against this exact deployment. |
| "What's not done yet?" | Operational: nightly DB backups + restore drill; rotating the Glance Postgres password and PostgREST JWT secret into Kubernetes secrets; one stale Phase-17 line in the E2E test that needs auth headers added. None of that affects what you're seeing on screen. See `PRODUCTION_READINESS_PLAN.md`. |
diff --git a/DIGITAL_TWIN_PLATFORM_OVERVIEW.md b/DIGITAL_TWIN_PLATFORM_OVERVIEW.md
index e88ae10..7f7d42b 100644
--- a/DIGITAL_TWIN_PLATFORM_OVERVIEW.md
+++ b/DIGITAL_TWIN_PLATFORM_OVERVIEW.md
@@ -73,7 +73,7 @@ flowchart LR
AzFn -->|POST /runs/sync X-Ingestion-Token| Api
Op -->|browser| Web
- Web -->|NextAuth OAuth| NH
+ Web -->|SAML 2.0| NH
Web -->|REST + headers| Api
Api <-->|RLS-scoped| Db
@@ -88,7 +88,7 @@ flowchart LR
1. **Stateless API.** Every durable artifact (equipment metadata, projects, runs, ML models, snapshots) lives in Postgres. There is no `PersistentVolumeClaim` mounted into the API pod. Approved-equipment JSON snapshots and uploaded files are an ephemeral cache; Postgres is authoritative.
2. **Read-only towards equipment.** Telemetry flows *in* from Glance; the platform has no write path back to the tool. This is a deliberate safety property, not a missing feature.
3. **Defense-in-depth authentication.** Three orthogonal auth mechanisms:
- - **NanoHub SSO** for users (NextAuth.js → OAuth).
+ - **NanoHub SSO** for users (SAML 2.0 with Auth.js-encrypted sessions).
- **`X-System-Token` + `X-User-*` headers** from the Next.js proxy to the API (machine-verified identity).
- **`X-Ingestion-Token`** for the Azure Function (unique key; refuses user traffic).
4. **Postgres Row-Level Security** (RLS) is the final backstop. Non-admin users physically cannot read projects or runs they are not a member of, regardless of what the API does.
@@ -207,7 +207,7 @@ RBAC is enforced in **three places** for defense-in-depth:
### Stage 1 — Partner & User Onboarding
-**Status: Fully implemented on `dev`.**
+**Status: Implemented; nanoHUB SP registration and production rollout pending.**
#### What it is
Bringing a new partner, PI, or researcher onto the platform. Everything — authentication, role assignment, organization tagging, database row creation — happens automatically the first time the user logs in via NanoHub SSO. An administrator can also pre-invite a user with a specific role before their first login.
@@ -223,8 +223,8 @@ sequenceDiagram
participant D as Postgres
U->>W: Click "Sign in with NanoHub"
- W->>N: OAuth redirect
- N-->>W: OAuth callback + user profile
+ W->>N: Signed SAML AuthnRequest (HTTP-Redirect)
+ N-->>W: Signed assertion to ACS (HTTP-POST)
W->>A: GET /admin/users/{email}/role (X-System-Token)
A->>D: SELECT / INSERT user row (upsert)
A-->>W: {role, organization, id}
@@ -235,7 +235,7 @@ sequenceDiagram
#### Where it lives
-- **Front end:** `web/auth.ts` (NextAuth.js config), `web/app/login/page.tsx` (landing), `web/lib/auth-context.tsx` (session provider + permissions).
+- **Front end:** `web/app/api/auth/saml/` (SAML routes), `web/auth.ts` (encrypted session compatibility), `web/app/login/page.tsx` (landing), and `web/lib/auth-context.tsx` (session provider + permissions).
- **Back end:** `api/routers/admin.py` (`GET /admin/users/{email}/role`, `POST /admin/users` invite, `GET /admin/users` list).
- **Admin UI:** `web/app/admin/users/page.tsx` — admin-only page with invite form (email + role + organization), user list, role badges.
- **Database:** `users` table — `id`, `name`, `email`, `role`, `organization`, `status`, `joined_at`, `last_active`.
diff --git a/NextJSAppDescriptionAndTestingDocument.md b/NextJSAppDescriptionAndTestingDocument.md
index 2b1fd1b..82228bd 100644
--- a/NextJSAppDescriptionAndTestingDocument.md
+++ b/NextJSAppDescriptionAndTestingDocument.md
@@ -149,7 +149,7 @@ The auth path is:
1. The user visits a protected page.
2. `web/proxy.ts` checks for a NextAuth session token.
3. Anonymous users are redirected to `/login`.
-4. Login uses NanoHUB OAuth through `web/auth.ts`.
+4. Login uses nanoHUB SAML through `web/app/api/auth/saml/`.
5. After sign-in, the Next.js API proxy at `web/app/api/dt/[...path]/route.ts`
forwards requests to FastAPI and injects these headers:
- `X-System-Token`
@@ -828,8 +828,9 @@ cd web
npm install
export NEXTAUTH_URL="http://localhost:3000"
export AUTH_SECRET="replace-me"
-export NANOHUB_CLIENT_ID="replace-me"
-export NANOHUB_CLIENT_SECRET="replace-me"
+export SAML_SP_PRIVATE_KEY_PATH="../geddes/k8s/saml/sp-private-key.pem"
+export SAML_SP_CERTIFICATE_PATH="../geddes/k8s/saml/sp-certificate.pem"
+export SAML_IDP_CERTIFICATE_PATH="../geddes/k8s/saml/idp-certificate.pem"
export DT_SYSTEM_TOKEN="replace-me"
export DT_API_URL_INTERNAL="http://127.0.0.1:8000/api"
export NEXT_PUBLIC_API_URL="http://127.0.0.1:8000/api"
@@ -844,8 +845,8 @@ http://localhost:3000
Important note:
-1. The app is designed around real NanoHUB OAuth.
-2. If you do not have valid NanoHUB credentials and callback configuration,
+1. The app is designed around real nanoHUB SAML.
+2. If you do not have valid SAML certificates and Service Provider registration,
local UI login will not behave like production.
3. In that case, use the deployed UI for browser validation and use local API
or scripts for backend testing.
@@ -953,7 +954,7 @@ Check:
Likely causes:
-1. Missing NanoHUB OAuth credentials
+1. Missing SAML signing keys or nanoHUB IdP certificate
2. Wrong `NEXTAUTH_URL`
3. Missing `AUTH_SECRET`
4. Expecting local DEMO mode to bypass route protection
diff --git a/PRODUCTION_READINESS_PLAN.md b/PRODUCTION_READINESS_PLAN.md
index 3b99d28..77e39a2 100644
--- a/PRODUCTION_READINESS_PLAN.md
+++ b/PRODUCTION_READINESS_PLAN.md
@@ -42,7 +42,7 @@ readiness:
| 4 | ~~`geddes/k8s/02-api.yaml` references a `dt-api-runtime` PVC; the PVC manifest exists at `geddes/k8s/06-api-storage.yaml` but was never applied to the cluster, so `kubectl apply -f geddes/k8s/` fails to schedule a new pod~~ **RESOLVED**: PVC removed from manifest; API is intentionally stateless. See Phase C. | ~~High~~ Closed |
| 5 | The Geddes registry contains two different images both tagged `:v6`, suggesting the registry is being treated as mutable | Medium |
| 6 | Real Azure Function ingestion path has not been exercised end-to-end against the v7 image | High |
-| 7 | NanoHUB OAuth2 / Auth.js login flow with role sync from JWT has not been smoke-tested end-to-end | Medium (UI works per user, but the auth handshake is server-side) |
+| 7 | NanoHUB SAML login flow with role sync from JWT has not been smoke-tested end-to-end | Medium (UI works per user, but the auth handshake is server-side) |
| 8 | No load / scale validation (the test inserted 8 rows into a dataset of ~140) | Medium |
| 9 | Glance read-only DB → PostgREST → platform read path is untouched and unverified | Low–Medium |
| 10 | Several uncommitted local changes (`web/auth.ts`, `azure/process_etcher_data/__init__.py`, `nanohub/code/src/*`, `web/app/api/dt/[...path]/route.ts`, `azure/local.settings.json.example`, etc.) sit in the working tree of `dev` after the merge — must be triaged | Medium |
@@ -573,7 +573,7 @@ removes the mock id from `processed_runs.json`).
## 8. Phase G — Auth Handshake Smoke Test
-**Goal**: Confirm the server-side OAuth2 → Auth.js → role-sync handshake
+**Goal**: Confirm the server-side SAML → Auth.js-compatible session → role-sync handshake
still works against `dt-api:v7`. The user has confirmed UI rendering, so
this phase only verifies the *server* part of the auth contract.
@@ -615,7 +615,7 @@ this phase only verifies the *server* part of the auth contract.
- No 5xx in the logs during the login window.
**If failure**: the most likely culprits are (a) `DT_SYSTEM_TOKEN` drift
-between `dt-web` and `dt-api`, (b) NanoHUB OAuth callback URL not
+between `dt-web` and `dt-api`, (b) nanoHUB SAML ACS URL not
whitelisted, or (c) `web/auth.ts` modifications from §6 not yet
committed and not running in the deployed `dt-web` image.
diff --git a/README.md b/README.md
index 546bfc3..580421f 100644
--- a/README.md
+++ b/README.md
@@ -100,4 +100,4 @@ Birck-Digital-Twin/
## Documentation
- **[Deployment Runbook](deployment_runbook.md)**: Step-by-step instructions for live setup.
- **[API V2 Docs](api/data_loader_pg.py)**: Python interface for Postgres data access.
-- **[Web Auth Docs](web/auth.ts)**: NanoHUB OAuth2 integration details.
+- **[Web Auth Docs](web/SAML_AUTHENTICATION.md)**: nanoHUB SAML integration and deployment details.
diff --git a/api/security.py b/api/security.py
index dab443f..a50f25e 100644
--- a/api/security.py
+++ b/api/security.py
@@ -53,7 +53,9 @@ def require_system_token(
) from exc
if x_system_token != expected_token:
- logger.warning(f"System token mismatch! Received: '{x_system_token}', Expected: '{expected_token}'")
+ # Never place either the received credential or the configured secret
+ # in logs. Operators only need the fact that validation failed.
+ logger.warning("System token mismatch")
raise HTTPException(status_code=403, detail="Invalid system token")
diff --git a/geddes/k8s/01-secrets.yaml.example b/geddes/k8s/01-secrets.yaml.example
index a016aaa..f4ee16c 100644
--- a/geddes/k8s/01-secrets.yaml.example
+++ b/geddes/k8s/01-secrets.yaml.example
@@ -12,9 +12,18 @@ stringData:
# Run `openssl rand -base64 32` locally and paste the result here
NEXTAUTH_SECRET: "REPLACE_WITH_BASE64_RANDOM_SECRET"
- # From nanohub.org/developer
- NANOHUB_CLIENT_ID: "REPLACE_WITH_CLIENT_ID"
- NANOHUB_CLIENT_SECRET: "REPLACE_WITH_CLIENT_SECRET"
+ # nanoHUB SAML Service Provider. The PEM files are mounted from the separate
+ # dt-saml-secret; never place the private key in this committed example.
+ SAML_SP_ENTITY_ID: "https://dt-nanohub.geddes.rcac.purdue.edu/api/auth/saml/metadata"
+ SAML_SP_PRIVATE_KEY_PATH: "/var/run/secrets/dt-saml/sp-private-key.pem"
+ SAML_SP_CERTIFICATE_PATH: "/var/run/secrets/dt-saml/sp-certificate.pem"
+ SAML_IDP_CERTIFICATE_PATH: "/var/run/secrets/dt-saml/idp-certificate.pem"
+ SAML_IDP_ENTITY_ID: "https://nanohub.org"
+ SAML_IDP_SSO_URL: "https://nanohub.org/saml/idp/login"
+ SAML_IDP_SLO_URL: "https://nanohub.org/saml/idp/logout"
+ # Keep true unless nanoHUB confirms that it signs only the Assertion and not
+ # the top-level Response.
+ SAML_WANT_AUTHN_RESPONSE_SIGNED: "true"
# Internal API base URL used by NextAuth callbacks and the server-side proxy
DT_API_URL_INTERNAL: "http://dt-api:8000/api"
diff --git a/geddes/k8s/01a-saml-secret.yaml.example b/geddes/k8s/01a-saml-secret.yaml.example
new file mode 100644
index 0000000..9c47ad8
--- /dev/null
+++ b/geddes/k8s/01a-saml-secret.yaml.example
@@ -0,0 +1,21 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: dt-saml-secret
+ namespace: ncn-digitaltwins-zchen
+type: Opaque
+stringData:
+ # Replace all three placeholders from local files before applying. Never
+ # commit the populated manifest or the SP private key.
+ sp-private-key.pem: |-
+ -----BEGIN PRIVATE KEY-----
+ REPLACE_WITH_DT_SP_PRIVATE_KEY
+ -----END PRIVATE KEY-----
+ sp-certificate.pem: |-
+ -----BEGIN CERTIFICATE-----
+ REPLACE_WITH_DT_SP_PUBLIC_CERTIFICATE
+ -----END CERTIFICATE-----
+ idp-certificate.pem: |-
+ -----BEGIN CERTIFICATE-----
+ REPLACE_WITH_NANOHUB_IDP_SIGNING_CERTIFICATE
+ -----END CERTIFICATE-----
diff --git a/geddes/k8s/03-web.yaml b/geddes/k8s/03-web.yaml
index d8ee084..4234e57 100644
--- a/geddes/k8s/03-web.yaml
+++ b/geddes/k8s/03-web.yaml
@@ -10,6 +10,8 @@ spec:
app: dt-web
template:
metadata:
+ annotations:
+ dt.purdue.edu/saml-rollout: "11853f3"
labels:
app: dt-web
spec:
@@ -24,7 +26,7 @@ spec:
containers:
- name: dt-web
# Notice we are using the 'sdx' namespace on the registry now
- image: geddes-registry.rcac.purdue.edu/sdx/dt-web:recipe-compare-4977487@sha256:8de26ecf0b5e4a16dfe87944dfd569a087f5289ab795c2e317245246808c0738
+ image: geddes-registry.rcac.purdue.edu/sdx/dt-web:saml-11853f3@sha256:951138bff0249f8c80c4b41bc459c918c7ff8c556645ad25276dfab94725e802
imagePullPolicy: Always
resources:
requests:
@@ -38,6 +40,14 @@ spec:
envFrom:
- secretRef:
name: dt-web-secret
+ volumeMounts:
+ - name: saml-certificates
+ mountPath: /var/run/secrets/dt-saml
+ readOnly: true
+ volumes:
+ - name: saml-certificates
+ secret:
+ secretName: dt-saml-secret
---
apiVersion: v1
diff --git a/web/SAML_AUTHENTICATION.md b/web/SAML_AUTHENTICATION.md
new file mode 100644
index 0000000..b9f57aa
--- /dev/null
+++ b/web/SAML_AUTHENTICATION.md
@@ -0,0 +1,69 @@
+# nanoHUB SAML authentication
+
+The Next.js service is the SAML 2.0 Service Provider (SP); nanoHUB is the
+Identity Provider (IdP). Auth.js remains in use only for its encrypted JWT
+session API so existing page guards and server-side API proxies do not need a
+second session implementation.
+
+## Service Provider registration
+
+- Entity ID and metadata:
+ `https://dt-nanohub.geddes.rcac.purdue.edu/api/auth/saml/metadata`
+- Assertion Consumer Service:
+ `https://dt-nanohub.geddes.rcac.purdue.edu/api/auth/saml/acs`
+- ACS binding: HTTP-POST
+- Single Logout Service:
+ `https://dt-nanohub.geddes.rcac.purdue.edu/api/auth/saml/sls`
+
+The SP signs AuthnRequests with RSA/SHA-256. Incoming responses must match the
+SP audience, nanoHUB issuer, request ID, and assertion time window. Assertions
+must be signed. Top-level Response signatures are required by default and may
+only be relaxed with `SAML_WANT_AUTHN_RESPONSE_SIGNED=false` if nanoHUB
+confirms that it signs the Assertion instead of the Response.
+
+## Required secret files
+
+Prepare these files outside Git:
+
+- `sp-private-key.pem`: DT's private SP signing key
+- `sp-certificate.pem`: matching public SP certificate sent to nanoHUB
+- `idp-certificate.pem`: nanoHUB signing certificate extracted from its IdP
+ metadata
+
+Create the Kubernetes secret directly from the files rather than writing a
+populated YAML manifest:
+
+```bash
+kubectl -n ncn-digitaltwins-zchen create secret generic dt-saml-secret \
+ --from-file=sp-private-key.pem=../geddes/k8s/saml/sp-private-key.pem \
+ --from-file=sp-certificate.pem=../geddes/k8s/saml/sp-certificate.pem \
+ --from-file=idp-certificate.pem=../geddes/k8s/saml/idp-certificate.pem \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+The deployment mounts this secret read-only at `/var/run/secrets/dt-saml`.
+The public configuration lives in `geddes/k8s/01-secrets.yaml.example`.
+Auth.js explicitly trusts the Geddes ingress host; the ingress must continue to
+overwrite forwarded host/protocol headers rather than accepting arbitrary
+client-supplied values.
+
+## Identity attributes
+
+The assertion must contain a valid email. The mapper looks for common SAML and
+LDAP attribute names for username, email, display name, and organization. Use
+the comma-separated `SAML_*_ATTRIBUTES` environment variables documented in
+`.env.local.example` when nanoHUB confirms its exact claim names.
+
+The nanoHUB username remains the preferred DT user ID. If no username
+attribute is present, the mapper uses a non-email NameID and then the local
+part of the verified email as a compatibility fallback.
+
+## Operational notes
+
+- The private SP key must never be committed or sent to nanoHUB.
+- Keep `dt-web` at one replica while Node-SAML uses its in-memory request-ID
+ cache. Before scaling horizontally, replace it with a shared cache provider.
+- A deployment restart invalidates SAML requests already in flight; users can
+ restart sign-in safely.
+- Keep the current OAuth deployment available as a rollback image until SAML
+ login, role mapping, and logout pass production smoke tests.
diff --git a/web/app/api/auth/[...nextauth]/route.ts b/web/app/api/auth/[...nextauth]/route.ts
index 6e8d1a0..82bcfc0 100644
--- a/web/app/api/auth/[...nextauth]/route.ts
+++ b/web/app/api/auth/[...nextauth]/route.ts
@@ -1,69 +1,5 @@
-// app/api/auth/[...nextauth]/route.ts
-// NextAuth.js v5 catch-all route — handles /api/auth/signin, /callback/nanohub, etc.
-//
-// IMPORTANT: We monkey-patch globalThis.fetch to intercept NanoHUB's token response
-// BEFORE oauth4webapi validates it. NanoHUB returns "scope": null which is invalid
-// per the OAuth2 spec, causing oauth4webapi to throw a strict validation error.
-// We strip the invalid null scope on the fly so oauth4webapi sees a clean response.
+// Auth.js retains the encrypted JWT session API used by SessionProvider. SAML
+// authentication itself is handled by the explicit /api/auth/saml/* routes.
import { handlers } from "@/auth";
-import { NextRequest } from "next/server";
-const NANOHUB_TOKEN_URL = "https://nanohub.org/developer/oauth/token";
-
-// Patch global fetch to intercept NanoHUB token responses
-const originalFetch = globalThis.fetch;
-globalThis.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
-
- const resp = await originalFetch(input, init);
-
- // Only intercept the NanoHUB token endpoint
- if (url === NANOHUB_TOKEN_URL) {
- const text = await resp.text();
- console.log("[fetch-patch] NanoHUB token raw response:", resp.status, text);
- try {
- const json = JSON.parse(text);
- // Strip the invalid null scope
- if (json.scope === null) {
- delete json.scope;
- console.log("[fetch-patch] Removed null scope from token response");
- }
- return new Response(JSON.stringify(json), {
- status: resp.status,
- statusText: resp.statusText,
- headers: { "Content-Type": "application/json" },
- });
- } catch (e) {
- console.error("[fetch-patch] Failed to parse token response:", e);
- return new Response(text, {
- status: resp.status,
- statusText: resp.statusText,
- headers: resp.headers,
- });
- }
- }
-
- return resp;
-} as typeof fetch;
-
-const nextAuthGet = handlers.GET as (req: NextRequest) => Promise;
-
-export const GET = async (req: NextRequest) => {
- try {
- const url = new URL(req.url);
- if (url.pathname === "/api/auth/callback/nanohub") {
- // Strip the dummy state NanoHUB returns — oauth4webapi rejects
- // any state param when checks are disabled
- if (url.searchParams.has("state")) {
- url.searchParams.delete("state");
- const modifiedReq = new NextRequest(url.href, req);
- return nextAuthGet(modifiedReq);
- }
- }
- } catch (err) {
- console.error("[Route Interceptor] ERROR:", err);
- }
- return nextAuthGet(req);
-};
-
-export const { POST } = handlers;
+export const { GET, POST } = handlers;
diff --git a/web/app/api/auth/saml/acs/route.ts b/web/app/api/auth/saml/acs/route.ts
new file mode 100644
index 0000000..88d323c
--- /dev/null
+++ b/web/app/api/auth/saml/acs/route.ts
@@ -0,0 +1,88 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { syncPlatformIdentity } from "@/auth";
+import { getSamlClient, getSamlRuntimeConfig } from "@/lib/saml/config";
+import { mapSamlProfile } from "@/lib/saml/profile";
+import { readRelayState } from "@/lib/saml/relay-state";
+import {
+ createSamlSessionToken,
+ setSamlSessionCookie,
+} from "@/lib/saml/session";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const MAX_SAML_RESPONSE_LENGTH = 2 * 1024 * 1024;
+
+function loginErrorResponse(baseUrl: string): NextResponse {
+ const url = new URL("/login", baseUrl);
+ url.searchParams.set("error", "SAMLAuthenticationFailed");
+ return NextResponse.redirect(url, 303);
+}
+
+export async function POST(request: NextRequest) {
+ let baseUrl: string;
+ try {
+ baseUrl = getSamlRuntimeConfig().baseUrl;
+ } catch (error: unknown) {
+ console.error(
+ "[saml] ACS configuration error:",
+ error instanceof Error ? error.message : String(error),
+ );
+ return NextResponse.json(
+ { detail: "SAML authentication is not configured" },
+ { status: 503 },
+ );
+ }
+
+ try {
+ const form = await request.formData();
+ const samlResponse = form.get("SAMLResponse");
+ const relayState = form.get("RelayState");
+ if (
+ typeof samlResponse !== "string" ||
+ !samlResponse ||
+ samlResponse.length > MAX_SAML_RESPONSE_LENGTH
+ ) {
+ throw new Error("Missing or oversized SAMLResponse");
+ }
+
+ const result = await getSamlClient().validatePostResponseAsync({
+ SAMLResponse: samlResponse,
+ });
+ if (result.loggedOut || !result.profile) {
+ throw new Error("The SAML response does not contain an identity assertion");
+ }
+
+ const config = getSamlRuntimeConfig();
+ if (result.profile.issuer !== config.idpEntityId) {
+ throw new Error("The SAML assertion issuer is not nanoHUB");
+ }
+
+ const identity = await syncPlatformIdentity(mapSamlProfile(result.profile));
+ const token = await createSamlSessionToken(identity);
+ const callbackUrl = readRelayState(
+ typeof relayState === "string" ? relayState : undefined,
+ );
+ const response = NextResponse.redirect(
+ new URL(callbackUrl, config.baseUrl),
+ 303,
+ );
+ setSamlSessionCookie(response, token);
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+ } catch (error: unknown) {
+ console.error(
+ "[saml] ACS rejected response:",
+ error instanceof Error ? error.message : String(error),
+ );
+ return loginErrorResponse(baseUrl);
+ }
+}
+
+export function GET() {
+ return NextResponse.json(
+ { detail: "The SAML ACS accepts HTTP POST only" },
+ { status: 405, headers: { Allow: "POST" } },
+ );
+}
diff --git a/web/app/api/auth/saml/login/route.ts b/web/app/api/auth/saml/login/route.ts
new file mode 100644
index 0000000..273ab0f
--- /dev/null
+++ b/web/app/api/auth/saml/login/route.ts
@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { getSamlClient } from "@/lib/saml/config";
+import { createRelayState, safeInternalPath } from "@/lib/saml/relay-state";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ try {
+ const callbackUrl = safeInternalPath(
+ request.nextUrl.searchParams.get("callbackUrl"),
+ );
+ const relayState = createRelayState(callbackUrl);
+ const redirectUrl = await getSamlClient().getAuthorizeUrlAsync(
+ relayState,
+ request.headers.get("host") || undefined,
+ {},
+ );
+ const response = NextResponse.redirect(redirectUrl, 302);
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+ } catch (error: unknown) {
+ console.error(
+ "[saml] Unable to start sign-in:",
+ error instanceof Error ? error.message : String(error),
+ );
+ return NextResponse.json(
+ { detail: "SAML authentication is not configured" },
+ { status: 503 },
+ );
+ }
+}
diff --git a/web/app/api/auth/saml/logout/route.ts b/web/app/api/auth/saml/logout/route.ts
new file mode 100644
index 0000000..697883f
--- /dev/null
+++ b/web/app/api/auth/saml/logout/route.ts
@@ -0,0 +1,60 @@
+import type { Profile } from "@node-saml/node-saml";
+import { NextRequest, NextResponse } from "next/server";
+
+import { getSamlClient, getSamlRuntimeConfig } from "@/lib/saml/config";
+import { createRelayState } from "@/lib/saml/relay-state";
+import {
+ clearSamlSessionCookie,
+ readSamlSessionToken,
+} from "@/lib/saml/session";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function localLogout(baseUrl: string): NextResponse {
+ const response = NextResponse.redirect(new URL("/login", baseUrl), 302);
+ clearSamlSessionCookie(response);
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+}
+
+export async function GET(request: NextRequest) {
+ let baseUrl = request.nextUrl.origin;
+ try {
+ const config = getSamlRuntimeConfig();
+ baseUrl = config.baseUrl;
+ const token = await readSamlSessionToken(request);
+ if (!token || typeof token.samlNameId !== "string") {
+ return localLogout(baseUrl);
+ }
+
+ const profile: Profile = {
+ issuer: config.idpEntityId,
+ nameID: token.samlNameId,
+ nameIDFormat:
+ typeof token.samlNameIdFormat === "string"
+ ? token.samlNameIdFormat
+ : config.identifierFormat ||
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
+ sessionIndex:
+ typeof token.samlSessionIndex === "string"
+ ? token.samlSessionIndex
+ : undefined,
+ };
+ const redirectUrl = await getSamlClient().getLogoutUrlAsync(
+ profile,
+ createRelayState("/login"),
+ {},
+ );
+ const response = NextResponse.redirect(redirectUrl, 302);
+ clearSamlSessionCookie(response);
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+ } catch (error: unknown) {
+ console.error(
+ "[saml] Remote logout failed; completing local logout:",
+ error instanceof Error ? error.message : String(error),
+ );
+ return localLogout(baseUrl);
+ }
+}
diff --git a/web/app/api/auth/saml/metadata/route.ts b/web/app/api/auth/saml/metadata/route.ts
new file mode 100644
index 0000000..bfe2cfe
--- /dev/null
+++ b/web/app/api/auth/saml/metadata/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from "next/server";
+
+import {
+ buildServiceProviderMetadata,
+ SamlConfigurationError,
+} from "@/lib/saml/config";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ try {
+ return new NextResponse(buildServiceProviderMetadata(), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/samlmetadata+xml; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ } catch (error: unknown) {
+ const message =
+ error instanceof SamlConfigurationError
+ ? error.message
+ : "Unable to generate SAML metadata";
+ console.error("[saml] Metadata configuration error:", message);
+ return NextResponse.json(
+ { detail: "SAML authentication is not configured" },
+ { status: 503 },
+ );
+ }
+}
diff --git a/web/app/api/auth/saml/sls/route.ts b/web/app/api/auth/saml/sls/route.ts
new file mode 100644
index 0000000..d1e8840
--- /dev/null
+++ b/web/app/api/auth/saml/sls/route.ts
@@ -0,0 +1,97 @@
+import type { Profile } from "@node-saml/node-saml";
+import { NextRequest, NextResponse } from "next/server";
+
+import { getSamlClient, getSamlRuntimeConfig } from "@/lib/saml/config";
+import { readRelayState } from "@/lib/saml/relay-state";
+import { clearSamlSessionCookie } from "@/lib/saml/session";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function completedLogout(baseUrl: string, relayState?: string): NextResponse {
+ const destination = readRelayState(relayState) || "/login";
+ const response = NextResponse.redirect(new URL(destination, baseUrl), 303);
+ clearSamlSessionCookie(response);
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+}
+
+async function logoutResponseForRequest(
+ profile: Profile,
+ relayState: string,
+): Promise {
+ const redirectUrl = await getSamlClient().getLogoutResponseUrlAsync(
+ profile,
+ relayState,
+ {},
+ true,
+ );
+ const response = NextResponse.redirect(redirectUrl, 303);
+ clearSamlSessionCookie(response);
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+}
+
+function invalidLogout(error: unknown): NextResponse {
+ console.error(
+ "[saml] Rejected logout message:",
+ error instanceof Error ? error.message : String(error),
+ );
+ return NextResponse.json({ detail: "Invalid SAML logout message" }, { status: 400 });
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const params = request.nextUrl.searchParams;
+ const samlRequest = params.get("SAMLRequest");
+ const samlResponse = params.get("SAMLResponse");
+ const signature = params.get("Signature");
+ const signatureAlgorithm = params.get("SigAlg");
+ if ((!samlRequest && !samlResponse) || !signature || !signatureAlgorithm) {
+ throw new Error("Signed SAMLRequest or SAMLResponse is required");
+ }
+
+ const container = Object.fromEntries(params.entries());
+ const rawQuery = request.url.split("?", 2)[1] || "";
+ const result = await getSamlClient().validateRedirectAsync(
+ container,
+ rawQuery,
+ );
+ const relayState = params.get("RelayState") || "";
+ if (samlRequest && result.profile) {
+ return logoutResponseForRequest(result.profile, relayState);
+ }
+ if (!result.loggedOut) throw new Error("Unexpected SAML logout result");
+ return completedLogout(getSamlRuntimeConfig().baseUrl, relayState);
+ } catch (error: unknown) {
+ return invalidLogout(error);
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const form = await request.formData();
+ const samlRequest = form.get("SAMLRequest");
+ const samlResponse = form.get("SAMLResponse");
+ const relayStateValue = form.get("RelayState");
+ const relayState =
+ typeof relayStateValue === "string" ? relayStateValue : "";
+
+ if (typeof samlRequest === "string" && samlRequest) {
+ const result = await getSamlClient().validatePostRequestAsync({
+ SAMLRequest: samlRequest,
+ });
+ return logoutResponseForRequest(result.profile, relayState);
+ }
+ if (typeof samlResponse === "string" && samlResponse) {
+ const result = await getSamlClient().validatePostResponseAsync({
+ SAMLResponse: samlResponse,
+ });
+ if (!result.loggedOut) throw new Error("Unexpected SAML logout result");
+ return completedLogout(getSamlRuntimeConfig().baseUrl, relayState);
+ }
+ throw new Error("SAMLRequest or SAMLResponse is required");
+ } catch (error: unknown) {
+ return invalidLogout(error);
+ }
+}
diff --git a/web/app/auth-proxy/route.ts b/web/app/auth-proxy/route.ts
deleted file mode 100644
index e6a2dcc..0000000
--- a/web/app/auth-proxy/route.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { NextResponse } from "next/server";
-
-/**
- * Proxy route to intercept NextAuth's authorization redirect.
- *
- * Auth.js v5 forces PKCE (code_challenge) and uses very long JWE strings for the 'state' parameter.
- * NanoHUB's older HUBzero OAuth implementation throws a 400 Bad Request if it encounters
- * PKCE parameters OR if the 'state' parameter is too long.
- *
- * This proxy:
- * 1. Strips PKCE parameters.
- * 2. Replaces the long state with a short dummy state (so NanoHUB doesn't crash).
- * 3. Saves the original long state in a cookie.
- *
- * The companion middleware.ts restores the original state on the callback so NextAuth validates it.
- */
-export async function GET(request: Request) {
- const url = new URL(request.url);
- const target = new URL("https://nanohub.org/developer/oauth/authorize");
-
- url.searchParams.forEach((value, key) => {
- // Strip PKCE and scope parameters
- // NanoHUB throws 400 if unsupported scopes like profile/email are present
- if (key === "code_challenge" || key === "code_challenge_method" || key === "scope") {
- return;
- }
-
- // Discard any state NextAuth might have generated (if checks were re-enabled)
- if (key === "state") {
- return;
- }
-
- target.searchParams.set(key, value);
- });
-
- // NanoHUB strictly requires a state parameter, but NextAuth no longer validates it
- // (we disabled state checks in auth.ts to avoid cross-domain proxy cookie mismatches).
- // Therefore, we just send a static dummy state to appease NanoHUB's older OAuth implementation.
- target.searchParams.set("state", "nanohub_dummy_state");
-
- return NextResponse.redirect(target.toString());
-}
diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx
index dc28007..88ac3f6 100644
--- a/web/app/login/page.tsx
+++ b/web/app/login/page.tsx
@@ -1,7 +1,6 @@
"use client";
import Image from "next/image";
-import { signIn } from "next-auth/react";
import { useState } from "react";
import { Atom, LogIn } from "lucide-react";
@@ -10,7 +9,12 @@ export default function LoginPage() {
const handleSignIn = async () => {
setLoading(true);
- await signIn("nanohub", { callbackUrl: "/" });
+ const callbackUrl = new URLSearchParams(window.location.search).get(
+ "callbackUrl",
+ );
+ const target = new URL("/api/auth/saml/login", window.location.origin);
+ target.searchParams.set("callbackUrl", callbackUrl || "/");
+ window.location.assign(target.toString());
};
return (
@@ -107,7 +111,7 @@ export default function LoginPage() {
- Uses your existing{" "}
+ Secure sign-in using your existing{" "}
nanohub.org account.
No new account needed — Purdue BoilerKey works via NanoHUB.
diff --git a/web/app/settings/page.tsx b/web/app/settings/page.tsx
index 9c095eb..00231b2 100644
--- a/web/app/settings/page.tsx
+++ b/web/app/settings/page.tsx
@@ -85,7 +85,7 @@ export default function SettingsPage() {