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
21 changes: 19 additions & 2 deletions src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,12 +962,16 @@ async function proxyToolCall(
// The OAuth provider library then passes them to the apiHandler as ctx.props
// on every authenticated call. `scopes` carries the actual scope grant from
// the original OAuth request so that resolveAuth can enforce them at call
// time (see C-1a remediation in resolveAuth).
// time (see C-1a remediation in resolveAuth). `tenantId` and `tier` are
// baked in at grant mint (#34 remediation) so session init reads them from
// props instead of re-calling provisionTenant on every request.
export interface OAuthProps {
userId?: string;
email?: string;
name?: string;
scopes?: string[];
tenantId?: string;
tier?: string;
}

// ─── Main request handler ─────────────────────────────────────
Expand Down Expand Up @@ -1070,7 +1074,20 @@ async function resolveAuth(
effectiveScopes = await resolveLegacyGrantScopes(request, env);
}

// Resolve tenant info from AUTH_SERVICE for proper tier
// #34 remediation: if the grant was minted with tenantId and tier baked
// into props, skip the per-request provisionTenant round trip. Falls
// through to the legacy path only for pre-#34 grants that carry neither.
if (oauthProps.tenantId && oauthProps.tier) {
return {
authenticated: true,
userId: oauthProps.userId,
tenantId: oauthProps.tenantId,
tier: oauthProps.tier as Tier,
scopes: effectiveScopes,
};
}

// Legacy cohort (pre-#34) — resolve tenant info from AUTH_SERVICE.
try {
const tenant = await env.AUTH_SERVICE.provisionTenant({
userId: oauthProps.userId,
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const oauthProvider = new OAuthProvider<GatewayEnv>({
// See src/oauth-handler.ts — we pass userId, email, name, and (per
// C-1a remediation) the actual scopes the token was granted so
// resolveAuth can enforce them at call time.
const oauthProps = (ctx as unknown as { props?: { userId?: string; email?: string; name?: string; scopes?: string[] } }).props;
const oauthProps = (ctx as unknown as { props?: { userId?: string; email?: string; name?: string; scopes?: string[]; tenantId?: string; tier?: string } }).props;
const response = await handleMcpRequest(request, env, oauthProps);
return addCorsHeaders(response);
},
Expand Down
35 changes: 35 additions & 0 deletions src/oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,32 @@ function buildAuthorizeRedirect(
return authorizeUrl.toString();
}

// --- Helper: resolve tenantId/tier to bake into grant props (#34) ---
//
// Called from both completeAuthorization sites (auto-approve + consent).
// On AUTH_SERVICE failure we return an empty object instead of throwing —
// that preserves the pre-#34 behavior where a grant was minted without
// tenant info and resolveAuth did the lookup per-request. The legacy
// fallback in gateway.ts:resolveAuth still handles that cohort, so we
// don't want a transient AUTH_SERVICE blip to block OAuth consent.
async function resolveTenantPropsForGrant(
env: GatewayEnv,
userId: string,
): Promise<{ tenantId?: string; tier?: string }> {
try {
const tenant = await env.AUTH_SERVICE.provisionTenant({
userId,
source: 'oauth',
});
return { tenantId: tenant.tenantId, tier: tenant.tier };
} catch (err) {
console.error(
`[oauth-handler] provisionTenant failed for userId=${userId} during grant mint: ${err instanceof Error ? err.message : String(err)}`,
);
return {};
}
}

// --- "Coming soon" page when signups are gated ---

function renderComingSoonPage(): string {
Expand Down Expand Up @@ -636,6 +662,7 @@ async function handleGetAuthorize(request: Request, env: GatewayEnv): Promise<Re
// The user already authenticated (login/signup/social) and we control
// all scopes. Showing a consent page adds latency that can cause
// Claude Code's local callback listener to time out.
const tenantProps = await resolveTenantPropsForGrant(env, identity.userId);
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReqInfo,
userId: identity.userId,
Expand All @@ -652,6 +679,11 @@ async function handleGetAuthorize(request: Request, env: GatewayEnv): Promise<Re
// to the gateway's apiHandler so resolveAuth can enforce it at
// call time (instead of hardcoding ['generate','read']).
scopes: oauthReqInfo.scope,
// #34 remediation: bake tenantId and tier into props at grant
// mint so session init doesn't have to re-resolve them on every
// request. tenantProps is empty on provisionTenant failure —
// resolveAuth falls back to the legacy per-request path then.
...tenantProps,
},
});
return Response.redirect(redirectTo, 302);
Expand Down Expand Up @@ -747,6 +779,7 @@ async function handlePostAuthorize(request: Request, env: GatewayEnv): Promise<R
? oauthReqInfo.scope
: DEFAULT_CONSENT_SCOPES;

const tenantProps = await resolveTenantPropsForGrant(env, identity.userId);
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReqInfo,
userId: identity.userId,
Expand All @@ -762,6 +795,8 @@ async function handlePostAuthorize(request: Request, env: GatewayEnv): Promise<R
// C-1a remediation: thread the actual OAuth scope grant through
// to the gateway's apiHandler so resolveAuth can enforce it.
scopes: effectiveScope,
// #34 remediation: see auto-approve branch above.
...tenantProps,
},
});

Expand Down
85 changes: 85 additions & 0 deletions test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,91 @@ describe('handleMcpRequest', () => {
});
});

describe('OAuth grant props (#34) — tenantId/tier baked in at mint', () => {
// The gateway's OAuth grant path used to mint grants with only
// {userId, email, name, scopes} and call provisionTenant on every
// request to resolve tenantId/tier. That re-resolution was both
// wasteful and a drift vector: if AUTH_SERVICE's view of the tenant
// tier changed between grant mint and session init, callers could
// silently downgrade (pro/enterprise → blessed on scaffold_create).
//
// Post-#34 grants bake tenantId and tier into props at mint time;
// resolveAuth reads them directly and must NOT call provisionTenant
// on the hot path. Legacy grants (no tenantId/tier in props) still
// fall through to the provisionTenant call for compatibility.

it('uses tenantId/tier from oauthProps and does not call provisionTenant', async () => {
let provisionTenantCalls = 0;
const env = makeEnv({
AUTH_SERVICE: {
...mockAuthService(),
provisionTenant: async () => {
provisionTenantCalls += 1;
return { tenantId: 'drifted-tenant', userId: 'user-1', tier: 'free', delinquent: false, createdAt: '' };
},
},
});
const oauthProps = {
userId: 'user-1',
email: 'user@stackbilt.dev',
name: 'User',
scopes: ['generate', 'read'],
tenantId: 'tenant-mint-time',
tier: 'pro',
};

const req = new Request('https://mcp.stackbilt.dev/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test' } },
}),
});
const res = await handleMcpRequest(req, env, oauthProps);

expect(res.status).toBe(200);
expect(provisionTenantCalls).toBe(0);
});

it('falls back to provisionTenant for legacy grants missing tenantId/tier', async () => {
let provisionTenantCalls = 0;
const env = makeEnv({
AUTH_SERVICE: {
...mockAuthService(),
provisionTenant: async () => {
provisionTenantCalls += 1;
return { tenantId: 'legacy-tenant', userId: 'user-1', tier: 'team', delinquent: false, createdAt: '' };
},
},
});
const oauthProps = {
userId: 'user-1',
email: 'user@stackbilt.dev',
name: 'User',
scopes: ['generate', 'read'],
// tenantId and tier deliberately omitted — legacy cohort
};

const req = new Request('https://mcp.stackbilt.dev/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test' } },
}),
});
const res = await handleMcpRequest(req, env, oauthProps);

expect(res.status).toBe(200);
expect(provisionTenantCalls).toBe(1);
});
});

describe('DELETE session', () => {
it('terminates an active session', async () => {
const env = makeEnv();
Expand Down
125 changes: 125 additions & 0 deletions test/oauth-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,131 @@ describe('OAuth handler — #28 empty-scope consent flow', () => {
});
});

describe('OAuth handler — #34 tenantId/tier baked into grant props', () => {
// Before #34, completeAuthorization was called with props limited to
// {userId, email, name, scopes}. Every request then re-resolved tier
// via provisionTenant — a drift vector where grant-time vs. session-time
// tier divergence silently downgraded pro/enterprise callers to blessed
// engine templates on scaffold_create.
//
// Both grant-mint sites (auto-approve and consent-approve) must call
// provisionTenant once at mint time and bake tenantId + tier into props
// so the gateway can read them directly on every request.

async function mkIdentityToken(clientId: string, redirectUri: string): Promise<string> {
return signIdentityToken(TEST_SECRET, {
userId: 'user-42',
email: 'kurt@stackbilt.dev',
name: 'Kurt',
clientId,
redirectUri,
});
}

it('auto-approve bakes tenantId and tier into grant props', async () => {
const provider = mockOAuthProvider();
const authService = mockAuthService({
provisionTenant: vi.fn(async () => ({
tenantId: 'tenant-pro',
userId: 'user-42',
tier: 'pro',
delinquent: false,
createdAt: '2026-01-01T00:00:00Z',
})),
});
const env = makeEnv({
OAUTH_PROVIDER: provider as unknown as GatewayEnv['OAUTH_PROVIDER'],
AUTH_SERVICE: authService as unknown as GatewayEnv['AUTH_SERVICE'],
});

const identityToken = await mkIdentityToken(
MOCK_AUTH_REQUEST.clientId,
MOCK_AUTH_REQUEST.redirectUri,
);
const req = getRequest(`/authorize?identity_token=${encodeURIComponent(identityToken)}`);
const res = await callHandler(req, env);

expect(res.status).toBe(302);
expect(authService.provisionTenant).toHaveBeenCalledWith({ userId: 'user-42', source: 'oauth' });
expect(provider.completeAuthorization).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
userId: 'user-42',
tenantId: 'tenant-pro',
tier: 'pro',
}),
}),
);
});

it('consent-approve bakes tenantId and tier into grant props', async () => {
const provider = mockOAuthProvider();
const authService = mockAuthService({
provisionTenant: vi.fn(async () => ({
tenantId: 'tenant-enterprise',
userId: 'user-42',
tier: 'enterprise',
delinquent: false,
createdAt: '2026-01-01T00:00:00Z',
})),
});
const env = makeEnv({
OAUTH_PROVIDER: provider as unknown as GatewayEnv['OAUTH_PROVIDER'],
AUTH_SERVICE: authService as unknown as GatewayEnv['AUTH_SERVICE'],
});

const identityToken = await mkIdentityToken(
MOCK_AUTH_REQUEST.clientId,
MOCK_AUTH_REQUEST.redirectUri,
);
const req = formRequest('/authorize', {
action: 'approve',
oauth_params: makeOAuthParamsB64(),
identity_token: identityToken,
});
const res = await callHandler(req, env);

expect(res.status).toBe(302);
expect(authService.provisionTenant).toHaveBeenCalledWith({ userId: 'user-42', source: 'oauth' });
expect(provider.completeAuthorization).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
tenantId: 'tenant-enterprise',
tier: 'enterprise',
}),
}),
);
});

it('mints a grant without tenantId/tier when provisionTenant fails (graceful degradation)', async () => {
const provider = mockOAuthProvider();
const authService = mockAuthService({
provisionTenant: vi.fn(async () => {
throw new Error('auth service unreachable');
}),
});
const env = makeEnv({
OAUTH_PROVIDER: provider as unknown as GatewayEnv['OAUTH_PROVIDER'],
AUTH_SERVICE: authService as unknown as GatewayEnv['AUTH_SERVICE'],
});

const identityToken = await mkIdentityToken(
MOCK_AUTH_REQUEST.clientId,
MOCK_AUTH_REQUEST.redirectUri,
);
const req = getRequest(`/authorize?identity_token=${encodeURIComponent(identityToken)}`);
const res = await callHandler(req, env);

expect(res.status).toBe(302);
expect(provider.completeAuthorization).toHaveBeenCalledOnce();
const call = (provider.completeAuthorization as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as {
props: { tenantId?: string; tier?: string };
};
expect(call.props.tenantId).toBeUndefined();
expect(call.props.tier).toBeUndefined();
});
});

describe('Cookie helpers (M-1 / HD-2)', () => {
it('getCookie returns named value from multi-cookie header', () => {
const req = new Request('https://x/', {
Expand Down
Loading