rakomi 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,15 @@ npx rakomi login
9
9
  ```
10
10
 
11
11
  Signs you in with your browser (loopback OAuth + PKCE). Add `--no-browser` on a headless/SSH
12
- session to get a short code to enter on another device instead.
12
+ session to get a short code to enter on another device instead. If you don't already have a
13
+ `accounts.rakomi.com` session or an invitation link, pass `--tenant-id <uuid>` (or set
14
+ `RAKOMI_PLATFORM_TENANT_ID`) so sign-in knows which tenant to authenticate against — ask your
15
+ Rakomi administrator for the right value.
16
+
17
+ `rakomi whoami` shows the tenant you signed in against ("Home tenant") and the tenant your other
18
+ commands should treat as active ("Active tenant"). `rakomi use <tenant-id>` remembers a tenant id
19
+ locally for that; `--tenant <tenant-id>` overrides it for one command. Neither is a verified
20
+ grant — the tenant `whoami` shows as your Home tenant is the only one the server has confirmed.
13
21
 
14
22
  ## Connect an AI agent
15
23
 
@@ -45,7 +53,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
45
53
  | --- | --- |
46
54
  | `login` | sign in (browser by default; `--no-browser` for a device code) |
47
55
  | `logout` | clear the local session |
48
- | `whoami` | show the signed-in account |
56
+ | `whoami` | show the signed-in account, home tenant, and active tenant |
57
+ | `use <tenant-id>` | remember a tenant id locally for `whoami`/future commands |
49
58
  | `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
50
59
  | `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
51
60
  | `tenants list` | list tenants you provisioned |
@@ -59,6 +68,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
59
68
  | `--dry-run` | print what would happen, make no writes or mutating calls |
60
69
  | `--no-browser` | use the device-code flow instead of opening a browser |
61
70
  | `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
71
+ | `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
72
+ | `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
62
73
  | `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
63
74
  | `--undo` | restore the `.mcp.json` `connect` last backed up |
64
75
  | `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
@@ -0,0 +1,51 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { request } from './http.js';
3
+ export const NO_OIDC_SOURCE_MESSAGE = 'rakomi login --ci found no OIDC identity token. Set RAKOMI_OIDC_TOKEN, pass ' +
4
+ '--oidc-token-file <path>, or run on a CI platform this command recognizes automatically ' +
5
+ '(GitHub Actions with `permissions: id-token: write`, or GitLab CI with CI_JOB_JWT_V2).';
6
+ export async function resolveCiOidcToken(deps, opts) {
7
+ if (opts.oidcTokenFile) {
8
+ let raw;
9
+ try {
10
+ raw = opts.readTextFile(opts.oidcTokenFile);
11
+ }
12
+ catch {
13
+ throw new CliError(`Could not read --oidc-token-file ${JSON.stringify(opts.oidcTokenFile)}.`, EXIT.FAIL);
14
+ }
15
+ const token = raw.trim();
16
+ if (token.length === 0) {
17
+ throw new CliError(`--oidc-token-file ${JSON.stringify(opts.oidcTokenFile)} was empty.`, EXIT.FAIL);
18
+ }
19
+ return token;
20
+ }
21
+ if (opts.env.RAKOMI_OIDC_TOKEN) {
22
+ return opts.env.RAKOMI_OIDC_TOKEN;
23
+ }
24
+ if (opts.env.ACTIONS_ID_TOKEN_REQUEST_URL && opts.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
25
+ return requestGitHubActionsIdToken(deps, opts.env.ACTIONS_ID_TOKEN_REQUEST_URL, opts.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, opts.audience);
26
+ }
27
+ const gitlabToken = opts.env.CI_JOB_JWT_V2 || opts.env.CI_JOB_JWT;
28
+ if (gitlabToken) {
29
+ return gitlabToken;
30
+ }
31
+ throw new CliError(NO_OIDC_SOURCE_MESSAGE, EXIT.FAIL);
32
+ }
33
+ async function requestGitHubActionsIdToken(deps, requestUrl, requestToken, audience) {
34
+ let url;
35
+ try {
36
+ url = new URL(requestUrl);
37
+ }
38
+ catch {
39
+ throw new CliError('ACTIONS_ID_TOKEN_REQUEST_URL is not a valid URL.', EXIT.FAIL);
40
+ }
41
+ url.searchParams.set('audience', audience);
42
+ const result = await request(deps, {
43
+ method: 'GET',
44
+ url: url.toString(),
45
+ headers: { authorization: `Bearer ${requestToken}` },
46
+ });
47
+ if (result.status !== 200 || typeof result.body.value !== 'string' || result.body.value.length === 0) {
48
+ throw new CliError('Could not obtain a GitHub Actions OIDC token (check `permissions: id-token: write` on this job).', EXIT.FAIL);
49
+ }
50
+ return result.body.value;
51
+ }
@@ -2,6 +2,7 @@ import { AmbiguousClientError, clientDisplayName, resolveTargetClients } from '.
2
2
  import { pollCimdMaterialization, requestWriteElevation } from '../connect-client.js';
3
3
  import { DEFAULT_MCP_URL } from '../env.js';
4
4
  import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
5
+ import { resolveDpopKey } from '../install-key.js';
5
6
  import { mcpConfigPath, undoMcpConfigWrite, writeRakomiMcpServerEntry } from '../mcp-config.js';
6
7
  const CLAUDE_DESKTOP_INSTRUCTIONS = [
7
8
  'Claude Desktop connects to remote MCP servers through Connectors, configured from your Claude',
@@ -24,7 +25,7 @@ export async function runConnect(deps) {
24
25
  if (!session)
25
26
  throw new NotLoggedInError();
26
27
  if (deps.status) {
27
- await reportStatus(deps, session.access_token);
28
+ await reportStatus(deps, session);
28
29
  return;
29
30
  }
30
31
  let targets;
@@ -41,24 +42,30 @@ export async function runConnect(deps) {
41
42
  deps.stdout.write(`${clientDisplayName(client)}:\n${CLAUDE_DESKTOP_INSTRUCTIONS}\n`);
42
43
  continue;
43
44
  }
44
- await connectClaudeCode(deps, session.access_token);
45
+ await connectClaudeCode(deps, session);
45
46
  }
46
47
  }
47
- async function reportStatus(deps, accessToken) {
48
+ async function reportStatus(deps, session) {
48
49
  if (!deps.cimdUrl) {
49
50
  throw new UsageError('`connect --status` needs `--cimd-url <url>` — the CIMD URL `connect` printed when it started waiting.');
50
51
  }
51
- const row = await pollCimdMaterialization(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken, cimdUrl: deps.cimdUrl });
52
+ const dpop = resolveDpopKey(deps.keys, session);
53
+ const row = await pollCimdMaterialization(deps, {
54
+ apiBaseUrl: deps.apiBaseUrl,
55
+ accessToken: session.access_token,
56
+ cimdUrl: deps.cimdUrl,
57
+ dpop,
58
+ });
52
59
  if (!row) {
53
60
  deps.stdout.write('Not connected yet — finish sign-in in your MCP client, then run this command again.\n');
54
61
  return;
55
62
  }
56
63
  deps.stdout.write(`Connected — access: ${row.agent_access.level}${row.agent_access.owner_granted ? ' (owner-granted)' : ''}.\n`);
57
64
  if (deps.write && row.agent_access.level !== 'write') {
58
- await requestWrite(deps, accessToken, row.id);
65
+ await requestWrite(deps, session, row.id);
59
66
  }
60
67
  }
61
- async function connectClaudeCode(deps, accessToken) {
68
+ async function connectClaudeCode(deps, session) {
62
69
  if (deps.dryRun) {
63
70
  deps.stdout.write(`Would write ${mcpConfigPath(deps.cwd)} with mcpServers.rakomi -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
64
71
  return;
@@ -78,7 +85,13 @@ async function connectClaudeCode(deps, accessToken) {
78
85
  'check the Agents page in the dashboard.\n');
79
86
  return;
80
87
  }
81
- const row = await pollCimdMaterialization(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken, cimdUrl: deps.cimdUrl });
88
+ const dpop = resolveDpopKey(deps.keys, session);
89
+ const row = await pollCimdMaterialization(deps, {
90
+ apiBaseUrl: deps.apiBaseUrl,
91
+ accessToken: session.access_token,
92
+ cimdUrl: deps.cimdUrl,
93
+ dpop,
94
+ });
82
95
  if (!row) {
83
96
  deps.stdout.write(`\nNot connected yet. Ctrl+C is safe — resume any time with:\n` +
84
97
  ` rakomi connect --status --cimd-url ${deps.cimdUrl}\n`);
@@ -89,11 +102,12 @@ async function connectClaudeCode(deps, accessToken) {
89
102
  if (deps.ci) {
90
103
  throw new InteractiveRequiredError('`connect --write` cannot run under --ci — write-access needs a human owner decision.');
91
104
  }
92
- await requestWrite(deps, accessToken, row.id);
105
+ await requestWrite(deps, session, row.id);
93
106
  }
94
107
  }
95
- async function requestWrite(deps, accessToken, oauthClientId) {
96
- await requestWriteElevation(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken, oauthClientId });
108
+ async function requestWrite(deps, session, oauthClientId) {
109
+ const dpop = resolveDpopKey(deps.keys, session);
110
+ await requestWriteElevation(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken: session.access_token, oauthClientId, dpop });
97
111
  deps.stdout.write("Write-access request sent to your tenant's owner(s) — if that's you, you'll approve it yourself " +
98
112
  'from the dashboard Agents page. Destructive actions will still ask a human every time.\n');
99
113
  }
@@ -1,10 +1,39 @@
1
- import { accountsBaseUrl, apiBaseUrl, clientId as resolveClientId, isCimdClientId } from '../env.js';
1
+ import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
+ import { generateEphemeralDpopKey } from '../dpop.js';
3
+ import { accountsBaseUrl, apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, isValidTenantId, platformTenantId, } from '../env.js';
2
4
  import { CliError, EXIT } from '../errors.js';
5
+ import { bindInstallKey, getOrCreateInstallKey, peekInstallKey, persistInstallKey, signClientAssertion, } from '../install-key.js';
3
6
  import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
4
7
  import { loginWithDeviceGrant } from '../oauth-device.js';
8
+ import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
5
9
  import { generatePkcePair } from '../pkce.js';
6
10
  export const LOGIN_SCOPE = 'openid profile email clients:read elevation:request';
7
11
  const LOOPBACK_TIMEOUT_MS = 5 * 60_000;
12
+ function cimdUnsupportedMessage(cid) {
13
+ return ('This Rakomi server does not have CIMD-based sign-in enabled, so the default client_id ' +
14
+ `(${cid}) cannot be used to sign in right now. Ask your tenant admin to register a client in ` +
15
+ 'the dashboard, then run `rakomi login --client <your-client-id>` (or set RAKOMI_CLIENT_ID) ' +
16
+ 'instead.');
17
+ }
18
+ function platformTenantMissingMessage() {
19
+ return ('rakomi login needs to know which tenant to authenticate your account against. Set ' +
20
+ 'RAKOMI_PLATFORM_TENANT_ID to a real tenant UUID, or pass --tenant-id <uuid>. If neither is ' +
21
+ 'set, this Rakomi environment likely does not have a platform tenant provisioned yet — ask ' +
22
+ 'your operator.');
23
+ }
24
+ function explicitTenantIdMalformedMessage(value) {
25
+ return (`--tenant-id "${value}" is not a valid tenant id — expected a well-formed UUID (not the nil ` +
26
+ 'or max UUID). Pass the real tenant id, or omit --tenant-id to fall back to RAKOMI_PLATFORM_TENANT_ID.');
27
+ }
28
+ function resolvePlatformTenantId(env, explicit) {
29
+ if (explicit !== undefined) {
30
+ if (!isValidTenantId(explicit)) {
31
+ throw new CliError(explicitTenantIdMalformedMessage(explicit), EXIT.FAIL);
32
+ }
33
+ return explicit;
34
+ }
35
+ return platformTenantId(env);
36
+ }
8
37
  export async function runLogin(deps) {
9
38
  const apiUrl = apiBaseUrl(deps.env);
10
39
  const cid = resolveClientId(deps.env);
@@ -13,21 +42,42 @@ export async function runLogin(deps) {
13
42
  "default CIMD client_id does not support it. Pass --client <your-client-id> (create one " +
14
43
  'in the dashboard) for headless/SSH sign-in, or omit --no-browser to sign in via loopback.', EXIT.FAIL);
15
44
  }
45
+ if (!deps.noBrowser && isCimdClientId(cid)) {
46
+ const supported = await isCimdPlatformSupported(deps, apiUrl);
47
+ if (supported === false) {
48
+ throw new CliError(cimdUnsupportedMessage(cid), EXIT.FAIL);
49
+ }
50
+ }
51
+ const installKey = isCimdClientId(cid) ? getOrCreateInstallKey(deps.keys, cid) : undefined;
52
+ let tenantId;
53
+ if (!deps.noBrowser && isCimdClientId(cid)) {
54
+ tenantId = resolvePlatformTenantId(deps.env, deps.explicitTenantId);
55
+ if (!tenantId) {
56
+ throw new CliError(platformTenantMissingMessage(), EXIT.FAIL);
57
+ }
58
+ }
16
59
  const token = deps.noBrowser
17
60
  ? await runDeviceGrantLogin(deps, apiUrl, cid)
18
- : await runLoopbackLogin(deps, apiUrl, cid);
61
+ : await runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId);
19
62
  const session = {
20
63
  access_token: token.access_token,
21
64
  refresh_token: token.refresh_token,
22
- token_type: 'Bearer',
65
+ token_type: token.token_type === 'DPoP' ? 'DPoP' : 'Bearer',
23
66
  expires_at: deps.now() + token.expires_in * 1000,
24
67
  api_base_url: apiUrl,
25
68
  client_id: cid,
69
+ ...(tenantId ? { home_tenant_id: tenantId } : {}),
26
70
  };
71
+ if (token.token_type !== 'Bearer' && token.token_type !== 'DPoP') {
72
+ deps.stdout.write(`(debug) unexpected token_type "${token.token_type}" from the server — treating this session as Bearer.\n`);
73
+ }
27
74
  deps.session.write(session);
28
75
  deps.stdout.write('Logged in to Rakomi.\n');
76
+ if (installKey) {
77
+ await bindInstallKey(deps, deps.keys, { apiBaseUrl: apiUrl, clientId: cid, accessToken: token.access_token, dpopBound: session.token_type === 'DPoP' }, installKey);
78
+ }
29
79
  }
30
- async function runLoopbackLogin(deps, apiUrl, cid) {
80
+ async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
31
81
  const listener = await deps.startLoopback();
32
82
  try {
33
83
  const pkce = generatePkcePair();
@@ -37,6 +87,7 @@ async function runLoopbackLogin(deps, apiUrl, cid) {
37
87
  redirectUri: listener.redirectUri,
38
88
  pkce,
39
89
  scope: LOGIN_SCOPE,
90
+ tenantId,
40
91
  });
41
92
  deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
42
93
  await deps.openBrowser(authorizeUrl);
@@ -53,6 +104,10 @@ async function runLoopbackLogin(deps, apiUrl, cid) {
53
104
  redirectUri: listener.redirectUri,
54
105
  code: callback.code,
55
106
  codeVerifier: pkce.codeVerifier,
107
+ clientAssertion: installKey?.confirmedBound
108
+ ? signClientAssertion(installKey, { clientId: cid, audience: `${apiUrl}/oauth/token` })
109
+ : undefined,
110
+ dpopKey: installKey,
56
111
  });
57
112
  }
58
113
  finally {
@@ -68,3 +123,26 @@ async function runDeviceGrantLogin(deps, apiUrl, cid) {
68
123
  },
69
124
  }, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
70
125
  }
126
+ export async function runLoginCi(deps) {
127
+ const apiUrl = apiBaseUrl(deps.env);
128
+ const subjectToken = await resolveCiOidcToken(deps, {
129
+ env: deps.ciEnv,
130
+ oidcTokenFile: deps.oidcTokenFile,
131
+ readTextFile: deps.readTextFile,
132
+ audience: apiUrl,
133
+ });
134
+ const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
135
+ const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
136
+ const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl, dpopKey });
137
+ if (!existingKey)
138
+ persistInstallKey(deps.keys, dpopKey);
139
+ const session = {
140
+ access_token: token.access_token,
141
+ token_type: token.token_type,
142
+ expires_at: deps.now() + token.expires_in * 1000,
143
+ api_base_url: apiUrl,
144
+ client_id: CI_FEDERATION_SESSION_CLIENT_ID,
145
+ };
146
+ deps.session.write(session);
147
+ deps.stdout.write('Logged in to Rakomi (CI workload identity).\n');
148
+ }
@@ -0,0 +1,45 @@
1
+ import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
+ import { generateEphemeralDpopKey } from '../dpop.js';
3
+ import { apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, DEFAULT_MCP_URL } from '../env.js';
4
+ import { getOrCreateInstallKey, peekInstallKey, persistInstallKey } from '../install-key.js';
5
+ import { listMcpToolsCi, MCP_RESOURCE } from '../mcp-tools-ci.js';
6
+ import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
7
+ function hintLine(t) {
8
+ const hints = [];
9
+ if (t.annotations?.readOnlyHint)
10
+ hints.push('read-only');
11
+ if (t.annotations?.idempotentHint)
12
+ hints.push('idempotent');
13
+ return hints.length > 0 ? ` (${hints.join(', ')})` : '';
14
+ }
15
+ export async function runMcpToolsCi(deps) {
16
+ const apiUrl = apiBaseUrl(deps.env);
17
+ const mcpUrl = deps.env.RAKOMI_API_URL ? `${apiUrl}/mcp` : DEFAULT_MCP_URL;
18
+ const subjectToken = await resolveCiOidcToken(deps, {
19
+ env: deps.ciEnv,
20
+ oidcTokenFile: deps.oidcTokenFile,
21
+ readTextFile: deps.readTextFile,
22
+ audience: MCP_RESOURCE,
23
+ });
24
+ const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
25
+ const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
26
+ const token = await exchangeOidcSubjectToken(deps, {
27
+ apiBaseUrl: apiUrl,
28
+ subjectToken,
29
+ resource: MCP_RESOURCE,
30
+ dpopKey,
31
+ });
32
+ if (!existingKey)
33
+ persistInstallKey(deps.keys, dpopKey);
34
+ const resolvedKey = getOrCreateInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
35
+ const tools = await listMcpToolsCi(deps, { mcpUrl, accessToken: token.access_token, dpopKey: resolvedKey });
36
+ if (deps.json) {
37
+ deps.stdout.write(JSON.stringify({ tools }) + '\n');
38
+ return;
39
+ }
40
+ if (tools.length === 0) {
41
+ deps.stdout.write('No MCP tools visible to this CI identity.\n');
42
+ return;
43
+ }
44
+ deps.stdout.write(tools.map((t) => `${t.name}${hintLine(t)} — ${t.description}`).join('\n') + '\n');
45
+ }
@@ -1,4 +1,5 @@
1
1
  import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
2
+ import { resolveDpopKey } from '../install-key.js';
2
3
  import { createTenant, listTenants } from '../tenants-client.js';
3
4
  export async function runTenantsCreate(deps, args) {
4
5
  const session = deps.session.read();
@@ -23,6 +24,7 @@ export async function runTenantsCreate(deps, args) {
23
24
  name: args.name,
24
25
  slug: args.slug,
25
26
  ownerEmail: ownerIsSelf ? undefined : args.owner,
27
+ dpop: resolveDpopKey(deps.keys, session),
26
28
  });
27
29
  if (deps.json) {
28
30
  deps.stdout.write(JSON.stringify(result) + '\n');
@@ -41,7 +43,12 @@ export async function runTenantsList(deps) {
41
43
  const session = deps.session.read();
42
44
  if (!session)
43
45
  throw new NotLoggedInError();
44
- const result = await listTenants(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, parent: 'me' });
46
+ const result = await listTenants(deps, {
47
+ apiBaseUrl: session.api_base_url,
48
+ accessToken: session.access_token,
49
+ parent: 'me',
50
+ dpop: resolveDpopKey(deps.keys, session),
51
+ });
45
52
  if (deps.json) {
46
53
  deps.stdout.write(JSON.stringify(result) + '\n');
47
54
  return;
@@ -0,0 +1,10 @@
1
+ import { isValidTenantId } from '../env.js';
2
+ import { UsageError } from '../errors.js';
3
+ export function runUse(deps, args) {
4
+ if (!isValidTenantId(args.tenantId)) {
5
+ throw new UsageError(`"${args.tenantId}" is not a valid tenant id — expected a UUID (e.g. the value \`rakomi login\` ` +
6
+ 'printed as "Home tenant", or the id shown in the dashboard).');
7
+ }
8
+ deps.tenantConfig.write(args.tenantId);
9
+ deps.stdout.write(`Active tenant set to ${args.tenantId}.\n`);
10
+ }
@@ -1,18 +1,33 @@
1
1
  import { NotLoggedInError } from '../errors.js';
2
+ import { resolveDpopKey } from '../install-key.js';
2
3
  import { fetchUserInfo } from '../userinfo-client.js';
3
4
  export async function runWhoami(deps) {
4
5
  const session = deps.session.read();
5
6
  if (!session)
6
7
  throw new NotLoggedInError();
7
- const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token });
8
+ const dpop = resolveDpopKey(deps.keys, session);
9
+ const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, dpop });
10
+ const activeTenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
8
11
  if (deps.json) {
9
- deps.stdout.write(JSON.stringify({ sub: info.sub, email: info.email, org_id: info.org_id, org_role: info.org_role, session_store: deps.session.describePath() }) + '\n');
12
+ deps.stdout.write(JSON.stringify({
13
+ sub: info.sub,
14
+ email: info.email,
15
+ org_id: info.org_id,
16
+ org_role: info.org_role,
17
+ session_store: deps.session.describePath(),
18
+ token_type: session.token_type,
19
+ home_tenant_id: session.home_tenant_id,
20
+ active_tenant_id: activeTenantId,
21
+ }) + '\n');
10
22
  return;
11
23
  }
12
24
  const lines = [`Signed in as: ${info.email ?? info.sub}`];
13
25
  if (info.org_id)
14
26
  lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
15
27
  lines.push(`API: ${session.api_base_url}`);
28
+ lines.push(`Home tenant: ${session.home_tenant_id ?? 'unknown (this session did not authenticate via the CIMD-default flow)'}`);
29
+ lines.push(`Active tenant: ${activeTenantId ?? 'none set (run `rakomi use <tenant-id>`)'}`);
16
30
  lines.push(`Session stored in: ${deps.session.describePath()}`);
31
+ lines.push(`Token type: ${session.token_type}`);
17
32
  deps.stdout.write(lines.join('\n') + '\n');
18
33
  }
@@ -6,7 +6,8 @@ export async function pollCimdMaterialization(deps, opts) {
6
6
  const result = await request(deps, {
7
7
  method: 'GET',
8
8
  url: url.toString(),
9
- headers: { authorization: `Bearer ${opts.accessToken}` },
9
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
10
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
10
11
  });
11
12
  if (result.status === 401) {
12
13
  throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
@@ -20,8 +21,9 @@ export async function requestWriteElevation(deps, opts) {
20
21
  const result = await request(deps, {
21
22
  method: 'POST',
22
23
  url: `${opts.apiBaseUrl}/v1/oauth-clients/${encodeURIComponent(opts.oauthClientId)}/elevation-requests`,
23
- headers: { authorization: `Bearer ${opts.accessToken}` },
24
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
24
25
  body: { scopes: ['mcp:tools:write'] },
26
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
25
27
  });
26
28
  if (result.status === 401) {
27
29
  throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
package/dist/dpop.js ADDED
@@ -0,0 +1,42 @@
1
+ import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
2
+ export function generateEphemeralDpopKey() {
3
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
4
+ const publicJwk = publicKey.export({ format: 'jwk' });
5
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
6
+ return { privateKeyPem, publicJwk };
7
+ }
8
+ function base64url(buf) {
9
+ return buf.toString('base64url');
10
+ }
11
+ export function canonicalizeHtu(input) {
12
+ const url = new URL(input);
13
+ const protocol = url.protocol.toLowerCase();
14
+ const isDefaultPort = (protocol === 'https:' && url.port === '443') || (protocol === 'http:' && url.port === '80');
15
+ const host = (isDefaultPort ? url.hostname : url.host).toLowerCase();
16
+ const lowerHexPath = url.pathname.replace(/%([0-9A-Fa-f]{2})/g, (_m, h) => `%${h.toLowerCase()}`);
17
+ const collapsed = lowerHexPath.replace(/\/{2,}/g, '/');
18
+ const path = collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed;
19
+ return `${protocol}//${host}${path}`;
20
+ }
21
+ export function buildDpopProof(key, claims, opts) {
22
+ const privateKey = createPrivateKey(key.privateKeyPem);
23
+ const now = opts?.nowSec ?? Math.floor(Date.now() / 1000);
24
+ const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk };
25
+ const payload = {
26
+ jti: randomUUID(),
27
+ htm: claims.htm,
28
+ htu: claims.htu,
29
+ iat: now,
30
+ };
31
+ if (claims.accessToken !== undefined) {
32
+ payload.ath = createHash('sha256').update(claims.accessToken, 'utf8').digest('base64url');
33
+ }
34
+ if (claims.nonce !== undefined) {
35
+ payload.nonce = claims.nonce;
36
+ }
37
+ const h64 = base64url(Buffer.from(JSON.stringify(header)));
38
+ const p64 = base64url(Buffer.from(JSON.stringify(payload)));
39
+ const signingInput = `${h64}.${p64}`;
40
+ const signature = cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
41
+ return `${signingInput}.${base64url(signature)}`;
42
+ }
package/dist/env.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { request } from './http.js';
1
2
  export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
2
3
  export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
3
4
  export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
4
5
  export const DEFAULT_CIMD_CLIENT_ID_URL = 'https://api.rakomi.com/.well-known/rakomi-cli/client-metadata.json';
6
+ export const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
5
7
  export function isCimdClientId(id) {
6
8
  let url;
7
9
  try {
@@ -21,3 +23,33 @@ export function accountsBaseUrl(env) {
21
23
  export function clientId(env) {
22
24
  return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
23
25
  }
26
+ const TENANT_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
+ const NIL_TENANT_ID = '00000000-0000-0000-0000-000000000000';
28
+ const MAX_TENANT_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
29
+ export function isValidTenantId(id) {
30
+ if (id.length !== 36)
31
+ return false;
32
+ if (!TENANT_ID_UUID_PATTERN.test(id))
33
+ return false;
34
+ const lower = id.toLowerCase();
35
+ return lower !== NIL_TENANT_ID && lower !== MAX_TENANT_ID;
36
+ }
37
+ export function platformTenantId(env) {
38
+ const raw = env.RAKOMI_PLATFORM_TENANT_ID;
39
+ return raw && isValidTenantId(raw) ? raw : undefined;
40
+ }
41
+ const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
42
+ export async function isCimdPlatformSupported(deps, apiUrl) {
43
+ try {
44
+ const result = await request(deps, {
45
+ method: 'GET',
46
+ url: `${apiUrl}/.well-known/oauth-authorization-server`,
47
+ });
48
+ if (result.status !== 200)
49
+ return undefined;
50
+ return result.body[CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD] === true;
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
package/dist/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildDpopProof, canonicalizeHtu } from './dpop.js';
1
2
  import { CliError, EXIT } from './errors.js';
2
3
  export const DEFAULT_TIMEOUT_MS = 15_000;
3
4
  function isErrorEnvelope(v) {
@@ -25,7 +26,7 @@ export function errorCode(body) {
25
26
  return body.code;
26
27
  return undefined;
27
28
  }
28
- export async function request(deps, req) {
29
+ async function performOnce(deps, req, dpopNonce) {
29
30
  const controller = new AbortController();
30
31
  const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
31
32
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -42,6 +43,18 @@ export async function request(deps, req) {
42
43
  }
43
44
  if (req.idempotencyKey)
44
45
  headers['idempotency-key'] = req.idempotencyKey;
46
+ if (req.dpop) {
47
+ const proof = buildDpopProof(req.dpop.key, {
48
+ htm: req.method,
49
+ htu: canonicalizeHtu(req.url),
50
+ accessToken: req.dpop.accessToken,
51
+ nonce: dpopNonce,
52
+ });
53
+ headers['dpop'] = proof;
54
+ if (req.dpop.accessToken !== undefined) {
55
+ headers['authorization'] = `DPoP ${req.dpop.accessToken}`;
56
+ }
57
+ }
45
58
  let res;
46
59
  try {
47
60
  res = await deps.fetchImpl(req.url, { method: req.method, headers, body, signal: controller.signal });
@@ -68,3 +81,24 @@ export async function request(deps, req) {
68
81
  clearTimeout(timer);
69
82
  }
70
83
  }
84
+ function isDpopNonceChallenge(result) {
85
+ if (result.status !== 401)
86
+ return undefined;
87
+ if (errorCode(result.body) !== 'auth/invalid_dpop_proof')
88
+ return undefined;
89
+ const nonce = result.headers.get('dpop-nonce');
90
+ return nonce && nonce.length > 0 ? nonce : undefined;
91
+ }
92
+ export async function request(deps, req) {
93
+ const first = await performOnce(deps, req);
94
+ if (!req.dpop)
95
+ return first;
96
+ const nonce = isDpopNonceChallenge(first);
97
+ if (nonce === undefined)
98
+ return first;
99
+ const retry = await performOnce(deps, req, nonce);
100
+ if (isDpopNonceChallenge(retry) !== undefined) {
101
+ throw new CliError('The Rakomi API kept demanding a fresh DPoP nonce; the request could not complete.', EXIT.FAIL);
102
+ }
103
+ return retry;
104
+ }
package/dist/index.js CHANGED
@@ -6,14 +6,17 @@ import { fileURLToPath } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { systemBrowserOpener } from './browser.js';
8
8
  import { runConnect } from './commands/connect.js';
9
- import { runLogin } from './commands/login.js';
9
+ import { runLogin, runLoginCi } from './commands/login.js';
10
10
  import { runLogout } from './commands/logout.js';
11
+ import { runMcpToolsCi } from './commands/mcp.js';
11
12
  import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
13
+ import { runUse } from './commands/use.js';
12
14
  import { runWhoami } from './commands/whoami.js';
13
15
  import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
14
16
  import { CliError, EXIT, UsageError } from './errors.js';
15
17
  import { startLoopbackListener } from './loopback-server.js';
16
18
  import { resolveStores } from './session.js';
19
+ import { FileTenantConfigStore } from './tenant-config.js';
17
20
  import { helpText, usageLine } from './usage.js';
18
21
  const GLOBAL_OPTIONS = {
19
22
  json: { type: 'boolean' },
@@ -22,11 +25,14 @@ const GLOBAL_OPTIONS = {
22
25
  'dry-run': { type: 'boolean' },
23
26
  'no-browser': { type: 'boolean' },
24
27
  'no-keychain': { type: 'boolean' },
28
+ 'oidc-token-file': { type: 'string' },
25
29
  client: { type: 'string' },
26
30
  undo: { type: 'boolean' },
27
31
  write: { type: 'boolean' },
28
32
  owner: { type: 'string' },
29
33
  slug: { type: 'string' },
34
+ 'tenant-id': { type: 'string' },
35
+ tenant: { type: 'string' },
30
36
  'cimd-url': { type: 'string' },
31
37
  status: { type: 'boolean' },
32
38
  help: { type: 'boolean', short: 'h' },
@@ -74,29 +80,62 @@ async function dispatch(args, deps) {
74
80
  const httpDeps = { fetchImpl: deps.fetchImpl };
75
81
  const [command, ...rest] = positionals;
76
82
  switch (command) {
77
- case 'login':
83
+ case 'login': {
84
+ if (values.ci === true) {
85
+ await runLoginCi({
86
+ ...httpDeps,
87
+ env: deps.env,
88
+ ciEnv: deps.ciEnv ?? {},
89
+ session: deps.session,
90
+ keys: deps.keys,
91
+ oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
92
+ readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
93
+ stdout: deps.stdout,
94
+ now: () => Date.now(),
95
+ });
96
+ return EXIT.OK;
97
+ }
78
98
  await runLogin({
79
99
  ...httpDeps,
80
100
  env: deps.env,
81
101
  session: deps.session,
102
+ keys: deps.keys,
82
103
  noBrowser: values['no-browser'] === true || ci,
83
104
  openBrowser: systemBrowserOpener(),
84
105
  startLoopback: startLoopbackListener,
85
106
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
86
107
  stdout: deps.stdout,
87
108
  now: () => Date.now(),
109
+ explicitTenantId: typeof values['tenant-id'] === 'string' ? values['tenant-id'] : undefined,
88
110
  });
89
111
  return EXIT.OK;
112
+ }
90
113
  case 'logout':
91
114
  runLogout({ session: deps.session, stdout: deps.stdout });
92
115
  return EXIT.OK;
93
116
  case 'whoami':
94
- await runWhoami({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
117
+ await runWhoami({
118
+ ...httpDeps,
119
+ session: deps.session,
120
+ keys: deps.keys,
121
+ json,
122
+ stdout: deps.stdout,
123
+ tenantConfig: deps.tenantConfig,
124
+ explicitTenant: typeof values.tenant === 'string' ? values.tenant : undefined,
125
+ });
126
+ return EXIT.OK;
127
+ case 'use': {
128
+ const tenantId = rest[0];
129
+ if (!tenantId)
130
+ throw new UsageError('Usage: rakomi use <tenant-id>');
131
+ runUse({ tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
95
132
  return EXIT.OK;
133
+ }
96
134
  case 'connect':
97
135
  await runConnect({
98
136
  ...httpDeps,
99
137
  session: deps.session,
138
+ keys: deps.keys,
100
139
  cwd: deps.cwd,
101
140
  apiBaseUrl: apiBaseUrl(deps.env),
102
141
  mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
@@ -111,17 +150,37 @@ async function dispatch(args, deps) {
111
150
  status: values.status === true,
112
151
  });
113
152
  return EXIT.OK;
153
+ case 'mcp': {
154
+ const [sub] = rest;
155
+ if (sub !== 'tools') {
156
+ throw new UsageError('Usage: rakomi mcp tools --ci');
157
+ }
158
+ if (values.ci !== true) {
159
+ throw new UsageError('rakomi mcp tools requires --ci (workload-identity federation; no interactive form exists yet).');
160
+ }
161
+ await runMcpToolsCi({
162
+ ...httpDeps,
163
+ env: deps.env,
164
+ ciEnv: deps.ciEnv ?? {},
165
+ keys: deps.keys,
166
+ oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
167
+ readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
168
+ json,
169
+ stdout: deps.stdout,
170
+ });
171
+ return EXIT.OK;
172
+ }
114
173
  case 'tenants': {
115
174
  const [sub, ...tenantArgs] = rest;
116
175
  if (sub === 'create') {
117
176
  const name = tenantArgs[0];
118
177
  if (!name)
119
178
  throw new UsageError('Usage: rakomi tenants create <name> [--owner me|<email>] [--slug <slug>]');
120
- await runTenantsCreate({ ...httpDeps, session: deps.session, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
179
+ await runTenantsCreate({ ...httpDeps, session: deps.session, keys: deps.keys, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
121
180
  return EXIT.OK;
122
181
  }
123
182
  if (sub === 'list') {
124
- await runTenantsList({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
183
+ await runTenantsList({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
125
184
  return EXIT.OK;
126
185
  }
127
186
  throw new UsageError('Usage: rakomi tenants <create|list> ...');
@@ -130,6 +189,11 @@ async function dispatch(args, deps) {
130
189
  throw new UsageError(`Unknown command "${command ?? ''}".`);
131
190
  }
132
191
  }
192
+ export function printFallbackDisclosureOnce(resolved, stderr) {
193
+ if (resolved.fallbackDisclosure) {
194
+ stderr.write(resolved.fallbackDisclosure + '\n');
195
+ }
196
+ }
133
197
  async function main() {
134
198
  const major = Number(process.versions.node.split('.')[0]);
135
199
  if (Number.isFinite(major) && major < 22) {
@@ -148,15 +212,21 @@ async function main() {
148
212
  RAKOMI_ACCOUNTS_URL: process.env.RAKOMI_ACCOUNTS_URL,
149
213
  RAKOMI_CLIENT_ID: process.env.RAKOMI_CLIENT_ID,
150
214
  RAKOMI_CONFIG_DIR: process.env.RAKOMI_CONFIG_DIR,
215
+ RAKOMI_PLATFORM_TENANT_ID: process.env.RAKOMI_PLATFORM_TENANT_ID,
151
216
  CI: process.env.CI,
152
217
  NO_COLOR: process.env.NO_COLOR,
153
218
  };
154
219
  const noKeychainEnv = process.env.RAKOMI_NO_KEYCHAIN;
220
+ const ciEnv = {
221
+ ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
222
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
223
+ CI_JOB_JWT_V2: process.env.CI_JOB_JWT_V2,
224
+ CI_JOB_JWT: process.env.CI_JOB_JWT,
225
+ RAKOMI_OIDC_TOKEN: process.env.RAKOMI_OIDC_TOKEN,
226
+ };
155
227
  const noKeychainFlag = process.argv.slice(2).includes('--no-keychain');
156
228
  const resolved = resolveStores(env, { noKeychain: noKeychainFlag || Boolean(env.CI) || Boolean(noKeychainEnv) });
157
- if (resolved.fallbackDisclosure) {
158
- process.stderr.write(resolved.fallbackDisclosure + '\n');
159
- }
229
+ printFallbackDisclosureOnce(resolved, { write: (text) => void process.stderr.write(text) });
160
230
  process.exitCode = await run(process.argv.slice(2), {
161
231
  stdout: { write: (text) => void process.stdout.write(text) },
162
232
  stderr: { write: (text) => void process.stderr.write(text) },
@@ -166,7 +236,10 @@ async function main() {
166
236
  isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
167
237
  fetchImpl: fetch,
168
238
  session: resolved.session,
239
+ keys: resolved.keys,
240
+ tenantConfig: new FileTenantConfigStore(env),
169
241
  detectClaudeCode: () => detectClaudeCodeCli(),
242
+ ciEnv,
170
243
  });
171
244
  }
172
245
  function detectClaudeCodeCli() {
@@ -0,0 +1,94 @@
1
+ import { createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
2
+ import { CI_FEDERATION_SESSION_CLIENT_ID, isCimdClientId } from './env.js';
3
+ import { request } from './http.js';
4
+ const INSTALL_KEY_NAME = 'install-key';
5
+ const ASSERTION_LIFETIME_SEC = 60;
6
+ function isStoredInstallKey(v) {
7
+ if (typeof v !== 'object' || v === null)
8
+ return false;
9
+ const r = v;
10
+ return (typeof r.clientId === 'string' &&
11
+ typeof r.privateKeyPem === 'string' &&
12
+ typeof r.publicJwk === 'object' &&
13
+ r.publicJwk !== null &&
14
+ typeof r.confirmedBound === 'boolean');
15
+ }
16
+ function generateKeyPair(clientId) {
17
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
18
+ const publicJwk = publicKey.export({ format: 'jwk' });
19
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
20
+ return { clientId, privateKeyPem, publicJwk, confirmedBound: false };
21
+ }
22
+ export function peekInstallKey(keys, clientId) {
23
+ const raw = keys.get(INSTALL_KEY_NAME);
24
+ if (raw === null)
25
+ return undefined;
26
+ try {
27
+ const parsed = JSON.parse(raw);
28
+ if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
29
+ return parsed;
30
+ }
31
+ catch {
32
+ }
33
+ return undefined;
34
+ }
35
+ export function persistInstallKey(keys, key) {
36
+ keys.set(INSTALL_KEY_NAME, JSON.stringify(key));
37
+ }
38
+ export function getOrCreateInstallKey(keys, clientId) {
39
+ const existing = peekInstallKey(keys, clientId);
40
+ if (existing)
41
+ return existing;
42
+ const fresh = generateKeyPair(clientId);
43
+ persistInstallKey(keys, fresh);
44
+ return fresh;
45
+ }
46
+ function base64url(buf) {
47
+ return buf.toString('base64url');
48
+ }
49
+ export function signClientAssertion(key, opts) {
50
+ const privateKey = createPrivateKey(key.privateKeyPem);
51
+ const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
52
+ const header = { alg: 'ES256', typ: 'JWT' };
53
+ const payload = {
54
+ iss: opts.clientId,
55
+ sub: opts.clientId,
56
+ aud: opts.audience,
57
+ jti: randomUUID(),
58
+ iat: now,
59
+ exp: now + ASSERTION_LIFETIME_SEC,
60
+ };
61
+ const h64 = base64url(Buffer.from(JSON.stringify(header)));
62
+ const p64 = base64url(Buffer.from(JSON.stringify(payload)));
63
+ const signingInput = `${h64}.${p64}`;
64
+ const signature = cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
65
+ return `${signingInput}.${base64url(signature)}`;
66
+ }
67
+ export async function bindInstallKey(deps, keys, opts, key) {
68
+ try {
69
+ const result = await request(deps, {
70
+ method: 'POST',
71
+ url: `${opts.apiBaseUrl}/v1/oauth-clients/install-key`,
72
+ headers: opts.dpopBound ? undefined : { authorization: `Bearer ${opts.accessToken}` },
73
+ body: { client_id: opts.clientId, jwk: key.publicJwk },
74
+ dpop: opts.dpopBound ? { key, accessToken: opts.accessToken } : undefined,
75
+ });
76
+ if (result.status !== 200)
77
+ return { attempted: true, bound: false };
78
+ const bound = result.body.bound === true;
79
+ if (bound !== key.confirmedBound) {
80
+ keys.set(INSTALL_KEY_NAME, JSON.stringify({ ...key, confirmedBound: bound }));
81
+ }
82
+ return { attempted: true, bound };
83
+ }
84
+ catch {
85
+ return { attempted: false, bound: false };
86
+ }
87
+ }
88
+ export function resolveDpopKey(keys, session) {
89
+ if (session.token_type !== 'DPoP')
90
+ return undefined;
91
+ if (!isCimdClientId(session.client_id) && session.client_id !== CI_FEDERATION_SESSION_CLIENT_ID)
92
+ return undefined;
93
+ return getOrCreateInstallKey(keys, session.client_id);
94
+ }
@@ -0,0 +1,44 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export const MCP_RESOURCE = 'https://mcp.rakomi.com';
4
+ const MCP_PROTOCOL_VERSION = '2026-07-28';
5
+ const MCP_PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version';
6
+ const MCP_METHOD_HEADER = 'Mcp-Method';
7
+ const MCP_META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion';
8
+ const MCP_META_CLIENT_CAPABILITIES_KEY = 'io.modelcontextprotocol/clientCapabilities';
9
+ function isJsonRpcError(body) {
10
+ return typeof body === 'object' && body !== null && 'error' in body;
11
+ }
12
+ export async function listMcpToolsCi(deps, opts) {
13
+ const message = {
14
+ jsonrpc: '2.0',
15
+ id: 1,
16
+ method: 'tools/list',
17
+ params: {
18
+ _meta: {
19
+ [MCP_META_PROTOCOL_VERSION_KEY]: MCP_PROTOCOL_VERSION,
20
+ [MCP_META_CLIENT_CAPABILITIES_KEY]: {},
21
+ },
22
+ },
23
+ };
24
+ const result = await request(deps, {
25
+ method: 'POST',
26
+ url: opts.mcpUrl,
27
+ headers: {
28
+ [MCP_PROTOCOL_VERSION_HEADER]: MCP_PROTOCOL_VERSION,
29
+ [MCP_METHOD_HEADER]: 'tools/list',
30
+ },
31
+ body: message,
32
+ dpop: { key: opts.dpopKey, accessToken: opts.accessToken },
33
+ });
34
+ if (result.status === 401) {
35
+ throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
36
+ }
37
+ if (isJsonRpcError(result.body)) {
38
+ throw new CliError(`Could not list MCP tools: ${result.body.error.message}`, EXIT.FAIL);
39
+ }
40
+ if (result.status !== 200 || typeof result.body !== 'object' || result.body === null || !('result' in result.body)) {
41
+ throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
42
+ }
43
+ return result.body.result.tools;
44
+ }
@@ -9,6 +9,8 @@ export function buildAuthorizeUrl(opts) {
9
9
  url.searchParams.set('state', opts.pkce.state);
10
10
  url.searchParams.set('code_challenge', opts.pkce.codeChallenge);
11
11
  url.searchParams.set('code_challenge_method', opts.pkce.codeChallengeMethod);
12
+ if (opts.tenantId)
13
+ url.searchParams.set('tenant_id', opts.tenantId);
12
14
  return url.toString();
13
15
  }
14
16
  export async function exchangeAuthorizationCode(deps, opts) {
@@ -21,7 +23,14 @@ export async function exchangeAuthorizationCode(deps, opts) {
21
23
  redirect_uri: opts.redirectUri,
22
24
  client_id: opts.clientId,
23
25
  code_verifier: opts.codeVerifier,
26
+ ...(opts.clientAssertion
27
+ ? {
28
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
29
+ client_assertion: opts.clientAssertion,
30
+ }
31
+ : {}),
24
32
  },
33
+ dpop: opts.dpopKey ? { key: opts.dpopKey } : undefined,
25
34
  });
26
35
  if (result.status !== 200) {
27
36
  throw new CliError(`Could not complete sign-in: ${describeError(result.body, result.status)}`, EXIT.FAIL);
@@ -0,0 +1,23 @@
1
+ import { buildDpopProof, canonicalizeHtu } from './dpop.js';
2
+ import { CliError, EXIT } from './errors.js';
3
+ import { describeError, request } from './http.js';
4
+ export const OIDC_FEDERATION_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
5
+ export async function exchangeOidcSubjectToken(deps, opts) {
6
+ const tokenUrl = `${opts.apiBaseUrl}/oauth/token`;
7
+ const proof = buildDpopProof(opts.dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
8
+ const result = await request(deps, {
9
+ method: 'POST',
10
+ url: tokenUrl,
11
+ headers: { dpop: proof },
12
+ form: {
13
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
14
+ subject_token: opts.subjectToken,
15
+ subject_token_type: OIDC_FEDERATION_SUBJECT_TOKEN_TYPE,
16
+ resource: opts.resource,
17
+ },
18
+ });
19
+ if (result.status !== 200) {
20
+ throw new CliError(`Could not sign in from CI: ${describeError(result.body, result.status)}`, EXIT.FAIL);
21
+ }
22
+ return result.body;
23
+ }
package/dist/pkce.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  function base64url(buf) {
3
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
3
+ return buf.toString('base64url');
4
4
  }
5
5
  export function generateCodeVerifier() {
6
6
  return base64url(randomBytes(32));
package/dist/session.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { delimiter, join } from 'node:path';
5
+ import { CliError } from './errors.js';
5
6
  export function configDir(env) {
6
7
  if (env.RAKOMI_CONFIG_DIR)
7
8
  return env.RAKOMI_CONFIG_DIR;
@@ -49,12 +50,18 @@ export class FileSessionStore {
49
50
  return this.path;
50
51
  }
51
52
  }
53
+ function assertValidKeyName(name) {
54
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(name)) {
55
+ throw new TypeError(`invalid KeyStore name: ${JSON.stringify(name)} (expected 1-128 chars, alphanumeric/hyphen/underscore only, no path separators)`);
56
+ }
57
+ }
52
58
  export class FileKeyStore {
53
59
  dir;
54
60
  constructor(dir) {
55
61
  this.dir = dir;
56
62
  }
57
63
  path(name) {
64
+ assertValidKeyName(name);
58
65
  return join(this.dir, `key-${name}.json`);
59
66
  }
60
67
  get(name) {
@@ -88,20 +95,50 @@ function isStoredSession(value) {
88
95
  const v = value;
89
96
  return (typeof v.access_token === 'string' &&
90
97
  v.access_token.length > 0 &&
91
- v.token_type === 'Bearer' &&
98
+ (v.token_type === 'Bearer' || v.token_type === 'DPoP') &&
92
99
  typeof v.expires_at === 'number' &&
93
100
  typeof v.api_base_url === 'string' &&
94
- typeof v.client_id === 'string');
101
+ typeof v.client_id === 'string' &&
102
+ (v.home_tenant_id === undefined || typeof v.home_tenant_id === 'string'));
95
103
  }
96
104
  export function isExpired(session, now) {
97
105
  return now >= session.expires_at;
98
106
  }
107
+ export class ExecFileFailure extends Error {
108
+ command;
109
+ status;
110
+ stderrOutput;
111
+ constructor(command, status, stderrOutput) {
112
+ super(`${command} exited with status ${status ?? 'unknown'}${stderrOutput.length > 0 ? `: ${stderrOutput}` : ''}`);
113
+ this.command = command;
114
+ this.status = status;
115
+ this.stderrOutput = stderrOutput;
116
+ this.name = 'ExecFileFailure';
117
+ }
118
+ }
99
119
  function realExecFileSync(file, args, options) {
100
- return execFileSync(file, [...args], { encoding: 'utf8', input: options?.input, maxBuffer: 10 * 1024 * 1024 }).toString();
120
+ try {
121
+ return execFileSync(file, [...args], {
122
+ encoding: 'utf8',
123
+ input: options?.input,
124
+ maxBuffer: 10 * 1024 * 1024,
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ }).toString();
127
+ }
128
+ catch (e) {
129
+ const err = e;
130
+ const rawStderr = err.stderr;
131
+ const stderrOutput = (typeof rawStderr === 'string' ? rawStderr : rawStderr?.toString('utf8')) ?? '';
132
+ throw new ExecFileFailure(file, err.status ?? null, stderrOutput.trim());
133
+ }
101
134
  }
102
135
  function commandExistsOnPath(binaryName, pathEnv, existsFn) {
103
136
  return pathEnv.split(delimiter).some((dir) => dir.length > 0 && existsFn(join(dir, binaryName)));
104
137
  }
138
+ function keychainReadFailure(backendLabel, cause) {
139
+ const detail = cause instanceof ExecFileFailure && cause.stderrOutput.length > 0 ? cause.stderrOutput : cause instanceof Error ? cause.message : String(cause);
140
+ return new CliError(`Could not read your session from ${backendLabel}: ${detail}`);
141
+ }
105
142
  export class MacKeychainBackend {
106
143
  execFileSyncFn;
107
144
  platform;
@@ -127,8 +164,10 @@ export class MacKeychainBackend {
127
164
  const value = out.trim();
128
165
  return value.length > 0 ? value : null;
129
166
  }
130
- catch {
131
- return null;
167
+ catch (e) {
168
+ if (e instanceof ExecFileFailure && e.status === 44)
169
+ return null;
170
+ throw keychainReadFailure('macOS Keychain', e);
132
171
  }
133
172
  }
134
173
  set(service, account, value) {
@@ -165,8 +204,10 @@ export class LinuxSecretToolBackend {
165
204
  const value = out.replace(/\n$/, '');
166
205
  return value.length > 0 ? value : null;
167
206
  }
168
- catch {
169
- return null;
207
+ catch (e) {
208
+ if (e instanceof ExecFileFailure && e.stderrOutput.length === 0)
209
+ return null;
210
+ throw keychainReadFailure('the Linux Secret Service', e);
170
211
  }
171
212
  }
172
213
  set(service, account, value) {
@@ -227,7 +268,13 @@ export class WindowsDpapiBackend {
227
268
  const valueB64 = Buffer.from(value, 'utf8').toString('base64');
228
269
  const script = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Convert]::FromBase64String('${valueB64}'), $null, 'CurrentUser'))`;
229
270
  const cipherB64 = this.execFileSyncFn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]).trim();
230
- writeFileSync(this.filePath(service, account), cipherB64 + '\n', { mode: 0o600 });
271
+ const path = this.filePath(service, account);
272
+ writeFileSync(path, cipherB64 + '\n', { mode: 0o600 });
273
+ try {
274
+ chmodSync(path, 0o600);
275
+ }
276
+ catch {
277
+ }
231
278
  }
232
279
  delete(service, account) {
233
280
  try {
@@ -286,12 +333,25 @@ export class KeychainKeyStore {
286
333
  this.backend = backend;
287
334
  }
288
335
  get(name) {
336
+ try {
337
+ assertValidKeyName(name);
338
+ }
339
+ catch {
340
+ return null;
341
+ }
289
342
  return this.backend.get(KEY_SERVICE, name);
290
343
  }
291
344
  set(name, value) {
345
+ assertValidKeyName(name);
292
346
  this.backend.set(KEY_SERVICE, name, value);
293
347
  }
294
348
  clear(name) {
349
+ try {
350
+ assertValidKeyName(name);
351
+ }
352
+ catch {
353
+ return;
354
+ }
295
355
  this.backend.delete(KEY_SERVICE, name);
296
356
  }
297
357
  }
@@ -0,0 +1,50 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configDir } from './session.js';
4
+ function isStoredTenantConfig(value) {
5
+ if (typeof value !== 'object' || value === null)
6
+ return false;
7
+ const v = value;
8
+ return typeof v.active_tenant_id === 'string' && v.active_tenant_id.length > 0;
9
+ }
10
+ function tenantConfigFilePath(env) {
11
+ return join(configDir(env), 'tenant.json');
12
+ }
13
+ export class FileTenantConfigStore {
14
+ path;
15
+ dir;
16
+ constructor(env) {
17
+ this.dir = configDir(env);
18
+ this.path = tenantConfigFilePath(env);
19
+ }
20
+ read() {
21
+ try {
22
+ const raw = readFileSync(this.path, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ return isStoredTenantConfig(parsed) ? parsed.active_tenant_id : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ write(tenantId) {
31
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
32
+ const config = { active_tenant_id: tenantId };
33
+ writeFileSync(this.path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
34
+ try {
35
+ chmodSync(this.path, 0o600);
36
+ }
37
+ catch {
38
+ }
39
+ }
40
+ clear() {
41
+ try {
42
+ rmSync(this.path, { force: true });
43
+ }
44
+ catch {
45
+ }
46
+ }
47
+ describePath() {
48
+ return this.path;
49
+ }
50
+ }
@@ -16,9 +16,10 @@ export async function createTenant(deps, opts) {
16
16
  const result = await request(deps, {
17
17
  method: 'POST',
18
18
  url: `${opts.apiBaseUrl}/v1/tenants`,
19
- headers: { authorization: `Bearer ${opts.accessToken}` },
19
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
20
20
  body: { name: opts.name, ...(opts.slug ? { slug: opts.slug } : {}), ...(opts.ownerEmail ? { owner_email: opts.ownerEmail } : {}) },
21
21
  idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey(),
22
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
22
23
  });
23
24
  if (result.status === 401) {
24
25
  throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
@@ -46,7 +47,8 @@ export async function listTenants(deps, opts) {
46
47
  const result = await request(deps, {
47
48
  method: 'GET',
48
49
  url: url.toString(),
49
- headers: { authorization: `Bearer ${opts.accessToken}` },
50
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
51
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
50
52
  });
51
53
  if (result.status === 401) {
52
54
  throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
package/dist/usage.js CHANGED
@@ -7,8 +7,12 @@ export function helpText() {
7
7
  '',
8
8
  'Commands:',
9
9
  ' login sign in (browser by default; --no-browser for a device code)',
10
+ ' --ci sign in from CI via the runner\'s own OIDC identity (no browser/device code)',
11
+ ' --oidc-token-file <p> read the OIDC token from a file instead of the CI platform\'s own env',
12
+ ' --tenant-id <uuid> the tenant to authenticate against (or set RAKOMI_PLATFORM_TENANT_ID)',
10
13
  ' logout clear the local session',
11
- ' whoami show the signed-in account',
14
+ ' whoami show the signed-in account, home tenant, and active tenant',
15
+ ' use <tenant-id> remember a tenant id locally for whoami/future commands (a value, not a verified membership)',
12
16
  ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
13
17
  ' --write also request write access for the connected client',
14
18
  ' --client <name> claude-code | claude-desktop (required if more than one applies)',
@@ -19,6 +23,7 @@ export function helpText() {
19
23
  ' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
20
24
  ' --slug <slug> optional; auto-derived + suffixed if omitted',
21
25
  ' tenants list list tenants you provisioned',
26
+ ' mcp tools --ci list MCP read tools your CI identity can invoke (no login needed first)',
22
27
  '',
23
28
  'Global options:',
24
29
  ' --json machine-readable output',
@@ -26,6 +31,7 @@ export function helpText() {
26
31
  ' --dry-run print what would happen, make no writes or mutating calls',
27
32
  ' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
28
33
  ' --no-keychain store the session in a 0600 file instead of the OS keychain',
34
+ ' --tenant <id> override the active tenant for this invocation (whoami only today)',
29
35
  ' -h, --help show this help and exit',
30
36
  ' -V, --version print the version and exit',
31
37
  '',
@@ -4,7 +4,8 @@ export async function fetchUserInfo(deps, opts) {
4
4
  const result = await request(deps, {
5
5
  method: 'GET',
6
6
  url: `${opts.apiBaseUrl}/oauth/userinfo`,
7
- headers: { authorization: `Bearer ${opts.accessToken}` },
7
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
8
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
8
9
  });
9
10
  if (result.status === 401) {
10
11
  throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rakomi",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The Rakomi CLI — sign in, connect an AI agent (Claude Code, Claude Desktop) to your tenant, and provision tenants. EU-native auth-as-a-service.",
5
5
  "keywords": [
6
6
  "rakomi",
package/sbom.cdx.json CHANGED
@@ -26,10 +26,10 @@
26
26
  },
27
27
  "component": {
28
28
  "type": "library",
29
- "bom-ref": "pkg:npm/rakomi@0.1.1",
29
+ "bom-ref": "pkg:npm/rakomi@0.2.0",
30
30
  "name": "rakomi",
31
- "version": "0.1.1",
32
- "purl": "pkg:npm/rakomi@0.1.1",
31
+ "version": "0.2.0",
32
+ "purl": "pkg:npm/rakomi@0.2.0",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {