From 682c2018a5c327e9e4526321a5870418a3b05aea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 19:23:42 +0000 Subject: [PATCH] feat(auth): add kody login --org to bind an organization Append ?org= on the OAuth authorize URL so the grant binds to that org. Leave the resource canonical; production rejects a resource query. Omit --org and the server still binds the sole or signup org. Co-authored-by: Kent C. Dodds --- README.md | 3 +- skills/kody/SKILL.md | 1 + src/auth.ts | 6 +- src/cli.ts | 10 ++++ src/help.ts | 7 ++- src/oauth-provider.ts | 20 +++++++ test/auth.test.ts | 136 ++++++++++++++++++++++++++++++++++++++++++ test/cli.test.ts | 23 +++++++ 8 files changed, 203 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2f7b889..689d004 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ MCP connection. ```bash npm install -g @kodycodes/cli kody login +kody login --org acme ``` Or run via `npx @kodycodes/cli` without a global install. @@ -59,7 +60,7 @@ Or run via `npx @kodycodes/cli` without a global install. | --- | --- | | `kody install` | Detect running local MCP clients, write their config, and start host OAuth. **Recommended long-term path.** | | `kody skill install` | Copies the getting-started skill into Claude Code / Cursor / Agents. | -| `kody login` | Browser OAuth (CIMD + PKCE) for the CLI itself. Stores access and refresh tokens. | +| `kody login` | Browser OAuth (CIMD + PKCE) for the CLI itself. Stores access and refresh tokens. `kody login --org acme` binds that grant to organization `acme`. | | `kody logout` | Deletes stored CLI OAuth credentials and any stored bootstrap/API token. | | `kody status` | Shows CLI login / stored API token state without printing secrets. | | `kody auth bootstrap --code` | Redeems a one-shot `kody_bc_…` from MCP `cliCredentialBootstrap` and stores the resulting `kody_at_…` for `execute --local` (never prints the token). | diff --git a/skills/kody/SKILL.md b/skills/kody/SKILL.md index b529da3..61e54ce 100644 --- a/skills/kody/SKILL.md +++ b/skills/kody/SKILL.md @@ -50,6 +50,7 @@ Kody over MCP. ```bash npm install -g @kodycodes/cli kody login +kody login --org acme kody search "what can you do" kody search --domain email kody execute --code "import { kody } from 'kody:runtime'\nexport default async function main() { return await kody.search({ query: 'what can you do' }) }" diff --git a/src/auth.ts b/src/auth.ts index a99a306..687893c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -17,7 +17,7 @@ import { defaultScopes, loginTimeoutMs, } from './defaults.js' -import { createCliOAuthProvider } from './oauth-provider.js' +import { createCliOAuthProvider, orgSlugFromFlag } from './oauth-provider.js' import { redactError } from './redact.js' import { loadCredentials, @@ -28,6 +28,8 @@ import { export type LoginOptions = { mcpUrl?: string + /** Organization slug. Omit to let the server bind the sole or signup org. */ + org?: string openBrowser?: boolean backend?: SecretBackend timeoutMs?: number @@ -245,6 +247,7 @@ export async function login(options: LoginOptions = {}): Promise<{ backendPath?: string }> { const mcpUrl = options.mcpUrl ?? defaultMcpUrl + const org = options.org === undefined ? undefined : orgSlugFromFlag(options.org) const redirectUri = cliRedirectUrl() const expectedState = crypto.randomUUID() const server = await startCallbackServer(redirectUri) @@ -255,6 +258,7 @@ export async function login(options: LoginOptions = {}): Promise<{ loadStoredTokens: false, openBrowser: options.openBrowser !== false, expectedState, + ...(org ? { org } : {}), onAuthorizationUrl: (url) => { authorizationUrl = url options.onAuthorizationUrl?.(url) diff --git a/src/cli.ts b/src/cli.ts index 471c4f2..f5cf4b7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { import { defaultApiUrl, defaultMcpUrl, modernMcpProtocolVersion } from './defaults.js' import { usage } from './help.js' import { ensureFreshCredentials, login } from './auth.js' +import { orgSlugFromFlag } from './oauth-provider.js' import { deleteCredentials, loadCredentials } from './store.js' import { callKodyTool, formatToolResult, listKodyTools } from './mcp.js' import { runInstall } from './install.js' @@ -102,6 +103,7 @@ function parseKnown(args: Array) { 'max-lifetime-seconds': { type: 'string' }, project: { type: 'boolean' }, 'no-browser': { type: 'boolean' }, + org: { type: 'string' }, clients: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, }, @@ -175,6 +177,9 @@ async function dispatch( ) { throw new Error('--allow-private-network can only be used with execute --local.') } + if (parsed.values.org !== undefined && parsed.command !== 'login') { + throw new Error('`--org` can only be used with `kody login`.') + } switch (parsed.command) { case 'help': @@ -184,9 +189,14 @@ async function dispatch( write(`${readPackageVersion()}\n`) return 0 case 'login': { + const org = + typeof parsed.values.org === 'string' + ? orgSlugFromFlag(parsed.values.org) + : undefined write('Opening the Kody login page in your browser…\n') const result = await login({ mcpUrl, + ...(org ? { org } : {}), openBrowser: parsed.values['no-browser'] !== true, onAuthorizationUrl: (url) => { write(`If the browser does not open, visit:\n${url.href}\n`) diff --git a/src/help.ts b/src/help.ts index cd2d624..bb4add6 100644 --- a/src/help.ts +++ b/src/help.ts @@ -7,7 +7,7 @@ export const usage = `Kody CLI ${readPackageVersion()} Install Kody as a remote MCP server in local agents, or use this CLI as a local client. Usage: - kody login [--mcp-url ] [--no-browser] + kody login [--mcp-url ] [--org ] [--no-browser] kody logout [--mcp-url ] [--api-url ] kody status [--mcp-url ] [--api-url ] kody auth bootstrap --code (--lifetime short|long | --idle-ttl-seconds --max-lifetime-seconds ) [--api-url ] @@ -23,6 +23,11 @@ Usage: VS Code, Goose, and others). For web-based clients (ChatGPT, Claude.ai, Grok), see ${onboardingUrl(defaultMcpUrl)} + --org + Bind this login to an organization. + Example: \`kody login --org acme\` + Omit \`--org\` and the server binds your only organization. + --clients Comma-separated ids: ${hostIds.join(', ')} auth bootstrap diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index bb7f9cc..740b20b 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -29,6 +29,17 @@ export function buildCliClientMetadata(): OAuthClientMetadata { } } +/** Public org slug: trim and lowercase, matching the server's `?org=` rules. */ +export function orgSlugFromFlag(value: string): string { + const slug = value.trim().toLowerCase() + if (!slug) { + throw new Error( + '`--org` requires an organization slug. Example: `kody login --org acme`.', + ) + } + return slug +} + export function createCliOAuthProvider(input: { mcpUrl: string redirectUri: URL @@ -36,6 +47,12 @@ export function createCliOAuthProvider(input: { loadStoredTokens: boolean openBrowser: boolean expectedState: string + /** + * Bind the grant to this org. Set on the authorize URL (`?org=`). + * Do not put it on the OAuth `resource`: production rejects a resource + * that adds a query (`?org=` or `?profile=`). + */ + org?: string onAuthorizationUrl?: (url: URL) => void }): OAuthClientProvider { const clientMetadataUrl = cliClientMetadataUrl(input.mcpUrl) @@ -91,6 +108,9 @@ export function createCliOAuthProvider(input: { tokens = next }, async redirectToAuthorization(authorizationUrl) { + if (input.org !== undefined) { + authorizationUrl.searchParams.set('org', orgSlugFromFlag(input.org)) + } input.onAuthorizationUrl?.(authorizationUrl) if (input.openBrowser) { await openUrl(authorizationUrl.href) diff --git a/test/auth.test.ts b/test/auth.test.ts index fd6f516..c3c34af 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test' import { credentialsFromTokens, isAccessTokenExpired, + login, } from '../src/auth.js' import { cliClientMetadataUrl, @@ -14,6 +15,7 @@ import { import { buildCliClientMetadata, createCliOAuthProvider, + orgSlugFromFlag, } from '../src/oauth-provider.js' import type { StoredCredentials } from '../src/store.js' @@ -121,3 +123,137 @@ test('CLI OAuth identity is CIMD with a fixed loopback redirect', async () => { assert.equal(client?.client_id, provider.clientMetadataUrl) assert.equal(provider.clientMetadata.scope, 'openid profile email') }) + +test('orgSlugFromFlag lowercases and rejects a blank slug', () => { + assert.equal(orgSlugFromFlag('Acme'), 'acme') + assert.equal(orgSlugFromFlag(' KentCDodds '), 'kentcdodds') + assert.throws(() => orgSlugFromFlag(' '), /kody login --org acme/) +}) + +test('login authorize URL gets ?org= and keeps the canonical resource', async () => { + const seen: Array = [] + const provider = createCliOAuthProvider({ + mcpUrl: 'https://kody.codes/mcp', + redirectUri: cliRedirectUrl(), + loadStoredTokens: false, + openBrowser: false, + expectedState: 'state', + org: 'Acme', + onAuthorizationUrl: (url) => { + seen.push(new URL(url.href)) + }, + }) + const authorizationUrl = new URL( + 'https://kody.codes/oauth/authorize?response_type=code&resource=https%3A%2F%2Fkody.codes%2Fmcp&profile=CI+Bot', + ) + await provider.redirectToAuthorization(authorizationUrl) + assert.equal(seen.length, 1) + const url = seen[0] + assert.ok(url) + assert.equal(url.searchParams.get('org'), 'acme') + assert.equal(url.searchParams.get('profile'), 'CI Bot') + assert.equal(url.searchParams.get('resource'), 'https://kody.codes/mcp') + assert.equal(new URL(url.searchParams.get('resource') ?? '').search, '') +}) + +function oauthDiscoveryFetch(origin: string): typeof fetch { + const metadata = { + issuer: origin, + authorization_endpoint: `${origin}/oauth/authorize`, + token_endpoint: `${origin}/oauth/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none'], + client_id_metadata_document_supported: true, + } + const resource = { + resource: `${origin}/mcp`, + authorization_servers: [origin], + scopes_supported: ['openid', 'profile', 'email'], + bearer_methods_supported: ['header'], + } + return async (input) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ) + if (url.pathname.includes('oauth-protected-resource')) { + return Response.json(resource) + } + if ( + url.pathname.includes('oauth-authorization-server') || + url.pathname.includes('openid-configuration') + ) { + return Response.json(metadata) + } + return new Response('not found', { status: 404 }) + } +} + +test('login appends ?org= on the authorize URL and omits it otherwise', async () => { + const origin = 'https://oauth.test' + const fetchFn = oauthDiscoveryFetch(origin) + const withOrg: Array = [] + await assert.rejects( + () => + login({ + mcpUrl: `${origin}/mcp`, + org: 'Acme', + openBrowser: false, + timeoutMs: 200, + fetchFn, + onAuthorizationUrl: (url) => { + withOrg.push(new URL(url.href)) + }, + }), + /Timed out waiting for the browser login/, + ) + assert.equal(withOrg.length, 1) + const url = withOrg[0] + assert.ok(url) + assert.equal(url.origin + url.pathname, `${origin}/oauth/authorize`) + assert.equal(url.searchParams.get('org'), 'acme') + assert.equal(url.searchParams.get('resource'), `${origin}/mcp`) + + const withoutOrg: Array = [] + await assert.rejects( + () => + login({ + mcpUrl: `${origin}/mcp`, + openBrowser: false, + timeoutMs: 200, + fetchFn, + onAuthorizationUrl: (url) => { + withoutOrg.push(new URL(url.href)) + }, + }), + /Timed out waiting for the browser login/, + ) + assert.equal(withoutOrg.length, 1) + assert.equal(withoutOrg[0]?.searchParams.get('org'), null) + assert.equal(withoutOrg[0]?.searchParams.get('resource'), `${origin}/mcp`) +}) + +test('authorize URL omits org when the flag is absent', async () => { + let seen: URL | undefined + const provider = createCliOAuthProvider({ + mcpUrl: 'https://kody.codes/mcp', + redirectUri: cliRedirectUrl(), + loadStoredTokens: false, + openBrowser: false, + expectedState: 'state', + onAuthorizationUrl: (url) => { + seen = new URL(url.href) + }, + }) + const authorizationUrl = new URL( + 'https://kody.codes/oauth/authorize?response_type=code&resource=https%3A%2F%2Fkody.codes%2Fmcp', + ) + await provider.redirectToAuthorization(authorizationUrl) + assert.equal(seen?.searchParams.get('org'), null) + assert.equal(seen?.searchParams.get('resource'), 'https://kody.codes/mcp') +}) diff --git a/test/cli.test.ts b/test/cli.test.ts index 75553ba..9c4e8cb 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -58,6 +58,7 @@ test('resolveCommand maps subcommands and flags', () => { resolveCommand(['execute', '--file', 'mod.js']).values.file, 'mod.js', ) + assert.equal(resolveCommand(['login', '--org', 'Acme']).values.org, 'Acme') assert.equal( resolveCommand(['execute', '--invoke', 'kody:@scope/pkg/export']).values.invoke, 'kody:@scope/pkg/export', @@ -447,6 +448,28 @@ test('help documents the api command', async () => { assert.equal(code, 0) assert.match(stdout, /kody api /) assert.match(stdout, /usageGet/) + assert.match(stdout, /kody login \[--mcp-url \] \[--org \]/) + assert.match(stdout, /kody login --org acme/) +}) + +test('--org is login-only and requires a slug', async () => { + let stderr = '' + const other = await runCli(['search', '--org', 'acme'], { + stderr: (text) => { + stderr += text + }, + }) + assert.equal(other, 1) + assert.match(stderr, /`--org` can only be used with `kody login`/) + + stderr = '' + const blank = await runCli(['login', '--org', ' '], { + stderr: (text) => { + stderr += text + }, + }) + assert.equal(blank, 1) + assert.match(stderr, /kody login --org acme/) }) test('execute with --token (no --local) uses CapabilityProxy and never requires login', async () => {