Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
110 commits
Select commit Hold shift + click to select a range
b4247d6
Support running OpenShell Controller behind a reverse proxy
ivobrett May 10, 2026
c042d83
Fix dashboard WebSocket disconnect and Hermes sandbox half-onboarding
ivobrett May 18, 2026
3e374c8
Document proposed mcpauth forwardAuth integration
ivobrett May 18, 2026
8e4f6ea
Bump pinned OpenClaw version to 2026.5.20
ivobrett May 22, 2026
82f53d6
Merge remote-tracking branch 'upstream/main' into gatewaydashboard
ivobrett May 22, 2026
68be3be
trying to fix hermes dashboard
ivobrett May 23, 2026
5766fd9
Fix Hermes dashboard launch: VPS docker exec tunnel + proxy asset rew…
ivobrett May 23, 2026
dec9d9f
Revert hermes dashboard launch feature; keep prebuild step
ivobrett May 23, 2026
46cb197
feat(auth): MCPAuth OAuth2 IDP Integration
ivobrett May 27, 2026
b73d451
Merge branch 'gatewaydashboard-with-auth-trial' into gatewaydashboard
ivobrett May 27, 2026
467a528
fix create url blocking
ivobrett May 27, 2026
0f2f700
Fix sandbox per-user auth: gate by name, not UUID
ivobrett May 27, 2026
90f1018
Update sandbox access control doc to use sandbox name not UUID
ivobrett May 27, 2026
1e49edc
Allow MCPAuth users through WebSocket dashboard upgrade handler
ivobrett May 27, 2026
ff6521a
Enforce global read-only access for MCPAuth enterprise users
ivobrett May 27, 2026
f2ad3fd
Fix password reset and add Security page for sandbox access
ivobrett May 27, 2026
61fc831
Hide and gate password change for non-operator users
ivobrett May 27, 2026
6e74cc3
Allow unauthenticated GET /api/auth/me so first-run setup works
ivobrett May 27, 2026
e0b19b2
Generate OpenClaw gateway token on sandbox creation
ivobrett May 27, 2026
7f0620f
Allow MCPAuth users into the live terminal for authorized sandboxes
ivobrett May 27, 2026
350ccc0
Terminal fullscreen toggle, dashboard deep-link login flow, copy-link…
ivobrett May 27, 2026
18886b6
Default Inference to Auto, agent-aware Quick Deploy, fix Hermes redep…
ivobrett May 28, 2026
f68c279
Strip CLI header from exported policy YAML in redeploy path
ivobrett May 28, 2026
3fc35e2
Quick Deploy: exclude mismatched-agent sources from candidate list
ivobrett May 28, 2026
4d85350
Fix type error in agentForName registry entry cast
ivobrett May 28, 2026
737956a
refactor: consolidate auth, move middleware to Node, file-backed acce…
ivobrett May 28, 2026
a88aa69
rename: mcpauth/CF_Authorization → OAuth/oauth_session
ivobrett May 28, 2026
328370a
Merge refactor: consolidated auth library + OAuth rename
ivobrett May 28, 2026
aeda8c8
chore: bump NEMOCLAW_INSTALL_TAG v0.0.37 -> v0.0.56 (+ OPENCLAW_VERSI…
ivobrett Jun 2, 2026
4024147
fix: unpin Debian-version apt pkgs in NemoClaw Dockerfile after extra…
ivobrett Jun 3, 2026
34a166e
Merge upstream/main into gatewaydashboard (+ CLAUDE.md)
ivobrett Jun 4, 2026
95debb2
fix: nemoclaw onboard --name + restore Dockerfile unpin patch
ivobrett Jun 4, 2026
5410d39
docs(CLAUDE.md): section on version-bump handling
ivobrett Jun 4, 2026
add45db
sandbox/create: extend first-build timeout to 20 min
ivobrett Jun 4, 2026
7646e9d
sandbox/create: surface prebuilt baseline sandboxes for fast first-de…
ivobrett Jun 4, 2026
28e854c
Merge upstream/main into gatewaydashboard (3 commits)
ivobrett Jun 9, 2026
ccfca34
feat: multi-tenant Hermes Desktop remote-gateway exposure
ivobrett Jun 10, 2026
fbd453d
feat: auto-upgrade in-sandbox hermes to >=0.16 at expose time
ivobrett Jun 10, 2026
c143a1e
fix: desktop-mode leak check asserts no token in response, not 404
ivobrett Jun 10, 2026
a93307d
docs: rewrite remote-desktop plan as as-built setup guide
ivobrett Jun 10, 2026
1204f3a
Merge remotegateway: Hermes Desktop remote-gateway exposure
ivobrett Jun 10, 2026
8c3e20d
updated doc explaining hermes remote desktop
ivobrett Jun 10, 2026
a351fd2
fix(hermes-remote): provision API_SERVER_KEY before upgrade-hermes runs
ivobrett Jun 11, 2026
b403108
docs(hermes-remote): document upgrade-hermes.sh removal conditions
ivobrett Jun 11, 2026
3388948
feat(ui): add Copy Desktop gateway URL button to Hermes sandbox card
ivobrett Jun 11, 2026
315a81f
fix(hermes-remote): open UFW port before launch.sh, not after
ivobrett Jun 11, 2026
fa05da6
revert(ui): remove redundant Copy Desktop gateway URL button
ivobrett Jun 11, 2026
c570f54
fix(hermes-remote): use absolute path /usr/sbin/ufw in expose.sh
ivobrett Jun 11, 2026
9324bc3
docs(CLAUDE.md): add hermes-remote troubleshooting guide and producti…
ivobrett Jun 11, 2026
5bdb74e
fix(hermes-remote): match hermes.real gateway run + fix BYOVPS bridge IP
ivobrett Jun 11, 2026
d4b86ee
fix(hermes-remote): resolve Traefik network via Gerbil when using con…
ivobrett Jun 11, 2026
fa949ad
docs(CLAUDE.md): add BYOVPS architecture differences section
ivobrett Jun 11, 2026
7aa59ef
fix(hermes-remote): back off forward restarts; document 2026-06-11 ou…
ivobrett Jun 11, 2026
85c8cc1
fix(server): derive OpenClaw control UI origin from the sandbox hash …
ivobrett Jun 11, 2026
da141dc
docs(CLAUDE.md): ensure-mtls now scheme-matches CLI registration
ivobrett Jun 11, 2026
7faac78
fix(server): strip forwarded headers on dashboard WS upstream; restor…
ivobrett Jun 11, 2026
8b9eb85
fix(sandbox/create): log stderr tail on command failure
ivobrett Jun 13, 2026
c35fea5
fix(dashboard): auto-refresh stale openclaw_dashboard_token on 401
ivobrett Jun 13, 2026
48bbfa5
fix(server): make cookie token win over client URL token in dashboard…
ivobrett Jun 13, 2026
ed22c9e
debug(server): log token fingerprints in dashboard-ws-client-connected
ivobrett Jun 13, 2026
a2e8ddb
fix(server): cookie Authorization header also wins over client value
ivobrett Jun 13, 2026
b42b323
Revert "debug(server): log token fingerprints in dashboard-ws-client-…
ivobrett Jun 13, 2026
c240245
Merge upstream/main into gatewaydashboard (1 commit)
ivobrett Jun 13, 2026
7c87996
docs(CLAUDE.md): add §11 OpenClaw dashboard token+tunnel architecture
ivobrett Jun 13, 2026
6eadce0
test(dashboard): lock fix-chain invariants + add npm test runner
ivobrett Jun 14, 2026
2802079
test(dashboard): runtime behavioural test for cookie-wins guards
ivobrett Jun 14, 2026
d10002c
docs(CLAUDE.md): document local regression testing for dashboard fix …
ivobrett Jun 14, 2026
83bfbe8
feat(inference): raise gemma4:12b output cap to 8192
ivobrett Jun 18, 2026
b27646c
Revert "feat(inference): raise gemma4:12b output cap to 8192"
ivobrett Jun 18, 2026
b9f7fce
fix(sandbox/create): reclaim ollama port from rogue cloud-init launch
ivobrett Jun 18, 2026
0c7e7bf
Revert "fix(sandbox/create): reclaim ollama port from rogue cloud-ini…
ivobrett Jun 18, 2026
92e486a
docs(CLAUDE.md): point ollama bootstrap fixes at manidae-cloud
ivobrett Jun 18, 2026
c4c2c1d
fix(hermes-remote/watchdog): auto-recover dead gateway on BYOVPS (#2478)
ivobrett Jun 21, 2026
daa9d70
fix(hermes-remote/expose): add LimitNOFILE=65536 to forward unit temp…
ivobrett Jun 21, 2026
73d9664
fix(sandbox/create): verify gateway token via WS handshake after doctor
ivobrett Jun 21, 2026
617bbc3
fix(dashboard/proxy): wipe stale sessionStorage tokens on bootstrap
ivobrett Jun 21, 2026
e3c5e66
fix(hermes-remote): install gateway-recovery guards from host (#2478)
ivobrett Jun 21, 2026
1959da8
docs(CLAUDE.md): document upstream-merge resolution for new fork hooks
ivobrett Jun 21, 2026
dfb80dd
fix(hermes-remote/ensure-recovery-guards): use chmod 666 not u+w
ivobrett Jun 21, 2026
5f48420
feat(hermes-remote): vendor NemoClaw safety-net + ciao preloads
ivobrett Jun 21, 2026
44fa4de
feat(setup): add production install script + systemd unit template
ivobrett Jun 21, 2026
0b45d52
fix(restore): pre-buffer multipart body in server.mjs to avoid Next.j…
ivobrett Jun 21, 2026
6364d41
fix(restore): replace hand-rolled multipart parser with Node.js built…
ivobrett Jun 21, 2026
a3077ff
fix(restore): handle restore endpoint entirely in server.mjs, bypass …
ivobrett Jun 21, 2026
d47032b
fix(restore): add list-based fallback to sandbox ID resolution
ivobrett Jun 21, 2026
79f2d34
fix(restore): use docker cp + docker exec to bypass gRPC 1 MiB stdin …
ivobrett Jun 21, 2026
4dfd019
fix(restore): tolerate macOS tar metadata in archives
ivobrett Jun 21, 2026
9fc2291
fix(restore): use newline-separated script so ec=\$? always runs
ivobrett Jun 21, 2026
2d04774
fix(mcp): use openshell sandbox exec for in-sandbox writes
ivobrett Jun 22, 2026
1d95fae
fix(mcp): tolerate non-root + skip openclaw steps on Hermes sandboxes
ivobrett Jun 22, 2026
d5691cd
fix(mcp): unlock openclaw.json before rewrite + use set -e
ivobrett Jun 22, 2026
a01e54f
fix(mcp): write openclaw.json via 444 temp file + atomic rename
ivobrett Jun 22, 2026
774e32c
docs+fix: gateway recovery runbook + persist agent in NemoClaw registry
ivobrett Jun 22, 2026
cd7890f
feat(activity-log): paginate to 10 entries per page
ivobrett Jun 22, 2026
65c8af7
docs: add upstream divergence security audit + CLAUDE.md cross-refs
ivobrett Jun 22, 2026
4774f69
feat(sandbox): identify Custom sandboxes + clean Connect-to-Terminal
ivobrett Jun 22, 2026
efdedce
fix(terminal-server): use \`openshell sandbox connect\` not raw ssh
ivobrett Jun 22, 2026
876b561
feat(quick-deploy): add Custom as a Quick Deploy source agent
ivobrett Jun 22, 2026
fae8247
docs: trim CLAUDE.md from 1067 to 521 lines, extract runbooks
ivobrett Jun 22, 2026
3ba5683
chore(versions): pin NemoClaw default to v0.0.66
ivobrett Jun 23, 2026
7bcb986
feat: bump NemoClaw v0.0.66 -> v0.0.69 (Hermes v0.17.0)
ivobrett Jun 29, 2026
224da90
feat(controller): Hermes dashboard button + per-sandbox IdP gating, t…
ivobrett Jun 29, 2026
aac6d09
chore(nemoclaw): bump NEMOCLAW_INSTALL_REF v0.0.69 -> v0.0.70
ivobrett Jun 30, 2026
3f3d8c3
feat(openclaw-remote): parked POC to expose OpenClaw gateway for the …
ivobrett Jun 30, 2026
52a0a99
feat(openclaw-remote): controller UI to view/copy mobile-app gateway …
ivobrett Jun 30, 2026
f4d5179
fix(openclaw-remote): always rewrite forward unit + drop backtick in …
ivobrett Jun 30, 2026
48dd81c
fix(openclaw-remote): probe gateway reachability server-side (CORS fa…
ivobrett Jun 30, 2026
2be941e
fix(openclaw-remote): probe forward backend directly for reachability
ivobrett Jun 30, 2026
ff3dc06
chore(pins): bump OpenShell v0.0.44->v0.0.71 + NemoClaw v0.0.70->v0.0.71
ivobrett Jul 1, 2026
83ca1ba
Merge upstream/main (mmckeen-nv) into gatewaydashboard
ivobrett Jul 1, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ yarn-error.log*

# local env files
.env*.local
.env

# vercel
.vercel
Expand Down
521 changes: 521 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

271 changes: 271 additions & 0 deletions HERMES_REMOTE_DESKTOP.md

Large diffs are not rendered by default.

40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ It is currently built for active development and lab use. It includes a simple p

## Compatibility Targets

This dashboard is validated against the current NVIDIA NemoClaw repo and the OpenShell version range declared in NemoClaw's `nemoclaw-blueprint/blueprint.yaml`, not the older April 2026 point releases. Current NemoClaw `main` pins OpenShell exactly to `0.0.71`, so the bundled refresh helper defaults to:
This dashboard is validated against the current NVIDIA NemoClaw repo and the OpenShell version range declared in NemoClaw's `nemoclaw-blueprint/blueprint.yaml`, not the older April 2026 point releases. NemoClaw `v0.0.71` pins OpenShell exactly to `0.0.71` (native gateway mTLS), so the bundled refresh helper defaults to:

- OpenShell installer release: `v0.0.71` (`OPENSHELL_VERSION=v0.0.71`)
- NemoClaw source ref: `main` (`NEMOCLAW_INSTALL_REF=main`)
- NemoClaw source ref: `v0.0.71` (`NEMOCLAW_INSTALL_REF=v0.0.71`) — keeps the Hermes v0.17.0 base (v0.0.69 was the first to bundle it)
- OpenClaw base-image build target: `2026.5.27` (`OPENCLAW_VERSION=2026.5.27`) unless overridden

Runtime/toolchain versions used during development:
Expand Down Expand Up @@ -98,7 +98,7 @@ Install or refresh the locked OpenShell/NemoClaw pair first:
./install_versioned_nemoclaw_openshell.sh
```

That helper defaults to `OPENSHELL_VERSION=v0.0.71`, `NEMOCLAW_INSTALL_REF=main`, and `OPENCLAW_VERSION=2026.5.27`.
That helper defaults to `OPENSHELL_VERSION=v0.0.71`, `NEMOCLAW_INSTALL_REF=v0.0.71`, and `OPENCLAW_VERSION=2026.5.27`.

Then install the dashboard from the repository root:

Expand Down Expand Up @@ -426,6 +426,40 @@ Before exposing this outside a trusted lab network, replace auth with a real ide

## Troubleshooting

### Gateway down — `openshell sandbox list` returns "Connection refused"

The OpenShell Docker-driver gateway is a background process managed by NemoClaw. If it crashes or is killed, every `openshell sandbox` command fails with:

```
Error: × transport error
╰─▶ Connection refused (os error 111)
```

All sandbox containers are also stopped when the gateway shuts down.

**Recovery — one command:**

```bash
PATH=/root/.nvm/versions/node/v22.22.3/bin:/root/.local/bin:$PATH \
HOME=/root \
OPENSHELL_GATEWAY=nemoclaw \
nemoclaw <any-sandbox-name> recover
```

Replace `<any-sandbox-name>` with any name from `nemoclaw list` (e.g. `my-first-hermes`). The `recover` sub-command restarts the host gateway as a side-effect. Once it prints `✓ Docker-driver gateway is healthy`, `openshell sandbox list` works again and the sandboxes are back to Ready.

`HOME` must be set — NemoClaw needs it to locate `~/.local/state/nemoclaw/`. It is always set correctly inside the controller's systemd unit, but bare SSH sessions may lack it.

After the gateway is back, individual sandbox Hermes/OpenClaw gateways may need their own recovery if the inner gateway process also died:

```bash
# repeat for each sandbox that shows degraded health
PATH=/root/.nvm/versions/node/v22.22.3/bin:/root/.local/bin:$PATH \
HOME=/root \
OPENSHELL_GATEWAY=nemoclaw \
nemoclaw <sandbox-name> recover
```

Check OpenShell:

```bash
Expand Down
164 changes: 164 additions & 0 deletions SANDBOX_ACCESS_CONTROL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Per-Sandbox Access Control via OAuth/IDP

Allows enterprise users to authenticate via the internal IDP (OAuth/IDP) and access
only the sandboxes assigned to them, without needing the operator password.

## Architecture

```
User browser
openshell-controller.ag-*.nemoclaw.dpdns.org (Traefik, no badger@http)
Next.js middleware.ts
├── Operator session cookie → full access (all sandboxes, can create/delete)
└── `oauth_session` JWT cookie → read-only, own sandboxes only
```

The key insight: OAuth/IDP sets a `oauth_session` JWT cookie scoped to
`.ag-*.nemoclaw.dpdns.org`. Once a user logs into the IDP at
`idp.ag-*.nemoclaw.dpdns.org`, that cookie is automatically sent to the
openshell controller on the same domain. The controller validates the JWT
locally (no network call to OAuth/IDP) and gates per-sandbox access.

## What Was Changed

### 1. Traefik on VPS (`/etc/komodo/stacks/support-799109_setup-stack/config/traefik/rules/resource-overrides.yml`)

Removed `badger@http` from the openshell controller router so Pangolin no longer
gates the controller. The controller handles all auth itself.

**Before:**
```yaml
8-openshell-controller-router-auth:
middlewares:
- badger@http
rule: "Host(`openshell-controller.ag-*.nemoclaw.dpdns.org`)"
service: "8-openshell-controller-service@http"
```

**After:**
```yaml
8-openshell-controller-router-auth:
rule: "Host(`openshell-controller.ag-*.nemoclaw.dpdns.org`)"
service: "8-openshell-controller-service@http"
```

### 2. `.env.local` — New env vars

```env
# OAuth IDP Configuration for Enterprise Sandbox Access
OAUTH_JWT_SECRET=my-secret-key # must match the IDP's JWT signing secret
SANDBOX_ACCESS_USERS=sandbox-name:user@example.com,other-sandbox:other@example.com
OAUTH_LOGIN_URL=https://idp.ag-*.nemoclaw.dpdns.org/internal/login
OAUTH_CLIENT_ID=<oauth-client-id>
OAUTH_CLIENT_SECRET=<oauth-client-secret>
OAUTH_CALLBACK_URL=https://openshell-controller.ag-*.nemoclaw.dpdns.org/api/auth/callback
```

> The historical `MCPAUTH_*` and `CF_AUTH_JWT_SECRET` env var names are still
> read as fallbacks; existing deployments don't need to rename anything to
> upgrade. Note that `SANDBOX_ACCESS_USERS` is now optional — the access
> list is preferentially read from `data/sandbox-access.json`, edited via
> the Security page.

`SANDBOX_ACCESS_USERS` is a comma-separated list of `sandboxName:email` pairs.
Use the sandbox **name** (not the UUID) — e.g. `my-first-claw:alice@company.com`.
The name is what appears in the dashboard URL and the instance ID format `sandbox-{port}-{name}`.
A sandbox can have at most one assigned user. Operator (password login) bypasses
this check and sees all sandboxes.

### 3. `middleware.ts` — oauth_session validation

Added to the existing Next.js middleware:

- Reads `oauth_session` cookie on every request (falls back to the legacy
`CF_Authorization` cookie name for sessions issued by an older controller)
- Validates the JWT signature (HS256, `OAUTH_JWT_SECRET`)
- Extracts `email` claim
- If valid email found:
- GET `/api/sandbox/create` → allowed (template list is read-only)
- POST `/api/sandbox/create`, POST `/api/sandbox/delete` → 403 (operator only)
- Any `/api/sandbox/[sandboxId]/...` or `/api/telemetry/sandbox/[sandboxId]/...` → allowed only if `isUserAuthorizedForSandbox(email, sandboxId)` returns true
- Sets `x-forwarded-user` header for downstream handlers

### 4. `app/lib/auth/` — Shared auth library

**`verifyOAuthJWT(token, secret)`** (in `edge.ts` / `node.mjs`) — validates HMAC
HS256 JWT, returns `{ email, sub, exp, … }` or null.

**`isUserAuthorizedForSandbox(email, sandboxId)`** (in `controlAuth.ts`) —
consults the file-backed `SandboxAccessStore` and returns true if the email is
assigned to that sandbox. Sandbox matching is by sandbox name (not ID — the
sandbox ID from the URL is used as the sandbox name in this context).

The store is `data/sandbox-access.json`; the legacy `SANDBOX_ACCESS_USERS` CSV
env var is read as a fallback if the file is absent.

## User Flows

### Enterprise user (Alice)
1. Alice navigates to `https://idp.ag-*.nemoclaw.dpdns.org`
2. Logs in with email/password via the internal IDP
3. OAuth/IDP sets `oauth_session` JWT cookie (24h TTL, domain `.ag-*.nemoclaw.dpdns.org`)
4. Alice navigates to `https://openshell-controller.ag-*.nemoclaw.dpdns.org`
5. Controller middleware validates cookie → extracts `alice@company.com`
6. Only sandboxes assigned to `alice@company.com` in `SANDBOX_ACCESS_USERS` are visible/accessible
7. Create/delete operations return 403

### Operator
1. Navigates to `https://openshell-controller.ag-*.nemoclaw.dpdns.org`
2. Logs in with `OPENSHELL_CONTROL_PASSWORD` at `/login`
3. Gets `openshell_control_session` cookie
4. Full access — all sandboxes, create/delete enabled
5. Unaffected by `SANDBOX_ACCESS_USERS`

## Assigning a Sandbox to a User

Use the Security page (operator login required) at
`https://openshell-controller.ag-*.nemoclaw.dpdns.org/setup-account` to add
or remove rows. Changes are persisted to `data/sandbox-access.json` and
take effect immediately — no controller restart needed.

The sandbox name is the short identifier (e.g. `my-first-claw`), not the UUID.
Find it with `openshell sandbox list` or from the sandbox URL in the dashboard.

For headless / first-run setup the legacy `SANDBOX_ACCESS_USERS` CSV env var
in `.env.local` is still read as a fallback when the JSON file is absent:

```env
SANDBOX_ACCESS_USERS=alice-sandbox:alice@company.com,bob-sandbox:bob@company.com
```

To create a user in the IDP, use the IDP's own admin surface (e.g. the
Pangolin admin panel at `https://pangolin.ag-*.nemoclaw.dpdns.org`).

## JWT Secret

The OAuth `oauth_session` cookie is signed with HMAC HS256 using a shared
secret that must match the IDP's signing secret. The controller reads it
from `OAUTH_JWT_SECRET`, falling back to `MCPAUTH_JWT_SECRET` /
`CF_AUTH_JWT_SECRET` for backwards compatibility.

If the IDP defaults to a hardcoded development secret (e.g. `"my-secret-key"`),
override it for production and keep `OAUTH_JWT_SECRET` in sync.

## Future Enhancements

- **Dynamic sandbox assignment**: Add an "Assign to user" field in the sandbox
creation wizard (WizardPanel.tsx) that writes to a JSON config file instead of
requiring manual `.env.local` edits.

- **User creation in controller**: Add an operator-only UI to create/invite OAuth/IDP
users without needing to use the Pangolin admin panel.

- **Stronger JWT secret**: Move OAuth/IDP's `jwtSecret` to an env var and generate
a cryptographically random value on first run.

- **Per-sandbox URL**: If direct per-sandbox links are needed (e.g., to email
Alice a link directly to her sandbox), add a `/sandbox/[sandboxId]` route that
skips the full dashboard and proxies directly to the OpenClaw dashboard for that
sandbox. This requires the browser-redirect flow in OAuth/IDP (a new
`/auth/browser` endpoint that redirects to login instead of returning 401).
114 changes: 114 additions & 0 deletions app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server"
import { mintOAuthSessionJWT, sessionCookieOptionsForRequest } from "@/app/lib/controlAuth"
import { OAUTH_COOKIE_NAME } from "@/app/lib/auth/policy.mjs"

function base64UrlDecode(value: string) {
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=")
return Buffer.from(padded, "base64").toString("utf-8")
}

// Generic OAuth2 / OIDC callback handler. Reads IDP coordinates from the
// OAUTH_* env vars, falling back to the legacy MCPAUTH_* names so existing
// deployments don't need to rename anything.
function getIdpEnv(name: string) {
const upper = name.toUpperCase()
return (
process.env[`OAUTH_${upper}`]
|| process.env[`MCPAUTH_${upper}`]
|| ""
)
}

export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const code = searchParams.get("code")
const state = searchParams.get("state") || "/"

if (!code) {
return NextResponse.json({ ok: false, error: "Missing authorization code" }, { status: 400 })
}

const loginUrl = getIdpEnv("LOGIN_URL")
const tokenUrl = `${loginUrl ? loginUrl.replace("/authorize", "") : "http://localhost:11000"}/token`
const clientId = getIdpEnv("CLIENT_ID")
const redirectUri = getIdpEnv("CALLBACK_URL")

try {
// Exchange authorization code for token
const formData = new URLSearchParams()
formData.append("grant_type", "authorization_code")
formData.append("code", code)
formData.append("redirect_uri", redirectUri)
formData.append("client_id", clientId)

const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData.toString(),
})

const data = await response.json()
if (!response.ok) {
return NextResponse.json({ ok: false, error: data.error_description || "Token exchange failed" }, { status: response.status })
}

// Standard OIDC: extract email from the id_token JWT payload
const idToken = data.id_token as string | undefined
const accessToken = data.access_token as string | undefined

let userEmail: string | null = null
let scopes: string[] = []

if (idToken) {
try {
const parts = idToken.split(".")
if (parts.length >= 2) {
const payload = JSON.parse(base64UrlDecode(parts[1]))
userEmail = payload.email || payload.sub || null
}
} catch {
// fall through to access token parsing
}
}

// Fallback: parse scopes from access token payload
if (accessToken) {
try {
const parts = accessToken.split(".")
if (parts.length >= 2) {
const payload = JSON.parse(base64UrlDecode(parts[1]))
if (!userEmail && payload.email) userEmail = payload.email
if (Array.isArray(payload.scopes)) scopes = payload.scopes
}
} catch {
// ignore
}
}

if (!userEmail) {
return NextResponse.json({ ok: false, error: "User identity could not be retrieved from provider" }, { status: 500 })
}

// Mint the OAuth session JWT cookie using our shared secret.
const oauthToken = await mintOAuthSessionJWT(userEmail, scopes)

// Reconstruct target URL using the incoming Host header to ensure we
// redirect to the correct domain/port (e.g. localhost:3000 instead of
// 0.0.0.0:3000).
const host = request.headers.get("host") || "localhost:3000"
const protocol = request.headers.get("x-forwarded-proto") || request.nextUrl.protocol || "http"
const cleanProtocol = protocol.endsWith(":") ? protocol.slice(0, -1) : protocol
const baseUrl = `${cleanProtocol}://${host}`
const targetUrl = new URL(state.startsWith("/") ? state : "/", baseUrl)
const redirectResponse = NextResponse.redirect(targetUrl)
redirectResponse.cookies.set(OAUTH_COOKIE_NAME, oauthToken, sessionCookieOptionsForRequest(request))

return redirectResponse
} catch (error) {
console.error("OAuth callback error:", error)
return NextResponse.json(
{ ok: false, error: error instanceof Error ? error.message : "Internal server error during callback processing" },
{ status: 500 },
)
}
}
33 changes: 33 additions & 0 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,36 @@ export async function POST(request: NextRequest) {
response.cookies.set(settings.cookieName, await createSessionCookieValue(), sessionCookieOptionsForRequest(request))
return response
}

// Read IDP coordinates from OAUTH_* env vars, falling back to the legacy
// MCPAUTH_* names so existing .env.local files keep working.
function idpEnv(name: string) {
const upper = name.toUpperCase()
return process.env[`OAUTH_${upper}`] || process.env[`MCPAUTH_${upper}`] || ""
}

export async function GET() {
const loginBase = idpEnv("LOGIN_URL")
const clientId = idpEnv("CLIENT_ID")
const redirectUri = idpEnv("CALLBACK_URL")

let oauthLoginUrl: string | null = null
if (loginBase && clientId && redirectUri) {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid email",
})
oauthLoginUrl = `${loginBase}?${params.toString()}`
}

// Returns the URL under both names for one release: the new `oauthLoginUrl`
// and the historical `mcpAuthLoginUrl`, so a stale cached login page from
// a previous deploy keeps working.
return NextResponse.json({
oauthLoginUrl,
mcpAuthLoginUrl: oauthLoginUrl,
})
}

Loading