Skip to content
Open
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
14 changes: 14 additions & 0 deletions portal/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# CILogon-style OIDC credentials. Register the client with callback URL
# http://localhost:5173/api/auth/callback/cilogon for local dev. Leave
# OIDC_ISSUER_URL blank to fall back to the local dev credentials provider.
# For public clients (no client secret), leave OIDC_CLIENT_SECRET empty.
OIDC_CLIENT_ID=dev-nexus-portal
OIDC_CLIENT_SECRET=
OIDC_ISSUER_URL=https://auth.dev.cybershuttle.org/realms/default
# OIDC scope override. Defaults to CILogon's set
# (openid profile email org.cilogon.userinfo). For Keycloak realms that don't
# advertise that custom scope, use "openid profile email".
OIDC_SCOPE=openid profile email
NEXTAUTH_URL=http://localhost:5173
NEXTAUTH_SECRET= # generate with: openssl rand -base64 32
AUTH_TRUST_HOST=true
3 changes: 3 additions & 0 deletions portal/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
19 changes: 19 additions & 0 deletions portal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.next/
node_modules/
tsconfig.tsbuildinfo
debug.log
*.log
test-results/
playwright-report/
.playwright/
.env
.env.local
.env.*.local

# Playwright/test artifacts
/test-results/
/playwright-report/
/blob-report/

# Local scratch/output files
summary.txt
105 changes: 105 additions & 0 deletions portal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Custos Portal

Next.js 15 portal for Custos SSH certificate management. Wraps the SSH
Certificate Signer service with an authenticated UI for browsing, inspecting,
and revoking certificates.

## Architecture at a glance

- **Next.js 15 App Router** with React 19, Tailwind CSS v4, and shadcn/ui
primitives (Badge, Button, Dialog, Skeleton, Table).
- **NextAuth v5 (Auth.js)** drives sign-in. Configured to talk to a CILogon-
compatible OIDC issuer (real CILogon, Keycloak, or any standards-compliant
provider) and falls back to a local "Dev" credentials provider when no
issuer is configured.
- **Server-side API proxy** at `/api/v1/[...path]` injects the session bearer
on GETs and signer client credentials on POSTs so nothing sensitive is ever
bundled into the browser.

```
Browser ── /api/v1/* ──▶ Next route ── adds auth ─▶ Signer service
▲ │
└── /api/auth/* ─────────┘
(NextAuth handlers — sign-in, callback, session)
```

Key files:

| File | Role |
| --- | --- |
| [`auth.ts`](auth.ts) | NextAuth configuration; selects between OIDC and Dev providers. |
| [`src/app/api/auth/[...nextauth]/route.ts`](src/app/api/auth/[...nextauth]/route.ts) | Mounts NextAuth handlers under `/api/auth/`. |
| [`src/app/api/v1/[...path]/route.ts`](src/app/api/v1/[...path]/route.ts) | Server-side proxy that forwards browser calls to the signer with the right credentials. |
| [`src/app/signer/`](src/app/signer/) | Certificate list, detail, and revoke flows. |
| [`src/app/layout/PortalLayout.tsx`](src/app/layout/PortalLayout.tsx) | Sidebar/header chrome and the unauthenticated → `signIn()` redirect. |

## Local setup

The dev server binds to `http://localhost:5173` (see `package.json`).

```bash
cp .env.local.example .env.local
# Fill in OIDC_CLIENT_ID, OIDC_ISSUER_URL (and OIDC_CLIENT_SECRET if you
# have one — leave blank for public/PKCE-only clients).
# Generate NEXTAUTH_SECRET: openssl rand -base64 32
npm install
npm run dev
```

Then open <http://localhost:5173>.

## OIDC authentication

The portal targets CILogon by default but accepts any OIDC-compliant issuer
through env vars. Required values in `.env.local`:

| Variable | Purpose |
| --- | --- |
| `OIDC_CLIENT_ID` | Client identifier registered at the OIDC provider. |
| `OIDC_CLIENT_SECRET` | Client secret. Leave blank for public (PKCE-only) clients. |
| `OIDC_ISSUER_URL` | Issuer base URL — e.g. `https://cilogon.org` or a Keycloak realm. |
| `OIDC_SCOPE` (optional) | Override the requested scope set. Defaults to `openid profile email org.cilogon.userinfo` (CILogon's claim set). For Keycloak realms that don't advertise `org.cilogon.userinfo`, use `openid profile email`. |
| `NEXTAUTH_URL` | Public origin Auth.js uses to build the redirect URI. Must match the scheme/host/port reachable from the browser — `http://localhost:5173` for local dev. |
| `NEXTAUTH_SECRET` | Cookie/JWT encryption key. Generate with `openssl rand -base64 32`. |
| `AUTH_TRUST_HOST` | Set to `true` so Auth.js trusts the inbound Host header outside Vercel-style deployments. |

Register this callback URL with the OIDC client for local dev:

```
http://localhost:5173/api/auth/callback/cilogon
```

The `cilogon` segment comes from the provider id in [`auth.ts`](auth.ts);
changing it is a breaking config change for any deployment that has the URL
whitelisted.

## Dev OIDC fallback

When `OIDC_ISSUER_URL` is left blank, the portal registers a local
credentials-only **Dev** provider instead of an OIDC one. Pair it with the
signer's built-in dev OIDC mode (which disables real token validation and
returns a default identity) so contributors can run the full sign-in flow
without standing up a real provider. See
[`../extensions/SSH-Certificate-Signer/README.md`](../extensions/SSH-Certificate-Signer/README.md)
for the matching `DEV_MODE` / `dev_mode.enabled` configuration.

## Signer integration

The portal never talks to the signer directly from the browser. Set these in
`.env.local` (or leave them unset to use the dev defaults baked into
[`src/lib/serverConfig.ts`](src/lib/serverConfig.ts)):

| Variable | Default | Purpose |
| --- | --- | --- |
| `SIGNER_API_BASE_URL` | `http://127.0.0.1:8084` | Base URL the proxy forwards to. |
| `SIGNER_CLIENT_ID` | `tenant1:webapp` | Sent as `X-Client-Id` on `/revoke`. |
| `SIGNER_CLIENT_SECRET` | `dev-secret` | Sent as `X-Client-Secret` on `/revoke`. |

## Scripts

- `npm run dev` — Next dev server on <http://localhost:5173>
- `npm run build` — production build
- `npm run typecheck` — `tsc --noEmit`
- `npm run lint` — `next lint`
- `npm test` — Vitest unit tests
- `npm run test:e2e` — Playwright e2e (spawns its own dev server on port 3105)
103 changes: 103 additions & 0 deletions portal/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* NextAuth (Auth.js v5) configuration for the Custos portal.
*
* Two provider configurations are possible at startup:
* - When `OIDC_ISSUER_URL` is set, a single OIDC provider is registered
* (provider id `cilogon`) and used as the only sign-in option.
* - When `OIDC_ISSUER_URL` is unset, a Credentials-based "Dev" provider is
* registered so contributors can exercise the portal locally against a
* signer service started with `DEV_MODE=true`.
*
* Anything downstream that needs the issuer's access token reads it off the
* session as `session.accessToken`, populated by the `jwt` and `session`
* callbacks below. The `/api/v1` proxy is the primary consumer.
*/
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";

const oidcConfigured = Boolean(process.env.OIDC_ISSUER_URL);

// A public OIDC client (no shared secret) authenticates the token exchange
// with PKCE instead. Detect it from the absence of `OIDC_CLIENT_SECRET` so
// the same code supports both confidential and public clients.
const isPublicClient = !process.env.OIDC_CLIENT_SECRET;

// CILogon exposes its custom userinfo claims behind the `org.cilogon.userinfo`
// scope. Other OIDC issuers (notably Keycloak realms) reject unknown scopes
// with `invalid_scope`, so the scope set is overridable via env.
const oidcScope =
process.env.OIDC_SCOPE ?? "openid profile email org.cilogon.userinfo";

const providers: NextAuthConfig["providers"] = oidcConfigured
? [
{
// The provider id is part of the callback path
// (/api/auth/callback/<id>) that must be registered at the OIDC
// provider, so changing it is a breaking change for deployments.
id: "cilogon",
name: "CILogon",
type: "oidc",
issuer: process.env.OIDC_ISSUER_URL,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
authorization: { params: { scope: oidcScope } },
// Tells openid-client to send no client credentials at the token
// endpoint. The matching client at the IdP must be configured as
// "public" (PKCE-only).
client: isPublicClient
? { token_endpoint_auth_method: "none" }
: undefined,
},
]
: [
// Dev fallback. Pairs with the signer's DEV_MODE so contributors can
// run the portal without standing up a real OIDC provider. Never
// registers when OIDC_ISSUER_URL is set.
Credentials({
id: "dev",
name: "Dev",
credentials: {},
authorize: async () => ({
id: "dev-user",
name: "Dev User",
email: process.env.DEV_DEFAULT_EMAIL ?? "dev@example.com",
}),
}),
];

export const { handlers, auth, signIn, signOut } = NextAuth({
providers,
// The portal is typically deployed behind a reverse proxy and run locally
// on a non-standard host; trust the inbound Host header rather than fixing
// a Vercel-style URL.
trustHost: true,
// Replace Auth.js's stock provider chooser and error screen with branded
// portal pages. Routing failed CILogon callbacks to /auth-error (instead
// of the default /api/auth/error) means a refresh re-renders a normal
// page where the user can retry or clear their session, rather than
// reloading the Auth.js error screen.
pages: {
signIn: "/signin",
error: "/auth-error",
},
callbacks: {
// The `account` object is only present on the first JWT pass right after
// a successful sign-in. Persist the issuer's access token onto the JWT
// then so subsequent requests can read it from the session.
async jwt({ token, account }) {
if (account?.access_token) {
token.accessToken = account.access_token;
} else if (!token.accessToken && !oidcConfigured) {
// Placeholder bearer the signer accepts when DEV_MODE=true.
token.accessToken = "dev-token";
}
return token;
},
async session({ session, token }) {
if (token.accessToken) {
session.accessToken = token.accessToken as string;
}
return session;
},
},
});
25 changes: 25 additions & 0 deletions portal/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
6 changes: 6 additions & 0 deletions portal/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
16 changes: 16 additions & 0 deletions portal/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Next.js configuration. `allowedDevOrigins` permits both localhost and
// 127.0.0.1 (over the portal's dev port 5173) to suppress the App Router's
// cross-origin warning when contributors reach the dev server via either
// hostname.
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
allowedDevOrigins: [
"http://127.0.0.1:5173",
"http://localhost:5173",
"127.0.0.1",
"localhost",
],
};

export default nextConfig;
Loading