rakomi 0.1.1 → 0.1.2

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.
@@ -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,19 @@
1
- import { accountsBaseUrl, apiBaseUrl, clientId as resolveClientId, isCimdClientId } from '../env.js';
1
+ import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
+ import { accountsBaseUrl, apiBaseUrl, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, } from '../env.js';
2
3
  import { CliError, EXIT } from '../errors.js';
4
+ import { bindInstallKey, getOrCreateInstallKey, signClientAssertion } from '../install-key.js';
3
5
  import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
4
6
  import { loginWithDeviceGrant } from '../oauth-device.js';
7
+ import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
5
8
  import { generatePkcePair } from '../pkce.js';
6
9
  export const LOGIN_SCOPE = 'openid profile email clients:read elevation:request';
7
10
  const LOOPBACK_TIMEOUT_MS = 5 * 60_000;
11
+ function cimdUnsupportedMessage(cid) {
12
+ return ('This Rakomi server does not have CIMD-based sign-in enabled, so the default client_id ' +
13
+ `(${cid}) cannot be used to sign in right now. Ask your tenant admin to register a client in ` +
14
+ 'the dashboard, then run `rakomi login --client <your-client-id>` (or set RAKOMI_CLIENT_ID) ' +
15
+ 'instead.');
16
+ }
8
17
  export async function runLogin(deps) {
9
18
  const apiUrl = apiBaseUrl(deps.env);
10
19
  const cid = resolveClientId(deps.env);
@@ -13,21 +22,34 @@ export async function runLogin(deps) {
13
22
  "default CIMD client_id does not support it. Pass --client <your-client-id> (create one " +
14
23
  'in the dashboard) for headless/SSH sign-in, or omit --no-browser to sign in via loopback.', EXIT.FAIL);
15
24
  }
25
+ if (!deps.noBrowser && isCimdClientId(cid)) {
26
+ const supported = await isCimdPlatformSupported(deps, apiUrl);
27
+ if (supported === false) {
28
+ throw new CliError(cimdUnsupportedMessage(cid), EXIT.FAIL);
29
+ }
30
+ }
31
+ const installKey = isCimdClientId(cid) ? getOrCreateInstallKey(deps.keys, cid) : undefined;
16
32
  const token = deps.noBrowser
17
33
  ? await runDeviceGrantLogin(deps, apiUrl, cid)
18
- : await runLoopbackLogin(deps, apiUrl, cid);
34
+ : await runLoopbackLogin(deps, apiUrl, cid, installKey);
19
35
  const session = {
20
36
  access_token: token.access_token,
21
37
  refresh_token: token.refresh_token,
22
- token_type: 'Bearer',
38
+ token_type: token.token_type === 'DPoP' ? 'DPoP' : 'Bearer',
23
39
  expires_at: deps.now() + token.expires_in * 1000,
24
40
  api_base_url: apiUrl,
25
41
  client_id: cid,
26
42
  };
43
+ if (token.token_type !== 'Bearer' && token.token_type !== 'DPoP') {
44
+ deps.stdout.write(`(debug) unexpected token_type "${token.token_type}" from the server — treating this session as Bearer.\n`);
45
+ }
27
46
  deps.session.write(session);
28
47
  deps.stdout.write('Logged in to Rakomi.\n');
48
+ if (installKey) {
49
+ await bindInstallKey(deps, deps.keys, { apiBaseUrl: apiUrl, clientId: cid, accessToken: token.access_token, dpopBound: session.token_type === 'DPoP' }, installKey);
50
+ }
29
51
  }
30
- async function runLoopbackLogin(deps, apiUrl, cid) {
52
+ async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
31
53
  const listener = await deps.startLoopback();
32
54
  try {
33
55
  const pkce = generatePkcePair();
@@ -53,6 +75,10 @@ async function runLoopbackLogin(deps, apiUrl, cid) {
53
75
  redirectUri: listener.redirectUri,
54
76
  code: callback.code,
55
77
  codeVerifier: pkce.codeVerifier,
78
+ clientAssertion: installKey?.confirmedBound
79
+ ? signClientAssertion(installKey, { clientId: cid, audience: `${apiUrl}/oauth/token` })
80
+ : undefined,
81
+ dpopKey: installKey,
56
82
  });
57
83
  }
58
84
  finally {
@@ -68,3 +94,23 @@ async function runDeviceGrantLogin(deps, apiUrl, cid) {
68
94
  },
69
95
  }, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
70
96
  }
97
+ const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
98
+ export async function runLoginCi(deps) {
99
+ const apiUrl = apiBaseUrl(deps.env);
100
+ const subjectToken = await resolveCiOidcToken(deps, {
101
+ env: deps.ciEnv,
102
+ oidcTokenFile: deps.oidcTokenFile,
103
+ readTextFile: deps.readTextFile,
104
+ audience: apiUrl,
105
+ });
106
+ const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl });
107
+ const session = {
108
+ access_token: token.access_token,
109
+ token_type: token.token_type,
110
+ expires_at: deps.now() + token.expires_in * 1000,
111
+ api_base_url: apiUrl,
112
+ client_id: CI_FEDERATION_SESSION_CLIENT_ID,
113
+ };
114
+ deps.session.write(session);
115
+ deps.stdout.write('Logged in to Rakomi (CI workload identity).\n');
116
+ }
@@ -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;
@@ -1,12 +1,21 @@
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 });
8
10
  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');
11
+ deps.stdout.write(JSON.stringify({
12
+ sub: info.sub,
13
+ email: info.email,
14
+ org_id: info.org_id,
15
+ org_role: info.org_role,
16
+ session_store: deps.session.describePath(),
17
+ token_type: session.token_type,
18
+ }) + '\n');
10
19
  return;
11
20
  }
12
21
  const lines = [`Signed in as: ${info.email ?? info.sub}`];
@@ -14,5 +23,6 @@ export async function runWhoami(deps) {
14
23
  lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
15
24
  lines.push(`API: ${session.api_base_url}`);
16
25
  lines.push(`Session stored in: ${deps.session.describePath()}`);
26
+ lines.push(`Token type: ${session.token_type}`);
17
27
  deps.stdout.write(lines.join('\n') + '\n');
18
28
  }
@@ -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('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
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,3 +1,4 @@
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';
@@ -21,3 +22,18 @@ export function accountsBaseUrl(env) {
21
22
  export function clientId(env) {
22
23
  return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
23
24
  }
25
+ const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
26
+ export async function isCimdPlatformSupported(deps, apiUrl) {
27
+ try {
28
+ const result = await request(deps, {
29
+ method: 'GET',
30
+ url: `${apiUrl}/.well-known/oauth-authorization-server`,
31
+ });
32
+ if (result.status !== 200)
33
+ return undefined;
34
+ return result.body[CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD] === true;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
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,7 +6,7 @@ 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
11
  import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
12
12
  import { runWhoami } from './commands/whoami.js';
@@ -22,6 +22,7 @@ const GLOBAL_OPTIONS = {
22
22
  'dry-run': { type: 'boolean' },
23
23
  'no-browser': { type: 'boolean' },
24
24
  'no-keychain': { type: 'boolean' },
25
+ 'oidc-token-file': { type: 'string' },
25
26
  client: { type: 'string' },
26
27
  undo: { type: 'boolean' },
27
28
  write: { type: 'boolean' },
@@ -74,11 +75,25 @@ async function dispatch(args, deps) {
74
75
  const httpDeps = { fetchImpl: deps.fetchImpl };
75
76
  const [command, ...rest] = positionals;
76
77
  switch (command) {
77
- case 'login':
78
+ case 'login': {
79
+ if (values.ci === true) {
80
+ await runLoginCi({
81
+ ...httpDeps,
82
+ env: deps.env,
83
+ ciEnv: deps.ciEnv ?? {},
84
+ session: deps.session,
85
+ oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
86
+ readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
87
+ stdout: deps.stdout,
88
+ now: () => Date.now(),
89
+ });
90
+ return EXIT.OK;
91
+ }
78
92
  await runLogin({
79
93
  ...httpDeps,
80
94
  env: deps.env,
81
95
  session: deps.session,
96
+ keys: deps.keys,
82
97
  noBrowser: values['no-browser'] === true || ci,
83
98
  openBrowser: systemBrowserOpener(),
84
99
  startLoopback: startLoopbackListener,
@@ -87,16 +102,18 @@ async function dispatch(args, deps) {
87
102
  now: () => Date.now(),
88
103
  });
89
104
  return EXIT.OK;
105
+ }
90
106
  case 'logout':
91
107
  runLogout({ session: deps.session, stdout: deps.stdout });
92
108
  return EXIT.OK;
93
109
  case 'whoami':
94
- await runWhoami({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
110
+ await runWhoami({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
95
111
  return EXIT.OK;
96
112
  case 'connect':
97
113
  await runConnect({
98
114
  ...httpDeps,
99
115
  session: deps.session,
116
+ keys: deps.keys,
100
117
  cwd: deps.cwd,
101
118
  apiBaseUrl: apiBaseUrl(deps.env),
102
119
  mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
@@ -117,11 +134,11 @@ async function dispatch(args, deps) {
117
134
  const name = tenantArgs[0];
118
135
  if (!name)
119
136
  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' });
137
+ 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
138
  return EXIT.OK;
122
139
  }
123
140
  if (sub === 'list') {
124
- await runTenantsList({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
141
+ await runTenantsList({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
125
142
  return EXIT.OK;
126
143
  }
127
144
  throw new UsageError('Usage: rakomi tenants <create|list> ...');
@@ -130,6 +147,11 @@ async function dispatch(args, deps) {
130
147
  throw new UsageError(`Unknown command "${command ?? ''}".`);
131
148
  }
132
149
  }
150
+ export function printFallbackDisclosureOnce(resolved, stderr) {
151
+ if (resolved.fallbackDisclosure) {
152
+ stderr.write(resolved.fallbackDisclosure + '\n');
153
+ }
154
+ }
133
155
  async function main() {
134
156
  const major = Number(process.versions.node.split('.')[0]);
135
157
  if (Number.isFinite(major) && major < 22) {
@@ -152,11 +174,16 @@ async function main() {
152
174
  NO_COLOR: process.env.NO_COLOR,
153
175
  };
154
176
  const noKeychainEnv = process.env.RAKOMI_NO_KEYCHAIN;
177
+ const ciEnv = {
178
+ ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
179
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
180
+ CI_JOB_JWT_V2: process.env.CI_JOB_JWT_V2,
181
+ CI_JOB_JWT: process.env.CI_JOB_JWT,
182
+ RAKOMI_OIDC_TOKEN: process.env.RAKOMI_OIDC_TOKEN,
183
+ };
155
184
  const noKeychainFlag = process.argv.slice(2).includes('--no-keychain');
156
185
  const resolved = resolveStores(env, { noKeychain: noKeychainFlag || Boolean(env.CI) || Boolean(noKeychainEnv) });
157
- if (resolved.fallbackDisclosure) {
158
- process.stderr.write(resolved.fallbackDisclosure + '\n');
159
- }
186
+ printFallbackDisclosureOnce(resolved, { write: (text) => void process.stderr.write(text) });
160
187
  process.exitCode = await run(process.argv.slice(2), {
161
188
  stdout: { write: (text) => void process.stdout.write(text) },
162
189
  stderr: { write: (text) => void process.stderr.write(text) },
@@ -166,7 +193,9 @@ async function main() {
166
193
  isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
167
194
  fetchImpl: fetch,
168
195
  session: resolved.session,
196
+ keys: resolved.keys,
169
197
  detectClaudeCode: () => detectClaudeCodeCli(),
198
+ ciEnv,
170
199
  });
171
200
  }
172
201
  function detectClaudeCodeCli() {
@@ -0,0 +1,85 @@
1
+ import { createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
2
+ import { 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 getOrCreateInstallKey(keys, clientId) {
23
+ const raw = keys.get(INSTALL_KEY_NAME);
24
+ if (raw !== null) {
25
+ try {
26
+ const parsed = JSON.parse(raw);
27
+ if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
28
+ return parsed;
29
+ }
30
+ catch {
31
+ }
32
+ }
33
+ const fresh = generateKeyPair(clientId);
34
+ keys.set(INSTALL_KEY_NAME, JSON.stringify(fresh));
35
+ return fresh;
36
+ }
37
+ function base64url(buf) {
38
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
39
+ }
40
+ export function signClientAssertion(key, opts) {
41
+ const privateKey = createPrivateKey(key.privateKeyPem);
42
+ const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
43
+ const header = { alg: 'ES256', typ: 'JWT' };
44
+ const payload = {
45
+ iss: opts.clientId,
46
+ sub: opts.clientId,
47
+ aud: opts.audience,
48
+ jti: randomUUID(),
49
+ iat: now,
50
+ exp: now + ASSERTION_LIFETIME_SEC,
51
+ };
52
+ const h64 = base64url(Buffer.from(JSON.stringify(header)));
53
+ const p64 = base64url(Buffer.from(JSON.stringify(payload)));
54
+ const signingInput = `${h64}.${p64}`;
55
+ const signature = cryptoSign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' });
56
+ return `${signingInput}.${base64url(signature)}`;
57
+ }
58
+ export async function bindInstallKey(deps, keys, opts, key) {
59
+ try {
60
+ const result = await request(deps, {
61
+ method: 'POST',
62
+ url: `${opts.apiBaseUrl}/v1/oauth-clients/install-key`,
63
+ headers: opts.dpopBound ? undefined : { authorization: `Bearer ${opts.accessToken}` },
64
+ body: { client_id: opts.clientId, jwk: key.publicJwk },
65
+ dpop: opts.dpopBound ? { key, accessToken: opts.accessToken } : undefined,
66
+ });
67
+ if (result.status !== 200)
68
+ return { attempted: true, bound: false };
69
+ const bound = result.body.bound === true;
70
+ if (bound !== key.confirmedBound) {
71
+ keys.set(INSTALL_KEY_NAME, JSON.stringify({ ...key, confirmedBound: bound }));
72
+ }
73
+ return { attempted: true, bound };
74
+ }
75
+ catch {
76
+ return { attempted: false, bound: false };
77
+ }
78
+ }
79
+ export function resolveDpopKey(keys, session) {
80
+ if (session.token_type !== 'DPoP')
81
+ return undefined;
82
+ if (!isCimdClientId(session.client_id))
83
+ return undefined;
84
+ return getOrCreateInstallKey(keys, session.client_id);
85
+ }
@@ -21,7 +21,14 @@ export async function exchangeAuthorizationCode(deps, opts) {
21
21
  redirect_uri: opts.redirectUri,
22
22
  client_id: opts.clientId,
23
23
  code_verifier: opts.codeVerifier,
24
+ ...(opts.clientAssertion
25
+ ? {
26
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
27
+ client_assertion: opts.clientAssertion,
28
+ }
29
+ : {}),
24
30
  },
31
+ dpop: opts.dpopKey ? { key: opts.dpopKey } : undefined,
25
32
  });
26
33
  if (result.status !== 200) {
27
34
  throw new CliError(`Could not complete sign-in: ${describeError(result.body, result.status)}`, EXIT.FAIL);
@@ -0,0 +1,24 @@
1
+ import { buildDpopProof, canonicalizeHtu, generateEphemeralDpopKey } 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 dpopKey = generateEphemeralDpopKey();
8
+ const proof = buildDpopProof(dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
9
+ const result = await request(deps, {
10
+ method: 'POST',
11
+ url: tokenUrl,
12
+ headers: { dpop: proof },
13
+ form: {
14
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
15
+ subject_token: opts.subjectToken,
16
+ subject_token_type: OIDC_FEDERATION_SUBJECT_TOKEN_TYPE,
17
+ resource: opts.resource,
18
+ },
19
+ });
20
+ if (result.status !== 200) {
21
+ throw new CliError(`Could not sign in from CI: ${describeError(result.body, result.status)}`, EXIT.FAIL);
22
+ }
23
+ return result.body;
24
+ }
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,7 +95,7 @@ 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
101
  typeof v.client_id === 'string');
@@ -96,12 +103,41 @@ function isStoredSession(value) {
96
103
  export function isExpired(session, now) {
97
104
  return now >= session.expires_at;
98
105
  }
106
+ export class ExecFileFailure extends Error {
107
+ command;
108
+ status;
109
+ stderrOutput;
110
+ constructor(command, status, stderrOutput) {
111
+ super(`${command} exited with status ${status ?? 'unknown'}${stderrOutput.length > 0 ? `: ${stderrOutput}` : ''}`);
112
+ this.command = command;
113
+ this.status = status;
114
+ this.stderrOutput = stderrOutput;
115
+ this.name = 'ExecFileFailure';
116
+ }
117
+ }
99
118
  function realExecFileSync(file, args, options) {
100
- return execFileSync(file, [...args], { encoding: 'utf8', input: options?.input, maxBuffer: 10 * 1024 * 1024 }).toString();
119
+ try {
120
+ return execFileSync(file, [...args], {
121
+ encoding: 'utf8',
122
+ input: options?.input,
123
+ maxBuffer: 10 * 1024 * 1024,
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ }).toString();
126
+ }
127
+ catch (e) {
128
+ const err = e;
129
+ const rawStderr = err.stderr;
130
+ const stderrOutput = (typeof rawStderr === 'string' ? rawStderr : rawStderr?.toString('utf8')) ?? '';
131
+ throw new ExecFileFailure(file, err.status ?? null, stderrOutput.trim());
132
+ }
101
133
  }
102
134
  function commandExistsOnPath(binaryName, pathEnv, existsFn) {
103
135
  return pathEnv.split(delimiter).some((dir) => dir.length > 0 && existsFn(join(dir, binaryName)));
104
136
  }
137
+ function keychainReadFailure(backendLabel, cause) {
138
+ const detail = cause instanceof ExecFileFailure && cause.stderrOutput.length > 0 ? cause.stderrOutput : cause instanceof Error ? cause.message : String(cause);
139
+ return new CliError(`Could not read your session from ${backendLabel}: ${detail}`);
140
+ }
105
141
  export class MacKeychainBackend {
106
142
  execFileSyncFn;
107
143
  platform;
@@ -127,8 +163,10 @@ export class MacKeychainBackend {
127
163
  const value = out.trim();
128
164
  return value.length > 0 ? value : null;
129
165
  }
130
- catch {
131
- return null;
166
+ catch (e) {
167
+ if (e instanceof ExecFileFailure && e.status === 44)
168
+ return null;
169
+ throw keychainReadFailure('macOS Keychain', e);
132
170
  }
133
171
  }
134
172
  set(service, account, value) {
@@ -165,8 +203,10 @@ export class LinuxSecretToolBackend {
165
203
  const value = out.replace(/\n$/, '');
166
204
  return value.length > 0 ? value : null;
167
205
  }
168
- catch {
169
- return null;
206
+ catch (e) {
207
+ if (e instanceof ExecFileFailure && e.stderrOutput.length === 0)
208
+ return null;
209
+ throw keychainReadFailure('the Linux Secret Service', e);
170
210
  }
171
211
  }
172
212
  set(service, account, value) {
@@ -227,7 +267,13 @@ export class WindowsDpapiBackend {
227
267
  const valueB64 = Buffer.from(value, 'utf8').toString('base64');
228
268
  const script = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Convert]::FromBase64String('${valueB64}'), $null, 'CurrentUser'))`;
229
269
  const cipherB64 = this.execFileSyncFn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]).trim();
230
- writeFileSync(this.filePath(service, account), cipherB64 + '\n', { mode: 0o600 });
270
+ const path = this.filePath(service, account);
271
+ writeFileSync(path, cipherB64 + '\n', { mode: 0o600 });
272
+ try {
273
+ chmodSync(path, 0o600);
274
+ }
275
+ catch {
276
+ }
231
277
  }
232
278
  delete(service, account) {
233
279
  try {
@@ -286,12 +332,25 @@ export class KeychainKeyStore {
286
332
  this.backend = backend;
287
333
  }
288
334
  get(name) {
335
+ try {
336
+ assertValidKeyName(name);
337
+ }
338
+ catch {
339
+ return null;
340
+ }
289
341
  return this.backend.get(KEY_SERVICE, name);
290
342
  }
291
343
  set(name, value) {
344
+ assertValidKeyName(name);
292
345
  this.backend.set(KEY_SERVICE, name, value);
293
346
  }
294
347
  clear(name) {
348
+ try {
349
+ assertValidKeyName(name);
350
+ }
351
+ catch {
352
+ return;
353
+ }
295
354
  this.backend.delete(KEY_SERVICE, name);
296
355
  }
297
356
  }
@@ -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,6 +7,8 @@ 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',
10
12
  ' logout clear the local session',
11
13
  ' whoami show the signed-in account',
12
14
  ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
@@ -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.1.2",
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.1.2",
30
30
  "name": "rakomi",
31
- "version": "0.1.1",
32
- "purl": "pkg:npm/rakomi@0.1.1",
31
+ "version": "0.1.2",
32
+ "purl": "pkg:npm/rakomi@0.1.2",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {