rakomi 0.1.0 → 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.
package/SECURITY.md ADDED
@@ -0,0 +1,206 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ This policy covers the four JS-family SDK packages: `@rakomi/node`, `@rakomi/sdk-core`,
6
+ `@rakomi/react`, and `@rakomi/react-native`.
7
+
8
+ While these packages remain pre-1.0 (`0.x`), they carry **no stability or support guarantee**
9
+ (SemVer 2.0.0 §4); the latest `0.x` line receives security updates on a best-effort basis.
10
+
11
+ From version **1.0** onward, Rakomi maintains the current (N) and previous (N-1) MAJOR in parallel, with N-1 receiving
12
+ security-only fixes. The CRA support period for each MAJOR is determined in accordance with
13
+ **CRA Art. 13(8)** — at least five years, or the product's expected use time where shorter. The authoritative, machine-readable support windows are published at
14
+ [`https://api.rakomi.com/.well-known/sdk-support.json`](https://api.rakomi.com/.well-known/sdk-support.json)
15
+ and rendered for humans on the [SDK Support & Lifecycle page](https://rakomi.com/sdk-support). This
16
+ document points at that single source rather than re-typing dated rows.
17
+
18
+ Vulnerabilities in peer dependencies (e.g., React) are out of Rakomi's direct scope, but Rakomi will update minimum peer dependency versions when a peer dependency has a known critical CVE affecting SDK users.
19
+
20
+ ## Reporting a Vulnerability
21
+
22
+ **Please do NOT open a public GitHub issue for security vulnerabilities.**
23
+
24
+ We support two reporting channels:
25
+
26
+ 1. **Email:** security@rakomi.com (preferred for initial contact)
27
+ 2. **GitHub Private Vulnerability Reporting:** [Submit via GitHub Security](https://github.com/rakomidev/rakomi-js/security/advisories/new) — each report automatically receives a GHSA tracking identifier (GHSA-xxxx-xxxx-xxxx).
28
+
29
+ For encrypted communication, a PGP public key is available at:
30
+ `https://rakomi.com/rakomi-security-pgp-key.asc`
31
+
32
+ Include the key fingerprint from this file (section 14) for out-of-band verification when sending encrypted reports.
33
+
34
+ ## Response Targets
35
+
36
+ We strive to meet these response targets. Actual response times may vary based on issue complexity and team availability.
37
+
38
+ | Severity | First Response | Fix Target | Disclosure |
39
+ |----------|---------------|------------|------------|
40
+ | Critical (actively exploited) | without undue delay (target: 24h) | 72h patch/mitigation | Designated EU authority notified within 24h (CRA Art. 14) |
41
+ | High | without undue delay (target: 48h) | 14 days | Coordinated after fix |
42
+ | Medium | 5 business days | 90 days | Coordinated after fix |
43
+ | Low | 5 business days | Next release cycle | Changelog note |
44
+
45
+ Severity is assessed using industry-standard vulnerability scoring criteria.
46
+
47
+ Rakomi is maintained by a small team. During periods of reduced availability, the auto-reply from security@rakomi.com will confirm receipt and provide the PGP key. For actively exploited vulnerabilities, we will respond as quickly as humanly possible.
48
+
49
+ ## Coordinated Vulnerability Disclosure (CVD) Policy
50
+
51
+ We follow a coordinated disclosure model with a **90-day embargo** period from the date of acknowledgment. During this time:
52
+
53
+ - Rakomi will work to develop and release a fix
54
+ - We will keep the reporter updated on progress at least every 7 business days for Critical/High severity issues
55
+ - We will notify the reporter when a fix is released
56
+ - After 90 days, we will publish a security advisory regardless of fix status
57
+
58
+ We may request an extension if the fix requires significant infrastructure changes, and we will coordinate with the reporter before any deadline extension.
59
+
60
+ ## Safe Harbor
61
+
62
+ We will not pursue legal action against researchers who follow this disclosure policy and act in good faith. We consider security research conducted in accordance with this policy to be:
63
+ - Conducted lawfully and in good faith under applicable EU law
64
+ - Not subject to legal action by CRE8EVE Sp. z o.o.
65
+
66
+ Safe harbor **does not extend to**:
67
+ - Accessing or modifying other users' data
68
+ - Performing denial of service attacks
69
+ - Social engineering employees or users
70
+ - Exfiltrating data beyond what is necessary to demonstrate the vulnerability
71
+ - Any activity that violates applicable law
72
+
73
+ ## Scope
74
+
75
+ **In scope:**
76
+ - `@rakomi/node`, `@rakomi/sdk-core`, `@rakomi/react`, and `@rakomi/react-native` SDK source code and published npm packages
77
+ - Security properties of API interactions initiated by the SDKs (request signing, token verification, credential handling)
78
+ - Authentication flow logic within the SDKs
79
+
80
+ **Out of scope:**
81
+ - Social engineering attacks against Rakomi employees or users
82
+ - Denial of service attacks
83
+ - Physical security
84
+ - Vulnerabilities in third-party services used by Rakomi's backend
85
+ - Vulnerabilities in peer dependencies (e.g., React) — reported to the relevant maintainer, but Rakomi will update minimum peer dependency versions when a peer dependency has a known critical CVE affecting SDK users
86
+
87
+ This policy applies to the official `@rakomi/node`, `@rakomi/sdk-core`, `@rakomi/react`, and `@rakomi/react-native` packages distributed via npmjs.com. Forks and derivatives are maintained by their respective authors. Customers in regulated sectors (healthcare, finance) may have additional notification obligations beyond this general policy — contact security@rakomi.com for sector-specific compliance documentation.
88
+
89
+ ## EU Authority Reporting
90
+
91
+ It is our **policy** to report actively exploited vulnerabilities and severe security incidents
92
+ having an impact on the security of our products to the relevant EU authority in accordance with
93
+ **CRA Art. 14**, on the statutory timeline: an early warning, followed by a fuller notification, and
94
+ a final report.
95
+
96
+ We report to the national coordinator CSIRT designated for our Member State of main establishment
97
+ (Poland), which is our live reporting channel today; onboarding to the EU single reporting platform
98
+ is in progress, and that platform is the documented onward path as it becomes available to
99
+ manufacturers.
100
+
101
+ ## Security Update Notifications
102
+
103
+ Consumers of Rakomi SDKs can receive security update notifications through:
104
+ - **GitHub Security Advisories** on this repository (subscribe via GitHub "Watch" → "Security alerts")
105
+ - **npm audit:** `npm audit` or `pnpm audit` will flag known vulnerabilities in installed versions
106
+
107
+ In accordance with CRA Art. 14(8), after becoming aware of an actively exploited vulnerability or a severe incident having an impact on the security of our products, we will inform impacted users (and, where appropriate, all users) — together with any available risk-mitigation or corrective measures — through the above channels.
108
+
109
+ ## Manufacturer Identification
110
+
111
+ **Legal entity:** CRE8EVE Sp. z o.o.
112
+ **Registered address:** Tulipanowa 4, 72-003 Dobra, Poland (EU)
113
+ **Contact:** security@rakomi.com (role-based — no personal mailbox or phone is published)
114
+ **Products covered:** `@rakomi/node`, `@rakomi/sdk-core`, `@rakomi/react`, `@rakomi/react-native` (published on npmjs.com)
115
+
116
+ The manufacturer is itself EU-established (Poland), so no CRA Art. 18 Authorised Representative is required (Art. 18 applies to manufacturers established outside the Union). CRA conformity assessment (Art. 32): these are Class I important products (Annex III). The internal-control procedure (Annex VIII, Module A — manufacturer self-assessment, no notified-body involvement) is available for a Class I product **only where harmonised standards, common specifications, or a European cybersecurity certification scheme at assurance level at least 'substantial' are applied in full** (Art. 32(2)); otherwise a third-party route — EU-type examination plus conformity to type (modules B+C), or full quality assurance (module H) — is required. The applicable route will be confirmed against the harmonised standards in force at the CRA application date. The EU Declaration of Conformity and CE marking attach at the CRA application date (Dec 2027) and are not yet issued. See the full manufacturer record at https://docs.rakomi.dev/compliance/manufacturer/.
117
+
118
+ ## Export Control / Cryptography Notice
119
+
120
+ The four `@rakomi/*` SDK packages incorporate and invoke cryptography — they verify asymmetric
121
+ digital signatures (JWT/token verification), compare key material in constant time, and rely on the
122
+ host platform's TLS for transport security. They are distributed as **publicly available, mass-market
123
+ software** with cryptographic functionality the end user cannot readily modify.
124
+
125
+ - **EU — Regulation (EU) 2021/821 (Dual-Use):** the SDKs qualify for the **mass-market** treatment
126
+ under the Cryptography Note (Note 3) to Category 5, Part 2 of Annex I — generally available to the
127
+ public, sold without restriction, and not designed for the user to alter the cryptographic
128
+ functionality. No export authorisation is required for their distribution within or from the EU, and —
129
+ unlike the US path — the EU decontrol is **self-executing**, with no notification or filing step.
130
+ - **US — Export Administration Regulations (EAR):** the cryptographic functionality is classifiable
131
+ under **ECCN 5D002**. As **publicly available** open-source software the source code is **not
132
+ subject to the EAR** (15 CFR §734.7(a)), and the corresponding object code is distributed under the
133
+ mass-market provisions. The one-time email notification of the public source-code URL to the U.S.
134
+ BIS and NSA is filed at first public release (15 CFR §742.15(b)).
135
+
136
+ This notice is provided for transparency and is **not legal advice**. Downstream redistributors are
137
+ responsible for their own export, import, and use obligations in their jurisdiction.
138
+
139
+ ## Post-Market Surveillance
140
+
141
+ Rakomi monitors SDK health after release through:
142
+ - Automated dependency vulnerability scanning (npm audit, Dependabot)
143
+ - Runtime error patterns derived from API logs (SDK version reported in User-Agent header)
144
+ - Periodic security review of SDK code per the internal security review process
145
+
146
+ This constitutes the "effective and regular tests and reviews of the security of the product with digital elements" required under CRA Annex I, Part II, point (3). The coordinated-vulnerability-disclosure policy required under CRA Annex I, Part II, point (5) is set out in the "Coordinated Vulnerability Disclosure (CVD) Policy" section above.
147
+
148
+ ## No Bounty Program
149
+
150
+ Rakomi does not currently operate a paid bug bounty program. We deeply appreciate responsible disclosure and will acknowledge researchers in security advisories (with their consent).
151
+
152
+ ## Reference: security.txt
153
+
154
+ This policy is referenced in our machine-readable security contact file (RFC 9116):
155
+ `/.well-known/security.txt` — deployed at `https://rakomi.com/.well-known/security.txt`
156
+
157
+ ---
158
+
159
+ ## What to Include in Your Report
160
+
161
+ *(ISO/IEC 29147:2018 §6.5)*
162
+
163
+ To help us triage efficiently, please include:
164
+
165
+ 1. **Affected package** name and version (e.g., @rakomi/node 0.2.0)
166
+ 2. **Reproduction steps** — a minimal, reproducible example
167
+ 3. **Impact assessment** — what an attacker could achieve
168
+ 4. **Proof of concept** — if available (do not use real user data)
169
+ 5. **Reporter contact** — so we can keep you updated
170
+
171
+ Reports that do not include reproduction steps or fall outside the defined scope may be closed without a tracking ID.
172
+
173
+ ## Report Tracking
174
+
175
+ *(ISO/IEC 29147:2018 §6.6)*
176
+
177
+ Each report receives a unique tracking identifier upon acknowledgment. For reports submitted via GitHub Private Vulnerability Reporting, the GHSA identifier (e.g., GHSA-xxxx-xxxx-xxxx) serves as the tracking ID. For email reports, we will direct you to also submit via GitHub PVR for formal tracking.
178
+
179
+ ## Status Updates
180
+
181
+ *(ISO/IEC 29147:2018 §6.4)*
182
+
183
+ We provide status updates at least every **7 business days** for Critical/High severity issues, and upon resolution for Medium/Low severity issues.
184
+
185
+ ## CVE Assignment
186
+
187
+ Confirmed vulnerabilities with sufficient impact will receive CVE identifiers via GitHub's CNA (CVE Numbering Authority) program.
188
+
189
+ ## Reporter Data Privacy
190
+
191
+ *(GDPR Art. 6(1)(f) + Art. 13/14)*
192
+
193
+ Reporter personal data (name, email) is processed under GDPR Art. 6(1)(f) legitimate interest for vulnerability coordination. This data is:
194
+ - Retained for the duration of the vulnerability lifecycle plus 2 years
195
+ - Not shared with third parties except as required for CVE assignment or regulatory reporting (e.g., ENISA, national CSIRT)
196
+ - Accessible to the reporter upon request (GDPR Art. 15)
197
+ - Deletable upon request after vulnerability closure (GDPR Art. 17, where not overridden by regulatory retention obligations)
198
+
199
+ To exercise your GDPR rights, contact security@rakomi.com.
200
+
201
+ ## PGP Key Fingerprint
202
+
203
+ The PGP public key for encrypted communication is available at:
204
+ `https://rakomi.com/rakomi-security-pgp-key.asc`
205
+
206
+ Verify the key fingerprint through an independent channel (e.g., LinkedIn, Twitter/X, or a direct phone call) before sending sensitive information.
@@ -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.0",
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",
@@ -34,7 +34,9 @@
34
34
  "files": [
35
35
  "dist",
36
36
  "README.md",
37
- "LICENSE"
37
+ "LICENSE",
38
+ "SECURITY.md",
39
+ "sbom.cdx.json"
38
40
  ],
39
41
  "scripts": {
40
42
  "build": "tsc",
package/sbom.cdx.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "bomFormat": "CycloneDX",
3
+ "specVersion": "1.6",
4
+ "version": 1,
5
+ "metadata": {
6
+ "supplier": {
7
+ "name": "CRE8EVE Sp. z o.o.",
8
+ "url": [
9
+ "https://rakomi.com"
10
+ ]
11
+ },
12
+ "authors": [
13
+ {
14
+ "name": "CRE8EVE Sp. z o.o."
15
+ }
16
+ ],
17
+ "tools": {
18
+ "components": [
19
+ {
20
+ "type": "application",
21
+ "group": "rakomi",
22
+ "name": "generate-sbom",
23
+ "version": "sha256:ffabbf3f1638"
24
+ }
25
+ ]
26
+ },
27
+ "component": {
28
+ "type": "library",
29
+ "bom-ref": "pkg:npm/rakomi@0.1.2",
30
+ "name": "rakomi",
31
+ "version": "0.1.2",
32
+ "purl": "pkg:npm/rakomi@0.1.2",
33
+ "licenses": [
34
+ {
35
+ "license": {
36
+ "name": "NOASSERTION"
37
+ }
38
+ }
39
+ ]
40
+ }
41
+ },
42
+ "components": []
43
+ }