From 11853f339d9ae8a40421cf7717302332fe38caf2 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Wed, 2 Sep 2026 16:58:09 -0400 Subject: [PATCH 1/2] Add nanoHUB SAML authentication --- .gitignore | 3 + DEMO_DRY_RUN.md | 4 +- DIGITAL_TWIN_PLATFORM_OVERVIEW.md | 12 +- NextJSAppDescriptionAndTestingDocument.md | 13 +- PRODUCTION_READINESS_PLAN.md | 6 +- README.md | 2 +- api/security.py | 4 +- geddes/k8s/01-secrets.yaml.example | 15 +- geddes/k8s/01a-saml-secret.yaml.example | 21 + geddes/k8s/03-web.yaml | 8 + web/SAML_AUTHENTICATION.md | 69 ++ web/app/api/auth/[...nextauth]/route.ts | 70 +- web/app/api/auth/saml/acs/route.ts | 88 ++ web/app/api/auth/saml/login/route.ts | 33 + web/app/api/auth/saml/logout/route.ts | 60 ++ web/app/api/auth/saml/metadata/route.ts | 32 + web/app/api/auth/saml/sls/route.ts | 97 +++ web/app/auth-proxy/route.ts | 42 - web/app/login/page.tsx | 10 +- web/app/settings/page.tsx | 2 +- web/auth.ts | 436 +++------- web/components/sidebar.tsx | 17 +- web/lib/auth-context.tsx | 3 +- web/lib/saml/config.ts | 272 ++++++ web/lib/saml/profile.ts | 176 ++++ web/lib/saml/relay-state.ts | 71 ++ web/lib/saml/session.ts | 77 ++ web/package-lock.json | 971 ++++++++++++++-------- web/package.json | 5 +- web/proxy.ts | 5 +- web/types/next-auth.d.ts | 9 + 31 files changed, 1814 insertions(+), 819 deletions(-) create mode 100644 geddes/k8s/01a-saml-secret.yaml.example create mode 100644 web/SAML_AUTHENTICATION.md create mode 100644 web/app/api/auth/saml/acs/route.ts create mode 100644 web/app/api/auth/saml/login/route.ts create mode 100644 web/app/api/auth/saml/logout/route.ts create mode 100644 web/app/api/auth/saml/metadata/route.ts create mode 100644 web/app/api/auth/saml/sls/route.ts delete mode 100644 web/app/auth-proxy/route.ts create mode 100644 web/lib/saml/config.ts create mode 100644 web/lib/saml/profile.ts create mode 100644 web/lib/saml/relay-state.ts create mode 100644 web/lib/saml/session.ts 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..6ebf557 100644 --- a/geddes/k8s/03-web.yaml +++ b/geddes/k8s/03-web.yaml @@ -38,6 +38,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() {

Auth -

NanoHUB OAuth2 + Postgres RBAC

+

NanoHUB SAML + Postgres RBAC

API diff --git a/web/auth.ts b/web/auth.ts index f1facd2..9599d25 100644 --- a/web/auth.ts +++ b/web/auth.ts @@ -1,39 +1,29 @@ /** - * auth.ts — NextAuth.js v5 (Auth.js) configuration + * Auth.js session configuration. * - * Uses NanoHUB's OAuth2 endpoints directly — no Auth0 or third-party broker. - * NanoHUB acts as the identity provider; this app is the OAuth2 client. + * Authentication is performed by the SAML routes under /api/auth/saml. The + * validated ACS handler issues an Auth.js-compatible encrypted JWT so the rest + * of the application can keep using auth(), useSession(), and getToken(). */ import NextAuth from "next-auth"; -import type { Provider } from "next-auth/providers"; -import type { Session } from "next-auth"; +import type { Session, User } from "next-auth"; import type { JWT } from "next-auth/jwt"; -import * as util from "util"; import { MissingServerEnvError, requireServerEnv } from "@/lib/server-env"; -type PlatformRole = "admin" | "pi" | "equipment_owner" | "researcher"; +export type PlatformRole = "admin" | "pi" | "equipment_owner" | "researcher"; -interface NanoHubUserInfoProfile { - id?: number | string; - uidNumber?: number | string; - username?: string; - name?: string; - email?: string; -} - -interface NanoHubCurrentUserResponse { - profile?: NanoHubUserInfoProfile; -} - -interface NanoHubSessionUser { +export interface PlatformIdentity { id: string; name: string; email: string; role: PlatformRole; organization: string; nanohubUsername?: string; + samlNameId?: string; + samlNameIdFormat?: string; + samlSessionIndex?: string; } interface RoleSyncResponse { @@ -42,13 +32,6 @@ interface RoleSyncResponse { organization: string; } -interface LoggerError { - cause?: { - err?: unknown; - }; - stack?: string; -} - const DEFAULT_ORGANIZATION = "Purdue University"; const DEFAULT_ROLE: PlatformRole = "researcher"; @@ -56,7 +39,7 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isPlatformRole(role: unknown): role is PlatformRole { +export function isPlatformRole(role: unknown): role is PlatformRole { return ( role === "admin" || role === "pi" || @@ -73,326 +56,168 @@ function getDtApiBaseUrl(): string { ); } -function getDtApiBaseUrlForRelativePaths(): string { - const baseUrl = getDtApiBaseUrl(); - return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; -} - -function getDtSystemToken(): string { - return requireServerEnv("DT_SYSTEM_TOKEN"); -} - function buildRoleSyncUrl(params: { email: string; name?: string; organization?: string; userId?: string; }): string { + const baseUrl = getDtApiBaseUrl(); const url = new URL( `admin/users/${encodeURIComponent(params.email)}/role`, - getDtApiBaseUrlForRelativePaths(), + baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`, ); - if (params.name) { - url.searchParams.set("name", params.name); - } + if (params.name) url.searchParams.set("name", params.name); if (params.organization) { url.searchParams.set("organization", params.organization); } - if (params.userId) { - url.searchParams.set("user_id", params.userId); - } - + if (params.userId) url.searchParams.set("user_id", params.userId); return url.toString(); } -function coerceUser(user: unknown): Partial { - if (!user || typeof user !== "object") { - return {}; +/** Resolve the database-backed role and canonical identity for a SAML user. */ +export async function syncPlatformIdentity( + identity: Omit & { role?: PlatformRole }, +): Promise { + const response = await fetch( + buildRoleSyncUrl({ + email: identity.email, + name: identity.name, + organization: identity.organization, + userId: identity.id, + }), + { + headers: { "X-System-Token": requireServerEnv("DT_SYSTEM_TOKEN") }, + cache: "no-store", + signal: AbortSignal.timeout(5_000), + }, + ); + + if (!response.ok) { + throw new Error(`role sync failed with status ${response.status}`); } - const candidate = user as Record; + const payload = (await response.json()) as RoleSyncResponse; return { - id: typeof candidate.id === "string" ? candidate.id : undefined, - name: typeof candidate.name === "string" ? candidate.name : undefined, - email: typeof candidate.email === "string" ? candidate.email : undefined, - role: isPlatformRole(candidate.role) ? candidate.role : undefined, + ...identity, + id: payload.user_id || identity.id, + role: isPlatformRole(payload.role) ? payload.role : DEFAULT_ROLE, + organization: payload.organization || identity.organization, + }; +} + +function coerceUser(user: User | undefined): Partial { + if (!user) return {}; + return { + id: typeof user.id === "string" ? user.id : undefined, + name: typeof user.name === "string" ? user.name : undefined, + email: typeof user.email === "string" ? user.email : undefined, + role: isPlatformRole(user.role) ? user.role : undefined, organization: - typeof candidate.organization === "string" - ? candidate.organization - : undefined, + typeof user.organization === "string" ? user.organization : undefined, nanohubUsername: - typeof candidate.nanohubUsername === "string" - ? candidate.nanohubUsername + typeof user.nanohubUsername === "string" + ? user.nanohubUsername + : undefined, + samlNameId: + typeof user.samlNameId === "string" ? user.samlNameId : undefined, + samlNameIdFormat: + typeof user.samlNameIdFormat === "string" + ? user.samlNameIdFormat + : undefined, + samlSessionIndex: + typeof user.samlSessionIndex === "string" + ? user.samlSessionIndex : undefined, }; } -function extractNanoHubProfile( - payload: NanoHubCurrentUserResponse | NanoHubUserInfoProfile, -): NanoHubUserInfoProfile { - if ("profile" in payload && payload.profile) { - return payload.profile; - } - - return payload as NanoHubUserInfoProfile; -} - -const NanoHubProvider: Provider = { - id: "nanohub", - name: "NanoHUB", - type: "oauth", - authorization: { - // Auth.js v5 forces PKCE (code_challenge), which breaks NanoHUB. - // Route the authorize redirect through a local proxy that strips the parameters. - url: `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/auth-proxy`, - params: { scope: "" }, +export const { handlers, auth, signOut } = NextAuth({ + // SAML authenticates users in /api/auth/saml/acs. No Auth.js provider is + // exposed; Auth.js is retained for encrypted cookie/session compatibility. + // Geddes terminates TLS at its ingress and forwards requests to this service. + // Trusting that proxy is required for Auth.js to serve the session endpoint. + trustHost: true, + providers: [], + session: { + strategy: "jwt", + maxAge: 8 * 60 * 60, }, - token: { - url: "https://nanohub.org/developer/oauth/token", - async conform(response: Response) { - const text = await response.text(); - console.log("[NextAuth][conform] Raw token response:", text); - - try { - const data = JSON.parse(text) as Record; - if (data.scope === null) { - delete data.scope; - console.log( - "[NextAuth][conform] Removed null scope from token response", - ); - } - - return new Response(JSON.stringify(data), { - status: response.status, - headers: { "Content-Type": "application/json" }, - }); - } catch (error: unknown) { - console.error( - "[NextAuth][conform] JSON parse error:", - getErrorMessage(error), - ); - return response; - } - }, - }, - userinfo: { - url: "https://nanohub.org/developer/api/tokens", - async request({ tokens }: { tokens: { access_token?: string | null } }) { - const accessToken = tokens.access_token; - if (!accessToken) { - throw new Error("NanoHUB access token missing from callback"); - } - - console.log( - "[NextAuth] Building userinfo for token:", - `${accessToken.substring(0, 8)}...`, - ); - - try { - const response = await fetch( - "https://nanohub.org/api/v1.1/members/currentuser", - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }, - ); - console.log("[NextAuth] currentuser endpoint:", response.status); - - if (response.ok) { - const payload = - (await response.json()) as NanoHubCurrentUserResponse | NanoHubUserInfoProfile; - const profile = extractNanoHubProfile(payload); - - console.log( - "[NextAuth] currentuser data:", - JSON.stringify(profile).substring(0, 600), - ); - - if (profile.username || profile.name || profile.id) { - return { - sub: profile.id ?? profile.uidNumber ?? profile.username ?? accessToken, - name: profile.name ?? profile.username ?? "NanoHUB User", - username: profile.username, - email: profile.email ?? `${profile.username ?? "user"}@nanohub.org`, - }; - } - } else { - const errText = await response.text(); - console.log( - "[NextAuth] currentuser error text:", - errText.substring(0, 200), - ); - } - } catch (error: unknown) { - console.log( - "[NextAuth] currentuser lookup failed:", - getErrorMessage(error), - ); - } - - return { - sub: accessToken, - name: "NanoHUB User", - email: `${accessToken.substring(0, 8)}@nanohub.org`, - username: accessToken.substring(0, 8), - }; - }, - }, - clientId: process.env.NANOHUB_CLIENT_ID, - clientSecret: process.env.NANOHUB_CLIENT_SECRET, - checks: [], - client: { token_endpoint_auth_method: "client_secret_post" }, - profile(profile: { - sub?: string | number; - id?: string | number; - uidNumber?: string | number; - username?: string; - name?: string; - displayName?: string; - email?: string; - }) { - console.log( - "[NextAuth] profile() called with:", - JSON.stringify(profile).substring(0, 200), - ); - - return { - id: String( - profile.sub ?? profile.id ?? profile.uidNumber ?? profile.username, - ), - name: profile.name ?? profile.displayName ?? profile.username ?? "NanoHUB User", - email: profile.email ?? `${profile.username ?? "user"}@purdue.edu`, - role: DEFAULT_ROLE, - organization: DEFAULT_ORGANIZATION, - nanohubUsername: profile.username, - } satisfies NanoHubSessionUser; - }, -}; - -export const { handlers, auth, signIn, signOut } = NextAuth({ - providers: [NanoHubProvider], - session: { strategy: "jwt" }, pages: { signIn: "/login", error: "/login", }, - logger: { - error(err: unknown) { - console.log("\n[NextAuth][error][DEEP_DUMP]", err); - - const typedError = err as LoggerError; - if (typedError?.cause) { - console.log("[NextAuth][error][CAUSE]", typedError.cause); - if (typedError.cause.err) { - console.log("[NextAuth][error][CAUSE.err]", typedError.cause.err); - } - } - if (typedError?.stack) { - console.log("[NextAuth][error][STACK]\n", typedError.stack); - } - }, - warn(code: string) { - console.warn("[NextAuth][warn]", code); - }, - debug(code: string, ...message: unknown[]) { - const rendered = message.map((entry) => - typeof entry === "object" - ? util.inspect(entry, { depth: 3 }) - : String(entry), - ); - console.log("[NextAuth][debug]", code, rendered); - }, - }, callbacks: { async jwt({ token, user }) { const authUser = coerceUser(user); - const resolvedEmail = authUser.email ?? token.email ?? undefined; - const resolvedName = authUser.name ?? token.name ?? undefined; - const resolvedUsername = - authUser.nanohubUsername ?? - (typeof token.nanohubUsername === "string" - ? token.nanohubUsername - : undefined); - const resolvedUserId = - resolvedUsername ?? + const email = authUser.email ?? token.email ?? undefined; + const name = authUser.name ?? token.name ?? undefined; + const userId = authUser.id ?? (typeof token.id === "string" ? token.id : undefined) ?? - resolvedEmail; - const resolvedOrganization = + email; + const organization = authUser.organization ?? (typeof token.organization === "string" ? token.organization - : undefined) ?? - DEFAULT_ORGANIZATION; - - if (resolvedUserId) { - token.id = resolvedUserId; - } - if (resolvedUsername) { - token.nanohubUsername = resolvedUsername; - } + : DEFAULT_ORGANIZATION); - if (!resolvedEmail) { - token.role = - isPlatformRole(token.role) ? token.role : DEFAULT_ROLE; - token.organization = - typeof token.organization === "string" - ? token.organization - : DEFAULT_ORGANIZATION; + if (!email || !userId) { + token.role = isPlatformRole(token.role) ? token.role : DEFAULT_ROLE; + token.organization = organization; return token; } try { - const syncUrl = buildRoleSyncUrl({ - email: resolvedEmail, - name: resolvedName, - organization: resolvedOrganization, - userId: resolvedUserId, - }); - console.log(`[NextAuth] Syncing role from DT-API: ${syncUrl}`); - - const response = await fetch(syncUrl, { - headers: { "X-System-Token": getDtSystemToken() }, - cache: "no-store", + const identity = await syncPlatformIdentity({ + id: userId, + email, + name: name || email, + organization, + role: isPlatformRole(token.role) ? token.role : DEFAULT_ROLE, + nanohubUsername: + authUser.nanohubUsername ?? + (typeof token.nanohubUsername === "string" + ? token.nanohubUsername + : undefined), + samlNameId: + authUser.samlNameId ?? + (typeof token.samlNameId === "string" + ? token.samlNameId + : undefined), + samlNameIdFormat: + authUser.samlNameIdFormat ?? + (typeof token.samlNameIdFormat === "string" + ? token.samlNameIdFormat + : undefined), + samlSessionIndex: + authUser.samlSessionIndex ?? + (typeof token.samlSessionIndex === "string" + ? token.samlSessionIndex + : undefined), }); - if (!response.ok) { - throw new Error(`role sync failed with status ${response.status}`); - } - - const payload = (await response.json()) as RoleSyncResponse; - token.id = payload.user_id || resolvedUserId; - token.role = isPlatformRole(payload.role) ? payload.role : DEFAULT_ROLE; - token.organization = payload.organization || DEFAULT_ORGANIZATION; - console.log(`[NextAuth] Mapped to role: ${token.role}`); + token.id = identity.id; + token.role = identity.role; + token.organization = identity.organization; + token.nanohubUsername = identity.nanohubUsername; + token.samlNameId = identity.samlNameId; + token.samlNameIdFormat = identity.samlNameIdFormat; + token.samlSessionIndex = identity.samlSessionIndex; } catch (error: unknown) { - if (error instanceof MissingServerEnvError) { - throw error; - } - console.error( - "[NextAuth] Backend role sync failed, falling back to existing token values:", - getErrorMessage(error), - ); - token.role = - isPlatformRole(token.role) ? token.role : DEFAULT_ROLE; - token.organization = - typeof token.organization === "string" - ? token.organization - : resolvedOrganization; + if (error instanceof MissingServerEnvError) throw error; + // Existing signed sessions remain usable through short backend outages. + // A new SAML session cannot reach this state because the ACS fails closed. + console.error("[auth] Role sync failed:", getErrorMessage(error)); + token.id = userId; + token.role = isPlatformRole(token.role) ? token.role : DEFAULT_ROLE; + token.organization = organization; } return token; }, async session({ session, token }: { session: Session; token: JWT }) { - // Zero-Surprise Demo Mode: forcibly become ngholiza so local dev can - // exercise the platform without a real NanoHUB sign-in. - // - // Hard-gated on NODE_ENV !== "production" so a stray DEMO_MODE=true in - // a production secret can never silently hand every visitor a "pi" - // session for ngholiza. Production builds ignore the flag entirely. if ( process.env.NODE_ENV !== "production" && process.env.DEMO_MODE === "true" @@ -403,7 +228,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ email: "ngholiza@purdue.edu", role: "pi", organization: "Birck Nanotechnology Center", - nanohubUsername: "ngholiza" + nanohubUsername: "ngholiza", }; return session; } @@ -413,8 +238,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ typeof token.id === "string" ? token.id : session.user.email ?? "nanohub-user"; - session.user.role = - isPlatformRole(token.role) ? token.role : DEFAULT_ROLE; + session.user.role = isPlatformRole(token.role) + ? token.role + : DEFAULT_ROLE; session.user.organization = typeof token.organization === "string" ? token.organization @@ -423,8 +249,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (typeof token.nanohubUsername === "string") { session.user.nanohubUsername = token.nanohubUsername; } + if (typeof token.samlNameId === "string") { + session.user.samlNameId = token.samlNameId; + } + if (typeof token.samlNameIdFormat === "string") { + session.user.samlNameIdFormat = token.samlNameIdFormat; + } + if (typeof token.samlSessionIndex === "string") { + session.user.samlSessionIndex = token.samlSessionIndex; + } } - return session; }, }, diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx index 6c03127..40e25fa 100644 --- a/web/components/sidebar.tsx +++ b/web/components/sidebar.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import { ROLE_LABELS, ROLE_COLORS, useAuth } from "@/lib/auth-context"; -import { signOut } from "next-auth/react"; import { LayoutDashboard, Cpu, @@ -195,13 +194,15 @@ export function Sidebar() { {/* Sign Out */} {isAuthenticated && ( - +
+ +
)} {/* User info */} diff --git a/web/lib/auth-context.tsx b/web/lib/auth-context.tsx index 1c5cfc3..f43adea 100644 --- a/web/lib/auth-context.tsx +++ b/web/lib/auth-context.tsx @@ -7,7 +7,7 @@ * uses internally: { user, permissions, hasPermission }. * * Auth flow contract: - * - The server-side middleware (web/middleware.ts) is the primary gate; + * - The server-side proxy (web/proxy.ts) is the primary gate; * anonymous requests to protected routes are redirected to /login * before this provider ever runs. * - This provider runs on the client AFTER the middleware has confirmed a @@ -183,7 +183,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (DEMO_MODE_ALLOWED) return; if (status !== "unauthenticated") return; if (pathname === "/login") return; - if (pathname?.startsWith("/auth-proxy")) return; const callback = encodeURIComponent(pathname || "/"); router.replace(`/login?callbackUrl=${callback}`); }, [status, pathname, router]); diff --git a/web/lib/saml/config.ts b/web/lib/saml/config.ts new file mode 100644 index 0000000..6443058 --- /dev/null +++ b/web/lib/saml/config.ts @@ -0,0 +1,272 @@ +import "server-only"; + +import { createPublicKey, X509Certificate } from "crypto"; +import { readFileSync } from "fs"; + +import { SAML, ValidateInResponseTo } from "@node-saml/node-saml"; + +import { requireServerEnv } from "@/lib/server-env"; + +const EMAIL_NAME_ID_FORMAT = + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; +const REDIRECT_BINDING = + "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; +const POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + +export class SamlConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "SamlConfigurationError"; + } +} + +export interface SamlRuntimeConfig { + baseUrl: string; + entityId: string; + metadataUrl: string; + acsUrl: string; + logoutUrl: string; + idpEntityId: string; + idpSsoUrl: string; + idpSloUrl: string; + spPrivateKey: string; + spCertificate: string; + idpCertificates: string[]; + identifierFormat: string | null; + wantAuthnResponseSigned: boolean; +} + +function boolFromEnv(name: string, fallback: boolean): boolean { + const value = process.env[name]?.trim().toLowerCase(); + if (!value) return fallback; + if (value === "true") return true; + if (value === "false") return false; + throw new SamlConfigurationError(`${name} must be true or false`); +} + +function readSecret(inlineName: string, pathName: string): string { + const inline = process.env[inlineName]?.trim(); + if (inline) return inline.replace(/\\n/g, "\n"); + + const path = process.env[pathName]?.trim(); + if (!path) { + throw new SamlConfigurationError( + `${inlineName} or ${pathName} must be configured`, + ); + } + + try { + return readFileSync(path, "utf8").trim(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new SamlConfigurationError(`Unable to read ${pathName}: ${message}`); + } +} + +function certificatePem(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed.includes("-----BEGIN CERTIFICATE-----")) return trimmed; + if (/^[A-Za-z0-9+/=\s]+$/.test(trimmed)) { + const body = trimmed.replace(/\s+/g, "").match(/.{1,64}/g)?.join("\n"); + return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----`; + } + throw new SamlConfigurationError(`${label} is not an X.509 certificate`); +} + +function certificateBundle(value: string, label: string): string[] { + const matches = value.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g, + ); + const certificates = matches?.length + ? matches.map((entry) => entry.trim()) + : [certificatePem(value, label)]; + + for (const certificate of certificates) { + try { + new X509Certificate(certificate); + } catch { + throw new SamlConfigurationError(`${label} contains an invalid certificate`); + } + } + return certificates; +} + +function assertMatchingKeyPair(privateKey: string, certificate: string): void { + try { + const privatePublicKey = createPublicKey(privateKey).export({ + format: "der", + type: "spki", + }); + const certificatePublicKey = new X509Certificate(certificate).publicKey.export( + { + format: "der", + type: "spki", + }, + ); + if (!privatePublicKey.equals(certificatePublicKey)) { + throw new SamlConfigurationError( + "The SAML SP private key does not match the SP certificate", + ); + } + } catch (error: unknown) { + if (error instanceof SamlConfigurationError) throw error; + throw new SamlConfigurationError("The SAML SP signing keypair is invalid"); + } +} + +function normalizeBaseUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new SamlConfigurationError("The SAML public base URL is invalid"); + } + + if ( + process.env.NODE_ENV === "production" && + parsed.protocol !== "https:" + ) { + throw new SamlConfigurationError( + "The production SAML public base URL must use HTTPS", + ); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +} + +function absoluteUrl(value: string | undefined, fallback: string): string { + const candidate = value?.trim() || fallback; + try { + return new URL(candidate).toString().replace(/\/$/, ""); + } catch { + throw new SamlConfigurationError(`Invalid SAML URL: ${candidate}`); + } +} + +export function getSamlRuntimeConfig(): SamlRuntimeConfig { + const configuredBaseUrl = + process.env.SAML_BASE_URL || + process.env.AUTH_URL || + process.env.NEXTAUTH_URL || + (process.env.NODE_ENV === "production" ? "" : "http://localhost:3000"); + if (!configuredBaseUrl) { + throw new SamlConfigurationError( + "SAML_BASE_URL, AUTH_URL, or NEXTAUTH_URL must be configured", + ); + } + + const baseUrl = normalizeBaseUrl(configuredBaseUrl); + const metadataUrl = absoluteUrl( + process.env.SAML_SP_METADATA_URL, + `${baseUrl}/api/auth/saml/metadata`, + ); + const acsUrl = absoluteUrl( + process.env.SAML_SP_ACS_URL, + `${baseUrl}/api/auth/saml/acs`, + ); + const logoutUrl = absoluteUrl( + process.env.SAML_SP_LOGOUT_URL, + `${baseUrl}/api/auth/saml/sls`, + ); + const spPrivateKey = readSecret( + "SAML_SP_PRIVATE_KEY", + "SAML_SP_PRIVATE_KEY_PATH", + ); + const spCertificate = certificateBundle( + readSecret("SAML_SP_CERTIFICATE", "SAML_SP_CERTIFICATE_PATH"), + "SAML_SP_CERTIFICATE", + )[0]; + const idpCertificates = certificateBundle( + readSecret("SAML_IDP_CERTIFICATE", "SAML_IDP_CERTIFICATE_PATH"), + "SAML_IDP_CERTIFICATE", + ); + assertMatchingKeyPair(spPrivateKey, spCertificate); + + const configuredFormat = process.env.SAML_NAME_ID_FORMAT?.trim(); + const identifierFormat = + configuredFormat?.toLowerCase() === "none" + ? null + : configuredFormat || EMAIL_NAME_ID_FORMAT; + + return { + baseUrl, + entityId: process.env.SAML_SP_ENTITY_ID?.trim() || metadataUrl, + metadataUrl, + acsUrl, + logoutUrl, + idpEntityId: process.env.SAML_IDP_ENTITY_ID?.trim() || "https://nanohub.org", + idpSsoUrl: absoluteUrl( + process.env.SAML_IDP_SSO_URL, + "https://nanohub.org/saml/idp/login", + ), + idpSloUrl: absoluteUrl( + process.env.SAML_IDP_SLO_URL, + "https://nanohub.org/saml/idp/logout", + ), + spPrivateKey, + spCertificate, + idpCertificates, + identifierFormat, + wantAuthnResponseSigned: boolFromEnv( + "SAML_WANT_AUTHN_RESPONSE_SIGNED", + true, + ), + }; +} + +let samlClient: SAML | undefined; + +export function getSamlClient(): SAML { + if (samlClient) return samlClient; + const config = getSamlRuntimeConfig(); + samlClient = new SAML({ + callbackUrl: config.acsUrl, + entryPoint: config.idpSsoUrl, + issuer: config.entityId, + audience: config.entityId, + idpIssuer: config.idpEntityId, + idpCert: + config.idpCertificates.length === 1 + ? config.idpCertificates[0] + : config.idpCertificates, + privateKey: config.spPrivateKey, + publicCert: config.spCertificate, + signatureAlgorithm: "sha256", + digestAlgorithm: "sha256", + identifierFormat: config.identifierFormat, + disableRequestedAuthnContext: true, + wantAssertionsSigned: true, + wantAuthnResponseSigned: config.wantAuthnResponseSigned, + validateInResponseTo: ValidateInResponseTo.always, + requestIdExpirationPeriodMs: 10 * 60 * 1000, + maxAssertionAgeMs: 10 * 60 * 1000, + acceptedClockSkewMs: 2 * 60 * 1000, + authnRequestBinding: "HTTP-Redirect", + logoutUrl: config.idpSloUrl, + logoutCallbackUrl: config.logoutUrl, + }); + return samlClient; +} + +export function buildServiceProviderMetadata(): string { + const config = getSamlRuntimeConfig(); + const metadata = getSamlClient().generateServiceProviderMetadata( + null, + config.spCertificate, + ); + + // Node-SAML currently emits HTTP-POST for SLO metadata. nanoHUB advertises + // only HTTP-Redirect for SLO, which is also what the handlers implement. + return metadata.replace( + `SingleLogoutService Binding="${POST_BINDING}"`, + `SingleLogoutService Binding="${REDIRECT_BINDING}"`, + ); +} + +export function getAuthSecret(): string { + return process.env.AUTH_SECRET?.trim() || requireServerEnv("NEXTAUTH_SECRET"); +} + +export const SAML_SESSION_MAX_AGE_SECONDS = 8 * 60 * 60; diff --git a/web/lib/saml/profile.ts b/web/lib/saml/profile.ts new file mode 100644 index 0000000..0445b78 --- /dev/null +++ b/web/lib/saml/profile.ts @@ -0,0 +1,176 @@ +import type { Profile } from "@node-saml/node-saml"; + +import type { PlatformIdentity } from "@/auth"; + +const EMAIL_NAME_ID_FORMAT = + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; +const DEFAULT_ORGANIZATION = "Purdue University"; + +const DEFAULT_USERNAME_ATTRIBUTES = [ + "username", + "uid", + "urn:oid:0.9.2342.19200300.100.1.1", + "urn:mace:dir:attribute-def:uid", +]; +const DEFAULT_EMAIL_ATTRIBUTES = [ + "email", + "mail", + "urn:oid:0.9.2342.19200300.100.1.3", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", +]; +const DEFAULT_NAME_ATTRIBUTES = [ + "displayName", + "name", + "cn", + "urn:oid:2.16.840.1.113730.3.1.241", + "urn:oid:2.5.4.3", +]; +const DEFAULT_ORGANIZATION_ATTRIBUTES = [ + "organization", + "organizationName", + "o", + "urn:oid:2.5.4.10", +]; + +export class SamlProfileError extends Error { + constructor(message: string) { + super(message); + this.name = "SamlProfileError"; + } +} + +function configuredAttributes(name: string, defaults: string[]): string[] { + const configured = process.env[name] + ?.split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + return configured?.length ? configured : defaults; +} + +function scalarString(value: unknown): string | undefined { + if (typeof value === "string") return value.trim() || undefined; + if (typeof value === "number") return String(value); + if (Array.isArray(value)) { + for (const entry of value) { + const result = scalarString(entry); + if (result) return result; + } + } + if (value && typeof value === "object") { + const candidate = value as Record; + return scalarString(candidate._ ?? candidate["#text"]); + } + return undefined; +} + +function profileAttributes(profile: Profile): Map { + const values = new Map(); + for (const [key, value] of Object.entries(profile)) { + if (typeof value !== "function") values.set(key.toLowerCase(), value); + } + + const attributes = profile.attributes; + if (attributes && typeof attributes === "object" && !Array.isArray(attributes)) { + for (const [key, value] of Object.entries( + attributes as Record, + )) { + values.set(key.toLowerCase(), value); + } + } + return values; +} + +function firstAttribute( + attributes: Map, + names: string[], +): string | undefined { + for (const name of names) { + const value = scalarString(attributes.get(name.toLowerCase())); + if (value) return value; + } + return undefined; +} + +function validEmail(value: string | undefined): string | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if ( + normalized.length > 254 || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) + ) { + return undefined; + } + return normalized; +} + +function validIdentityValue(value: string | undefined): string | undefined { + if (!value) return undefined; + const normalized = value.trim(); + if (!normalized || normalized.length > 255 || /[\u0000-\u001f\u007f]/.test(normalized)) { + return undefined; + } + return normalized; +} + +export function mapSamlProfile(profile: Profile): Omit { + const attributes = profileAttributes(profile); + const nameId = validIdentityValue(scalarString(profile.nameID)); + const nameIdFormat = scalarString(profile.nameIDFormat); + const email = validEmail( + firstAttribute( + attributes, + configuredAttributes("SAML_EMAIL_ATTRIBUTES", DEFAULT_EMAIL_ATTRIBUTES), + ) || (nameIdFormat === EMAIL_NAME_ID_FORMAT ? nameId : undefined), + ); + + if (!email) { + throw new SamlProfileError( + "The SAML assertion does not contain a valid email attribute", + ); + } + + const explicitUsername = validIdentityValue( + firstAttribute( + attributes, + configuredAttributes( + "SAML_USERNAME_ATTRIBUTES", + DEFAULT_USERNAME_ATTRIBUTES, + ), + ), + ); + const nonEmailNameId = nameId && !nameId.includes("@") ? nameId : undefined; + const username = explicitUsername || nonEmailNameId || email.split("@", 1)[0]; + + if (!username) { + throw new SamlProfileError( + "The SAML assertion does not contain a stable user identifier", + ); + } + + const displayName = validIdentityValue( + firstAttribute( + attributes, + configuredAttributes("SAML_NAME_ATTRIBUTES", DEFAULT_NAME_ATTRIBUTES), + ), + ); + const organization = validIdentityValue( + firstAttribute( + attributes, + configuredAttributes( + "SAML_ORGANIZATION_ATTRIBUTES", + DEFAULT_ORGANIZATION_ATTRIBUTES, + ), + ), + ); + + return { + id: username, + nanohubUsername: username, + email, + name: displayName || username, + organization: organization || DEFAULT_ORGANIZATION, + samlNameId: nameId, + samlNameIdFormat: nameIdFormat, + samlSessionIndex: validIdentityValue(scalarString(profile.sessionIndex)), + }; +} diff --git a/web/lib/saml/relay-state.ts b/web/lib/saml/relay-state.ts new file mode 100644 index 0000000..6622c9c --- /dev/null +++ b/web/lib/saml/relay-state.ts @@ -0,0 +1,71 @@ +import "server-only"; + +import { createHmac, timingSafeEqual } from "crypto"; + +import { getAuthSecret } from "@/lib/saml/config"; + +interface RelayPayload { + path: string; + expiresAt: number; +} + +function safeInternalPath(value: string | null | undefined): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) return "/"; + try { + const parsed = new URL(value, "https://dt.invalid"); + if (parsed.origin !== "https://dt.invalid") return "/"; + return `${parsed.pathname}${parsed.search}`; + } catch { + return "/"; + } +} + +function signature(payload: string): Buffer { + return createHmac("sha256", getAuthSecret()) + .update("dt-saml-relay-state\0") + .update(payload) + .digest(); +} + +export function createRelayState(callbackUrl: string | null | undefined): string { + const payload = Buffer.from( + JSON.stringify({ + path: safeInternalPath(callbackUrl), + expiresAt: Date.now() + 10 * 60 * 1000, + } satisfies RelayPayload), + ).toString("base64url"); + return `${payload}.${signature(payload).toString("base64url")}`; +} + +export function readRelayState(value: string | null | undefined): string { + if (!value) return "/"; + const [payload, encodedSignature, extra] = value.split("."); + if (!payload || !encodedSignature || extra) return "/"; + + try { + const received = Buffer.from(encodedSignature, "base64url"); + const expected = signature(payload); + if ( + received.length !== expected.length || + !timingSafeEqual(received, expected) + ) { + return "/"; + } + + const parsed = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as Partial; + if ( + typeof parsed.expiresAt !== "number" || + parsed.expiresAt < Date.now() || + typeof parsed.path !== "string" + ) { + return "/"; + } + return safeInternalPath(parsed.path); + } catch { + return "/"; + } +} + +export { safeInternalPath }; diff --git a/web/lib/saml/session.ts b/web/lib/saml/session.ts new file mode 100644 index 0000000..b0e85e5 --- /dev/null +++ b/web/lib/saml/session.ts @@ -0,0 +1,77 @@ +import "server-only"; + +import { encode, getToken } from "next-auth/jwt"; +import type { NextRequest, NextResponse } from "next/server"; + +import type { PlatformIdentity } from "@/auth"; +import { + getAuthSecret, + SAML_SESSION_MAX_AGE_SECONDS, +} from "@/lib/saml/config"; + +export function samlSessionCookieName(): string { + return process.env.NODE_ENV === "production" + ? "__Secure-authjs.session-token" + : "authjs.session-token"; +} + +export async function createSamlSessionToken( + identity: PlatformIdentity, +): Promise { + const cookieName = samlSessionCookieName(); + const token = await encode({ + secret: getAuthSecret(), + salt: cookieName, + maxAge: SAML_SESSION_MAX_AGE_SECONDS, + token: { + sub: identity.id, + id: identity.id, + name: identity.name, + email: identity.email, + role: identity.role, + organization: identity.organization, + nanohubUsername: identity.nanohubUsername, + samlNameId: identity.samlNameId, + samlNameIdFormat: identity.samlNameIdFormat, + samlSessionIndex: identity.samlSessionIndex, + }, + }); + + if (token.length > 3800) { + throw new Error("The SAML session token exceeds the cookie size limit"); + } + return token; +} + +export function setSamlSessionCookie( + response: NextResponse, + token: string, +): void { + response.cookies.set(samlSessionCookieName(), token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: SAML_SESSION_MAX_AGE_SECONDS, + }); +} + +export function clearSamlSessionCookie(response: NextResponse): void { + response.cookies.set(samlSessionCookieName(), "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + expires: new Date(0), + }); +} + +export async function readSamlSessionToken(request: NextRequest) { + return getToken({ + req: request, + secret: getAuthSecret(), + cookieName: samlSessionCookieName(), + secureCookie: process.env.NODE_ENV === "production", + }); +} diff --git a/web/package-lock.json b/web/package-lock.json index eb39a63..3c4bca8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,10 +8,11 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@node-saml/node-saml": "^5.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.575.0", - "next": "16.1.6", + "next": "16.3.4", "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "plotly.js": "^3.4.0", @@ -27,7 +28,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.3.4", "tailwindcss": "^4", "typescript": "^5" } @@ -46,9 +47,9 @@ } }, "node_modules/@auth/core": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", - "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -60,7 +61,7 @@ "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^6.8.0" + "nodemailer": "^7.0.7 || ^8.0.5" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -75,13 +76,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -90,9 +91,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -100,21 +101,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -131,14 +132,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -148,14 +149,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -165,9 +166,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -175,29 +176,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -207,9 +208,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -217,9 +218,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -227,9 +228,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -237,27 +238,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -267,33 +268,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -301,14 +302,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -339,9 +340,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -504,29 +505,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -556,9 +571,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -566,9 +581,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -578,19 +593,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -600,19 +615,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -626,9 +660,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -642,9 +676,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -658,9 +692,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -674,9 +708,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -690,9 +724,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -706,9 +740,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -722,9 +756,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -738,9 +772,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -754,9 +788,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -770,9 +804,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -782,19 +816,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -804,19 +838,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -826,19 +860,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -848,19 +882,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -870,19 +904,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -892,19 +926,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -914,19 +948,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -936,38 +970,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -977,16 +1027,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -996,16 +1046,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1015,7 +1065,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1189,25 +1239,26 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", + "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", - "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.4.tgz", + "integrity": "sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", + "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", "cpu": [ "arm64" ], @@ -1221,9 +1272,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", + "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", "cpu": [ "x64" ], @@ -1237,9 +1288,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", + "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", "cpu": [ "arm64" ], @@ -1253,9 +1304,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", + "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", "cpu": [ "arm64" ], @@ -1269,9 +1320,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", + "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", "cpu": [ "x64" ], @@ -1285,9 +1336,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", + "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", "cpu": [ "x64" ], @@ -1301,9 +1352,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", + "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", "cpu": [ "arm64" ], @@ -1317,9 +1368,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", + "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", "cpu": [ "x64" ], @@ -1332,6 +1383,29 @@ "node": ">= 10" } }, + "node_modules/@node-saml/node-saml": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", + "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.12", + "@types/qs": "^6.9.18", + "@types/xml-encryption": "^1.2.4", + "@types/xml2js": "^0.4.14", + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "debug": "^4.4.0", + "xml-crypto": "^6.1.2", + "xml-encryption": "^3.1.0", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1", + "xpath": "^0.0.34" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1531,9 +1605,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -1956,6 +2030,15 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2009,11 +2092,16 @@ "@types/pbf": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2025,6 +2113,12 @@ "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2060,6 +2154,24 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/xml-encryption": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", + "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -2259,26 +2371,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2624,6 +2736,24 @@ "win32" ] }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -2969,9 +3099,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3009,9 +3139,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3033,9 +3163,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -3053,11 +3183,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -3133,9 +3263,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -3700,7 +3830,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3860,9 +3989,9 @@ "license": "ISC" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -4160,6 +4289,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4255,13 +4390,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", - "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.4.tgz", + "integrity": "sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.6", + "@next/eslint-plugin-next": "16.3.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -4770,9 +4905,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -4837,9 +4972,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -6183,9 +6318,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -6198,10 +6333,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6850,9 +6995,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6916,9 +7061,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -6989,16 +7134,16 @@ } }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", + "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "@next/env": "16.3.4", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -7008,15 +7153,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.3.4", + "@next/swc-darwin-x64": "16.3.4", + "@next/swc-linux-arm64-gnu": "16.3.4", + "@next/swc-linux-arm64-musl": "16.3.4", + "@next/swc-linux-x64-gnu": "16.3.4", + "@next/swc-linux-x64-musl": "16.3.4", + "@next/swc-win32-arm64-msvc": "16.3.4", + "@next/swc-win32-x64-msvc": "16.3.4", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -7042,18 +7187,18 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.30", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", - "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", "license": "ISC", "dependencies": { - "@auth/core": "0.41.0" + "@auth/core": "0.41.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.7 || ^8.0.5", "react": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { @@ -7085,9 +7230,9 @@ "license": "ISC" }, "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -7104,9 +7249,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7132,11 +7277,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-svg-path": { "version": "0.1.0", @@ -7157,9 +7305,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", - "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7462,9 +7610,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -7558,9 +7706,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -7578,7 +7726,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7650,9 +7798,9 @@ } }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, "node_modules/punycode": { @@ -8242,54 +8390,59 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -8855,9 +9008,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -9134,7 +9287,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unquote": { @@ -9179,9 +9331,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -9472,6 +9624,89 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml-encryption": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz", + "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.5", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + } + }, + "node_modules/xml-encryption/node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index 3cfddf9..9510721 100644 --- a/web/package.json +++ b/web/package.json @@ -9,10 +9,11 @@ "lint": "eslint" }, "dependencies": { + "@node-saml/node-saml": "^5.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.575.0", - "next": "16.1.6", + "next": "16.3.4", "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "plotly.js": "^3.4.0", @@ -28,7 +29,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.3.4", "tailwindcss": "^4", "typescript": "^5" } diff --git a/web/proxy.ts b/web/proxy.ts index fbabe2c..644fda4 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -14,7 +14,7 @@ * server-side with no auth check. * * Strategy: - * - Public paths (login, OAuth handshake, NextAuth handlers, static + * - Public paths (login, SAML handshake, Auth.js session handlers, static * assets) are always allowed through. * - Everything else demands a NextAuth JWT cookie. We read it via * getToken from "next-auth/jwt", which is edge-runtime-safe and does @@ -38,8 +38,7 @@ const DEMO_MODE_ALLOWED = // Order doesn't matter; we use startsWith(). const PUBLIC_PATH_PREFIXES = [ "/login", - "/auth-proxy", // OAuth2 PKCE-stripping shim used during NanoHUB sign-in - "/api/auth", // NextAuth handlers themselves (signin, callback, csrf, ...) + "/api/auth", // SAML handshake and Auth.js session handlers "/api/mcp", // MCP endpoint handles its own Bearer token auth "/api/tools", // OpenAPI tool server handles its own Bearer token auth ]; diff --git a/web/types/next-auth.d.ts b/web/types/next-auth.d.ts index 6d0f778..49cdf51 100644 --- a/web/types/next-auth.d.ts +++ b/web/types/next-auth.d.ts @@ -9,6 +9,9 @@ declare module "next-auth" { role: PlatformRole; organization: string; nanohubUsername?: string; + samlNameId?: string; + samlNameIdFormat?: string; + samlSessionIndex?: string; }; } @@ -17,6 +20,9 @@ declare module "next-auth" { role: PlatformRole; organization: string; nanohubUsername?: string; + samlNameId?: string; + samlNameIdFormat?: string; + samlSessionIndex?: string; } } @@ -26,5 +32,8 @@ declare module "next-auth/jwt" { role?: PlatformRole; organization?: string; nanohubUsername?: string; + samlNameId?: string; + samlNameIdFormat?: string; + samlSessionIndex?: string; } } From adbc3e13789cd0661c414fe8655c62c743803cbf Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Thu, 3 Sep 2026 23:23:09 -0400 Subject: [PATCH 2/2] Record SAML deployment --- geddes/k8s/03-web.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/geddes/k8s/03-web.yaml b/geddes/k8s/03-web.yaml index 6ebf557..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: