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
34 changes: 31 additions & 3 deletions nyuchi-docs-mcp-worker/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,46 @@ import { createRemoteJWKSet, jwtVerify } from 'jose';
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
let jwksIssuer: string | undefined;

/**
* Parse — never concatenate — the configured AuthKit issuer (WORKOS_ISSUER)
* into an https origin. No compiled-in default.
*
* Accepts a bare host or an https origin, in any case. Any path, query or
* fragment is dropped. A blank value is null (unset); `http:`, any other
* scheme, embedded credentials and anything `URL` cannot parse are also null —
* an unusable issuer fails closed exactly like a missing one. The result is
* `URL.origin`, which the `iss` claim must equal exactly.
*/
export function normaliseIssuer(raw: string | undefined): string | null {
const value = (raw ?? '').trim();
if (!value) return null;
let url: URL;
try {
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password) return null;
return url.origin;
}

export async function verifyBearerAuth(
req: Request,
issuer: string | undefined
configuredIssuer: string | undefined
): Promise<{ authorized: boolean; subject?: string }> {
if (!issuer) return { authorized: false };
const issuer = normaliseIssuer(configuredIssuer);
if (!issuer) {
// Fail closed: without a configured issuer no token can be verified.
if (req.headers.has('authorization')) console.error('[auth] WORKOS_ISSUER is not configured (or is not an https origin)');
return { authorized: false };
}
const header = req.headers.get('authorization') ?? '';
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return { authorized: false };

try {
if (!jwks || jwksIssuer !== issuer) {
jwks = createRemoteJWKSet(new URL(`${issuer}/oauth2/jwks`));
jwks = createRemoteJWKSet(new URL('/oauth2/jwks', issuer));
jwksIssuer = issuer;
}
const { payload } = await jwtVerify(match[1], jwks, { issuer });
Expand Down
2 changes: 1 addition & 1 deletion nyuchi-docs-mcp-worker/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export interface Env {
FEEDBACK?: FeedbackStore;
/** Optional secret — when set, raise_issue files real GitHub issues. */
GITHUB_TOKEN?: string;
/** WorkOS issuer used to verify a caller's own bearer token (see src/auth.ts). Unset = every caller is treated as unauthenticated (public-only). */
/** WorkOS AuthKit issuer used to verify a caller's own bearer token (see src/auth.ts). REQUIRED, set per environment as a secret — never committed, no default. Unset = every caller is treated as unauthenticated (public-only; fails closed). */
WORKOS_ISSUER?: string;
/** Shared with nyuchi-docs's site worker — sent on internal-page fetches once a caller is verified, so the read skips the browser OIDC flow. */
INTERNAL_FETCH_KEY?: string;
Expand Down
88 changes: 88 additions & 0 deletions nyuchi-docs-mcp-worker/tests/issuer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// WORKOS_ISSUER is parsed — never concatenated — into an https origin, and a
// token's `iss` must equal that origin exactly. The site gate keeps an
// identical copy of the normaliser; both are exercised here.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import { normaliseIssuer, verifyBearerAuth } from '../src/auth.js';
import { normaliseIssuer as gateNormaliseIssuer } from '../../site/src/worker/gate.js';

for (const [name, normalise] of [
['mcp worker', normaliseIssuer],
['site gate', gateNormaliseIssuer],
] as const) {
describe(`normaliseIssuer (${name})`, () => {
it('is null when unset or blank', () => {
expect(normalise(undefined)).toBeNull();
expect(normalise(' ')).toBeNull();
});

it('accepts a bare host or an https origin in any case, and returns the origin', () => {
expect(normalise('https://identity.example.test')).toBe('https://identity.example.test');
expect(normalise('identity.example.test')).toBe('https://identity.example.test');
expect(normalise('https://identity.example.test/')).toBe('https://identity.example.test');
expect(normalise('HTTPS://Identity.Example.Test')).toBe('https://identity.example.test');
});

it('drops any path, query or fragment', () => {
expect(normalise('https://identity.example.test/x/y?z=1#f')).toBe('https://identity.example.test');
});

it('treats anything that is not an https origin as unconfigured', () => {
for (const bad of [
'http://identity.example.test',
'javascript://identity.example.test',
'https://user:pass@identity.example.test',
'user@identity.example.test',
'https://',
]) {
expect(normalise(bad), bad).toBeNull();
}
});
});
}

describe('verifyBearerAuth binds iss to the normalised origin', () => {
afterEach(() => vi.unstubAllGlobals());

it('accepts iss equal to the origin and rejects prefix, slash and http variants', async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' };
const fetched: string[] = [];
vi.stubGlobal('fetch', async (input: RequestInfo | URL) => {
fetched.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
return new Response(JSON.stringify({ keys: [jwk] }), { headers: { 'content-type': 'application/json' } });
});
const sign = (iss: string) =>
new SignJWT({})
.setProtectedHeader({ alg: 'RS256', kid: 'k1' })
.setIssuer(iss)
.setSubject('user_123')
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
const req = async (iss: string) =>
new Request('https://docs.test/mcp', { headers: { authorization: `Bearer ${await sign(iss)}` } });
const configured = 'HTTPS://Iss-Bind.Example.Test/some/path';

expect(await verifyBearerAuth(await req('https://iss-bind.example.test'), configured)).toEqual({
authorized: true,
subject: 'user_123',
});
expect(fetched).toEqual(['https://iss-bind.example.test/oauth2/jwks']);
for (const iss of [
'https://iss-bind.example.test.evil.test',
'https://iss-bind.example.test/',
'http://iss-bind.example.test',
]) {
expect(await verifyBearerAuth(await req(iss), configured), iss).toEqual({ authorized: false });
}
});

it('fails closed on an issuer that is not an https origin', async () => {
const res = await verifyBearerAuth(
new Request('https://docs.test/mcp', { headers: { authorization: 'Bearer a.b.c' } }),
'http://iss-bind.example.test'
);
expect(res).toEqual({ authorized: false });
});
});
16 changes: 6 additions & 10 deletions nyuchi-docs-mcp-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,12 @@ routes = [
[vars]
TOP_K = "5"
ALLOWED_ORIGINS = "https://docs.nyuchi.com,https://docs.bundu.org"
# The WorkOS **AuthKit issuer** — the OAuth 2.1 authorization server whose
# /.well-known/openid-configuration + /oauth2/jwks verify a caller's own
# bearer token (src/auth.ts); this worker never originates a login flow.
# It is NOT auth.mukoko.com (the WorkOS *auth API* host, which serves no
# authorization-server metadata), and neither api.nyuchi.com nor
# api.mukoko.com is WorkOS at all. Not a secret — an OIDC issuer URL is
# public by design — but keep it in sync with the same value the site
# worker uses. The pre-Aug-2026 value identity.nyuchi.com has no DNS
# record any more; do not restore it.
WORKOS_ISSUER = "https://accounts.mukoko.com"
# WORKOS_ISSUER — the WorkOS **AuthKit issuer** whose /oauth2/jwks verifies a
# caller's own bearer token (src/auth.ts) — is deliberately NOT a var here. It
# is REQUIRED and set per environment as a secret (`wrangler secret put
# WORKOS_ISSUER`; owner script, 1Password nyuchi/workos), never committed, no
# default in code. Until it is set no bearer token verifies, so internal
# content stays hidden (fail closed); public docs still answer.

# Same AI Search instance the Ask-AI tab uses — read tools ride the
# existing nyuchi-docs corpus; this worker adds no ingestion of its own.
Expand Down
66 changes: 55 additions & 11 deletions site/src/worker/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { securityTxtResponse } from './security-txt.js';
export interface Env {
ASSETS: Fetcher;
WORKOS_CLIENT_ID: string;
WORKOS_ISSUER: string;
/**
* The WorkOS AuthKit issuer. REQUIRED, set per environment as a Worker
* secret — never committed, no default in code. Internal pages answer 503
* "WORKOS_ISSUER is not configured" until it is set.
*/
WORKOS_ISSUER?: string;
/**
* Optional: only present for a confidential Connect app. The app this
* gate actually uses — WorkOS's shared "Nyuchi Internal Tools" app,
Expand Down Expand Up @@ -79,19 +84,52 @@ async function pkcePair(): Promise<{ verifier: string; challenge: string }> {
return { verifier, challenge };
}

const ISSUER_MISSING = 'WORKOS_ISSUER is not configured';

// Kept identical to normaliseIssuer in nyuchi-docs-mcp-worker/src/auth.ts
// (that package's tests exercise both copies).
/**
* Parse — never concatenate — the configured AuthKit issuer (WORKOS_ISSUER)
* into an https origin. No compiled-in default.
*
* Accepts a bare host or an https origin, in any case. Any path, query or
* fragment is dropped. A blank value is null (unset); `http:`, any other
* scheme, embedded credentials and anything `URL` cannot parse are also null —
* an unusable issuer fails closed exactly like a missing one. The result is
* `URL.origin`, which the `iss` claim must equal exactly.
*/
export function normaliseIssuer(raw: string | undefined): string | null {
const value = (raw ?? '').trim();
if (!value) return null;
let url: URL;
try {
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password) return null;
return url.origin;
}

function issuerOf(env: Env): string | null {
return normaliseIssuer(env.WORKOS_ISSUER);
}

// jose's remote JWKS caches keys internally; module-level so it survives
// across requests to the same isolate instead of re-fetching every time.
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
let jwksIssuer: string | undefined;

async function verifySession(env: Env, token: string): Promise<JWTPayload | null> {
const issuer = issuerOf(env);
if (!issuer) return null;
try {
if (!jwks || jwksIssuer !== env.WORKOS_ISSUER) {
jwks = createRemoteJWKSet(new URL(`${env.WORKOS_ISSUER}/oauth2/jwks`));
jwksIssuer = env.WORKOS_ISSUER;
if (!jwks || jwksIssuer !== issuer) {
jwks = createRemoteJWKSet(new URL('/oauth2/jwks', issuer));
jwksIssuer = issuer;
}
const { payload } = await jwtVerify(token, jwks, {
issuer: env.WORKOS_ISSUER,
issuer,
audience: env.WORKOS_CLIENT_ID,
});
return payload;
Expand All @@ -100,8 +138,8 @@ async function verifySession(env: Env, token: string): Promise<JWTPayload | null
}
}

function redirectToLogin(env: Env, url: URL, verifier: string, challenge: string, state: string): Response {
const authorizeUrl = new URL(`${env.WORKOS_ISSUER}/oauth2/authorize`);
function redirectToLogin(issuer: string, env: Env, url: URL, verifier: string, challenge: string, state: string): Response {
const authorizeUrl = new URL('/oauth2/authorize', issuer);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', env.WORKOS_CLIENT_ID);
authorizeUrl.searchParams.set('redirect_uri', `${url.origin}${CALLBACK_PATH}`);
Expand All @@ -120,7 +158,7 @@ function redirectToLogin(env: Env, url: URL, verifier: string, challenge: string
});
}

async function handleCallback(env: Env, req: Request, url: URL): Promise<Response> {
async function handleCallback(issuer: string, env: Env, req: Request, url: URL): Promise<Response> {
const cookies = parseCookies(req);
const raw = cookies[OAUTH_STATE_COOKIE];
if (!raw) return new Response('Login expired — go back and try again.', { status: 400 });
Expand All @@ -147,7 +185,7 @@ async function handleCallback(env: Env, req: Request, url: URL): Promise<Respons
};
if (env.WORKOS_CLIENT_SECRET) tokenParams.client_secret = env.WORKOS_CLIENT_SECRET;

const tokenRes = await fetch(`${env.WORKOS_ISSUER}/oauth2/token`, {
const tokenRes = await fetch(new URL('/oauth2/token', issuer), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(tokenParams),
Expand Down Expand Up @@ -183,7 +221,9 @@ export default {
}

if (url.pathname === CALLBACK_PATH) {
const res = await handleCallback(env, req, url);
const issuer = issuerOf(env);
if (!issuer) return new Response(`Service Unavailable: ${ISSUER_MISSING}`, { status: 503 });
const res = await handleCallback(issuer, env, req, url);
// one-shot: the state cookie is spent whether the callback succeeded or not
res.headers.append('set-cookie', clearCookie(OAUTH_STATE_COOKIE));
return res;
Expand All @@ -205,8 +245,12 @@ export default {
return env.ASSETS.fetch(req);
}

// Fail closed: the authorization server comes only from configuration.
const issuer = issuerOf(env);
if (!issuer) return new Response(`Service Unavailable: ${ISSUER_MISSING}`, { status: 503 });

const { verifier, challenge } = await pkcePair();
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
return redirectToLogin(env, url, verifier, challenge, state);
return redirectToLogin(issuer, env, url, verifier, challenge, state);
},
} satisfies ExportedHandler<Env>;
16 changes: 6 additions & 10 deletions site/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,12 @@ enabled = true
# https://docs.nyuchi.com/oauth/callback is registered on that app.
[vars]
WORKOS_CLIENT_ID = "client_01KVTX0V2K1VM3PSC0DJ9VZWTV"
# accounts.mukoko.com is the WorkOS **AuthKit issuer** — the OAuth 2.1
# authorization server serving /.well-known/openid-configuration,
# /oauth2/authorize, /oauth2/token and /oauth2/jwks (what gate.ts calls).
# It is NOT auth.mukoko.com (the WorkOS *auth API* / JWKS-fetch host, which
# serves no authorization-server metadata), and neither api.nyuchi.com (the
# Nyuchi API gateway) nor api.mukoko.com (a separate gateway, still being
# built) is WorkOS at all. The pre-Aug-2026 value identity.nyuchi.com has no
# DNS record any more — do not restore it; a dead issuer makes every
# `visibility: internal` page unsignable-into.
WORKOS_ISSUER = "https://accounts.mukoko.com"
# WORKOS_ISSUER — the WorkOS **AuthKit issuer** (serves
# /.well-known/openid-configuration, /oauth2/authorize, /oauth2/token and
# /oauth2/jwks, which gate.ts calls) — is deliberately NOT a var here. It is
# REQUIRED and set per environment as a secret (`wrangler secret put
# WORKOS_ISSUER`; owner script, 1Password nyuchi/workos), never committed, no
# default in code. `visibility: internal` pages answer 503 until it is set.

# Secret (wrangler secret put INTERNAL_FETCH_KEY): shared with
# nyuchi-docs-mcp-worker so it can read internal pages on behalf of a
Expand Down
Loading