Skip to content
Closed
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
19 changes: 19 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Local development settings for `wrangler dev`. Copy to `.dev.vars` (which is
# git-ignored) and fill in. Deployed environments set these as Worker secrets
# instead — never as [vars] in wrangler.toml.

# WorkOS AuthKit domain (bare host or https origin). 1Password: nyuchi/workos,
# field WORKOS_AUTHKIT_DOMAIN. Leave unset to use the localhost-only opt-out
# below.
AUTHKIT_DOMAIN=

# WorkOS Connect public client ID for the site login. 1Password:
# nyuchi/workos, field WORKOS_INTERNAL_TOOLS_CLIENT_ID.
WORKOS_CLIENT_ID=

# Signs the site session cookie. Any long random string locally.
SESSION_SECRET=

# Local development only: lets /mcp run without a bearer token when
# AUTHKIT_DOMAIN is unset and the request is to localhost. Never deployed.
ALLOW_UNAUTHENTICATED_DEV=true
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Bundu theme and official logos** in `nyuchi-imaging`: a `bundu` theme in
copper and terracotta, signed with the published Bundu Foundation logo.

### Security

- **The site login reads its WorkOS client ID from configuration.** `/login`
and `/callback` now use the `WORKOS_CLIENT_ID` Worker secret instead of a
compiled-in client ID. With it unset, `/login` answers `503`
"WORKOS_CLIENT_ID is not configured" and `/callback` denies without
attempting a token exchange; there is no fallback client.
- **`/mcp` fails closed when `AUTHKIT_DOMAIN` is not configured.** It used to
run without its bearer-token gate ("open mode"), so a missing secret served
every tool unauthenticated. It now answers `503` "AUTHKIT_DOMAIN is not
configured", as does the protected-resource metadata. Local development can
still opt out explicitly with `ALLOW_UNAUTHENTICATED_DEV=true`, honoured only
for requests to `localhost` and never set in a deployed environment.

### Changed

- **Vite+ for the root checks and Worker tests** (tooling only). `vite-plus`
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The web/MCP surface (`tools.nyuchi.com` / `tools.nyuchi.dev`, source in `mcp/src

### Authentication

- **Site pages** sit behind a site-wide login gate: Authorization Code + PKCE against WorkOS AuthKit (`identity.nyuchi.com`), session held in an HMAC-signed cookie. The gate **fails closed** — a missing `SESSION_SECRET` means "no valid session", never "run open".
- **Site pages** sit behind a site-wide login gate: Authorization Code + PKCE against WorkOS AuthKit (the `AUTHKIT_DOMAIN` and `WORKOS_CLIENT_ID` set per environment — never hardcoded), session held in an HMAC-signed cookie. The gate **fails closed** — a missing `SESSION_SECRET` means "no valid session", never "run open".
- **`/mcp`** requires WorkOS-issued bearer JWTs (OAuth 2.1, dynamic client registration) whenever `AUTHKIT_DOMAIN` is set. Audience is pinned to the registered resource (`MCP_RESOURCE`).

### Secrets
Expand Down
26 changes: 18 additions & 8 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ comment in `../wrangler.toml` for why `/mcp` lives on a separate hostname):
JSON-RPC.
- `/auth.md` — human/agent-readable description of the OAuth architecture
(`text/markdown`). Documents that this server is a resource server only —
the authorization server is identity.nyuchi.com (WorkOS Connect), outside
this repo.
the authorization server is WorkOS Connect on the configured
`AUTHKIT_DOMAIN`, outside this repo.
- `/.well-known/oauth-protected-resource` — protected-resource metadata (see
`auth.ts`); JSON 404 when auth is off.
- `/.well-known/oauth-authorization-server` and
`/.well-known/openid-configuration` — read-only mirrors of
identity.nyuchi.com's own discovery documents, fetched and passed through
the `AUTHKIT_DOMAIN` authorization server's own discovery documents, fetched and passed through
(never fabricated) when auth is on; JSON 404 when auth is off. Responses
are cached in-memory per-isolate for ~5 minutes as a soft optimization.
- `/login`, `/callback`, `/logout` — the **site-wide login gate** (see
Expand All @@ -53,17 +53,18 @@ comment in `../wrangler.toml` for why `/mcp` lives on a separate hostname):
Every human-facing page sits behind a session cookie (`nyuchi_session`), a
compact HS256 JWT signed with the `SESSION_SECRET` secret. The flow (see
`site-auth.ts`) is Authorization Code + PKCE against the same WorkOS Connect
app that already protects `/mcp` (`client_01KVTX0V2K1VM3PSC0DJ9VZWTV`,
authorization server `identity.nyuchi.com`), used here as a public client
app that already protects `/mcp` (client ID = the configured `WORKOS_CLIENT_ID`,
authorization server = the configured `AUTHKIT_DOMAIN`), used here as a public client
(`token_endpoint_auth_method=none` — no client secret is ever sent):

- `GET /login` — validates `?return_to=` as a same-origin relative path
(rejects absolute/protocol-relative URLs, falling back to `/`), generates
PKCE `state`/`code_verifier`/`code_challenge`, stashes them in a
short-lived `nyuchi_oauth` cookie, and 302s to
`https://identity.nyuchi.com/oauth2/authorize`. Returns 500 (fails closed)
if `SESSION_SECRET` isn't configured, rather than starting an OAuth round
trip that could never succeed.
`<AUTHKIT_DOMAIN>/oauth2/authorize`. Returns 500 (fails closed) if
`SESSION_SECRET` isn't configured, and 503 (naming the missing setting) if
`AUTHKIT_DOMAIN` or `WORKOS_CLIENT_ID` isn't, rather than starting an OAuth round trip that
could never succeed.
- `GET /callback` — reads the `nyuchi_oauth` cookie, verifies `state`
matches, exchanges `code` for an access token, verifies that token with
the exact same JWKS/issuer/audience logic `/mcp`'s bearer-token gate uses
Expand Down Expand Up @@ -93,6 +94,15 @@ authorization server `identity.nyuchi.com`), used here as a public client
just a fixed path list) and `[assets]` has an explicit `binding = "ASSETS"`
so `c.env.ASSETS.fetch(...)` can serve the static build from inside the
Worker after the gate passes.
- **`AUTHKIT_DOMAIN` is required** and set per environment as a Worker secret
(the owner script reads it from 1Password, `nyuchi/workos`). It is never
committed — not in code, not in `wrangler.toml` `[vars]` — and there is no
default.
- **`WORKOS_CLIENT_ID` is required** too: the site login's public client ID,
set per environment as a Worker secret (the owner script reads it from
1Password, `nyuchi/workos`, field `WORKOS_INTERNAL_TOOLS_CLIENT_ID`). There
is no compiled-in client. For `wrangler dev`, copy `.dev.vars.example` at
the repo root to `.dev.vars`.

## Where things live

Expand Down
2 changes: 1 addition & 1 deletion mcp/src/as-metadata-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* protected-resource metadata still find it.
*
* This proxies (fetches + passes through) the real upstream documents from
* identity.nyuchi.com — it never fabricates metadata. Only wired up when
* the configured AUTHKIT_DOMAIN — it never fabricates metadata. Only wired up when
* `authConfigured(env)` is true; see index.ts.
*/

Expand Down
42 changes: 26 additions & 16 deletions mcp/src/auth-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* architecture, served at GET /auth.md (see index.ts). This is descriptive
* documentation, not protocol metadata: the machine-readable metadata lives
* at /.well-known/oauth-protected-resource (this resource server) and at
* identity.nyuchi.com's own /.well-known/oauth-authorization-server (the
* the configured AUTHKIT_DOMAIN's own /.well-known/oauth-authorization-server (the
* WorkOS Connect authorization server, mirrored read-only at the same path
* on this domain — see auth.ts / index.ts).
*
Expand All @@ -14,15 +14,25 @@
* whichever hostname actually served the request.
*/

import { type AuthEnv, resourceOrigin, resourceUrl } from "./auth";

// Fixed, not derived from env.AUTHKIT_DOMAIN: this describes the canonical
// architecture (which authorization server this resource server trusts)
// even in open mode, when AUTHKIT_DOMAIN is unset — see "When auth is not
// required" below. Only the resource-server side (this domain) varies.
const ISSUER = "https://identity.nyuchi.com";
import { type AuthEnv, authConfigured, issuerUrl, resourceOrigin, resourceUrl } from "./auth";

export function authMd(env: AuthEnv): string {
// The issuer comes only from configuration (AUTHKIT_DOMAIN) — never a
// compiled-in host. When it is unset this page says so rather than naming one.
// An unusable value is described as such, never echoed.
let issuer: string | null = null;
let unsetReason = "AUTHKIT_DOMAIN is unset";
if (authConfigured(env)) {
try {
issuer = issuerUrl(env);
} catch {
unsetReason = "AUTHKIT_DOMAIN is not an https origin";
}
}
const issuerLabel = issuer ?? `not configured (${unsetReason})`;
const issuerMetadata = issuer
? new URL("/.well-known/oauth-authorization-server", issuer).href
: `not available (${unsetReason})`;
const resource = resourceUrl(env);
const origin = resourceOrigin(env);
const host = new URL(origin).hostname;
Expand All @@ -35,7 +45,7 @@ ${resource}.

${host} is a resource server only — it verifies bearer tokens but
never issues them and runs no authorization flow itself. The authorization
server is identity.nyuchi.com (WorkOS Connect), outside this repo; this page
server is WorkOS Connect (${issuerLabel}), outside this repo; this page
describes our side of the handshake only and does not restate or invent
authorization-server metadata on its behalf.

Expand All @@ -46,8 +56,8 @@ authorization-server metadata on its behalf.

## Authorization server

- Issuer: ${ISSUER} (WorkOS Connect)
- Metadata: ${ISSUER}/.well-known/oauth-authorization-server
- Issuer: ${issuerLabel} (WorkOS Connect)
- Metadata: ${issuerMetadata}
(mirrored at /.well-known/oauth-authorization-server on this domain)
- Flow: OAuth 2.1 Authorization Code + PKCE
- Client registration: Dynamic Client Registration (RFC 7591) at the
Expand All @@ -62,11 +72,11 @@ Send the access token as \`Authorization: Bearer <token>\` on requests to
\`/mcp\`. Unauthenticated requests receive \`401\` with a \`WWW-Authenticate\`
header pointing back at the protected-resource metadata.

## When auth is not required
## When auth is not configured

This server can run in an open mode (no \`AUTHKIT_DOMAIN\` configured) for
local development; the protected-resource metadata endpoint returns \`404\`
in that mode to signal no authorization is needed, and \`/mcp\` accepts
requests without a bearer token.
\`/mcp\` fails closed: if \`AUTHKIT_DOMAIN\` is not configured it answers
\`503\` and serves no tools. The only exception is local development, with
\`ALLOW_UNAUTHENTICATED_DEV=true\` set explicitly and requests addressed to
\`localhost\`; that setting is never present in a deployed environment.
`;
}
90 changes: 78 additions & 12 deletions mcp/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Optional WorkOS Connect (OAuth 2.1) protection for the MCP endpoint.
*
* Config comes from Worker vars (wrangler.toml [vars] or dashboard secrets):
* Config comes from the Worker's settings — never from committed config:
* AUTHKIT_DOMAIN — the WorkOS AuthKit/Connect domain for the environment,
* e.g. "your-workspace.authkit.app". When UNSET, the MCP
* a bare host or an https origin. Set per environment as a
* Worker secret (never a wrangler.toml [vars] entry, and
* there is no default in code). When UNSET, the MCP
* server runs open (no auth) and OAuth discovery endpoints
* return 404s that tell clients no sign-in is needed.
* MCP_RESOURCE — this server's canonical resource URL
Expand All @@ -15,7 +17,7 @@
* - Unauthenticated /mcp requests get 401 + WWW-Authenticate pointing at
* that metadata, which is how MCP clients discover the OAuth flow.
* - Bearer tokens are JWTs verified against the WorkOS JWKS
* (https://<AUTHKIT_DOMAIN>/oauth2/jwks) with issuer + audience checks.
* (<AUTHKIT_DOMAIN>/oauth2/jwks) with issuer + audience checks.
*
* Client registration (CIMD / dynamic client registration) is handled by
* WorkOS itself — enable it in the WorkOS dashboard under
Expand All @@ -27,22 +29,85 @@ import { createRemoteJWKSet, jwtVerify } from "jose";
export interface AuthEnv {
AUTHKIT_DOMAIN?: string;
MCP_RESOURCE?: string;
/**
* Local development only: `"true"` lets `/mcp` run without a bearer token
* when AUTHKIT_DOMAIN is unset AND the request is to localhost. Never set in
* any deployed environment (it is not in wrangler.toml); put it in
* `.dev.vars` for `wrangler dev`.
*/
ALLOW_UNAUTHENTICATED_DEV?: string;
}

// Must match the "AuthKit OAuth resource" registered in the WorkOS dashboard
// for this integration — see the MCP_RESOURCE comment in wrangler.toml.
export const DEFAULT_RESOURCE = "https://tools.nyuchi.dev/mcp";

/** Message used whenever a flow needs AUTHKIT_DOMAIN and it is unset. */
export const AUTHKIT_DOMAIN_MISSING = "AUTHKIT_DOMAIN is not configured";

/**
* True when AUTHKIT_DOMAIN is set at all. Deliberately presence-only: this
* switches auth ON, and an unusable value must not switch it OFF (that would
* fail open). With auth on and an invalid value, `issuerUrl` throws, so bearer
* verification returns null (401) and the OAuth surfaces error — fail closed.
*/
export function authConfigured(env: AuthEnv): boolean {
return typeof env.AUTHKIT_DOMAIN === "string" && env.AUTHKIT_DOMAIN.length > 0;
return typeof env.AUTHKIT_DOMAIN === "string" && env.AUTHKIT_DOMAIN.trim().length > 0;
}

const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);

/**
* True only for the explicit local-development opt-out: AUTHKIT_DOMAIN unset,
* `ALLOW_UNAUTHENTICATED_DEV` exactly `"true"`, and the request addressed to
* localhost. Everywhere else an unset AUTHKIT_DOMAIN fails closed — `/mcp`
* never serves tools unauthenticated by default.
*/
export function unauthenticatedDevAllowed(env: AuthEnv, requestUrl: string): boolean {
if (authConfigured(env)) return false;
if (env.ALLOW_UNAUTHENTICATED_DEV !== "true") return false;
try {
return LOCAL_HOSTNAMES.has(new URL(requestUrl).hostname);
} catch {
return false;
}
}

export function resourceUrl(env: AuthEnv): string {
return env.MCP_RESOURCE || DEFAULT_RESOURCE;
}

/**
* Parse — never concatenate — a configured AuthKit domain into an https origin.
*
* Accepts a bare host or an https origin, in any case. Any path, query or
* fragment is dropped. A blank value, `http:`, any other scheme, embedded
* credentials and anything `URL` cannot parse all throw an error whose message
* starts with `AUTHKIT_DOMAIN_MISSING`. The result is `URL.origin`.
*/
export function normaliseAuthkitDomain(value: string | undefined): string {
const raw = value?.trim();
if (!raw) throw new Error(AUTHKIT_DOMAIN_MISSING);
let url: URL;
try {
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
} catch {
throw new Error(`${AUTHKIT_DOMAIN_MISSING} (not a valid host or URL)`);
}
if (url.protocol !== "https:" || url.username || url.password) {
throw new Error(`${AUTHKIT_DOMAIN_MISSING} (must be an https origin)`);
}
return url.origin;
}

/**
* The AuthKit issuer origin, from configuration only, parsed by
* `normaliseAuthkitDomain` (the `iss` check is an exact string match against
* it). Throws when AUTHKIT_DOMAIN is unset or is not a bare host / https
* origin — callers check `authConfigured` first; there is no fallback host.
*/
export function issuerUrl(env: AuthEnv): string {
return `https://${env.AUTHKIT_DOMAIN}`;
return normaliseAuthkitDomain(env.AUTHKIT_DOMAIN);
}

/**
Expand Down Expand Up @@ -77,11 +142,11 @@ export function wwwAuthenticateHeader(env: AuthEnv): string {
/* JWKS instances are cached per AuthKit domain for the isolate's lifetime. */
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

function jwksFor(domain: string): ReturnType<typeof createRemoteJWKSet> {
let jwks = jwksCache.get(domain);
function jwksFor(issuer: string): ReturnType<typeof createRemoteJWKSet> {
let jwks = jwksCache.get(issuer);
if (!jwks) {
jwks = createRemoteJWKSet(new URL(`https://${domain}/oauth2/jwks`));
jwksCache.set(domain, jwks);
jwks = createRemoteJWKSet(new URL("/oauth2/jwks", issuer));
jwksCache.set(issuer, jwks);
}
return jwks;
}
Expand Down Expand Up @@ -117,10 +182,11 @@ export async function verifyJwt(
token: string | undefined,
expectedAudience?: string,
): Promise<VerifiedJwt | null> {
if (!token || !env.AUTHKIT_DOMAIN) return null;
if (!token || !authConfigured(env)) return null;
try {
const { payload } = await jwtVerify(token, jwksFor(env.AUTHKIT_DOMAIN), {
issuer: issuerUrl(env),
const issuer = issuerUrl(env);
const { payload } = await jwtVerify(token, jwksFor(issuer), {
issuer,
audience: expectedAudience ?? resourceUrl(env),
});
const scopes =
Expand Down
Loading
Loading