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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 2 additions & 2 deletions DEMO_DRY_RUN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down Expand Up @@ -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 SAMLencrypted 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`. |
Expand Down
12 changes: 6 additions & 6 deletions DIGITAL_TWIN_PLATFORM_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ flowchart LR
AzFn -->|POST /runs/sync<br/>X-Ingestion-Token| Api
Op -->|browser| Web
Web -->|NextAuth OAuth| NH
Web -->|SAML 2.0| NH
Web -->|REST + headers| Api
Api <-->|RLS-scoped| Db
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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}
Expand All @@ -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`.
Expand Down
13 changes: 7 additions & 6 deletions NextJSAppDescriptionAndTestingDocument.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions PRODUCTION_READINESS_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion api/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
15 changes: 12 additions & 3 deletions geddes/k8s/01-secrets.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions geddes/k8s/01a-saml-secret.yaml.example
Original file line number Diff line number Diff line change
@@ -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-----
12 changes: 11 additions & 1 deletion geddes/k8s/03-web.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ spec:
app: dt-web
template:
metadata:
annotations:
dt.purdue.edu/saml-rollout: "11853f3"
labels:
app: dt-web
spec:
Expand All @@ -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:
Expand All @@ -38,6 +40,14 @@ spec:
envFrom:
- secretRef:
name: dt-web-secret
volumeMounts:
- name: saml-certificates
mountPath: /var/run/secrets/dt-saml
readOnly: true
volumes:
- name: saml-certificates
secret:
secretName: dt-saml-secret

---
apiVersion: v1
Expand Down
69 changes: 69 additions & 0 deletions web/SAML_AUTHENTICATION.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 3 additions & 67 deletions web/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<Response>;

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;
Loading