clavarium-tether 0.1.0-next.2 → 0.1.0-next.4

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/host/oauth.mjs ADDED
@@ -0,0 +1,219 @@
1
+ /* THE OAUTH SOCLE -- user-delegated access to Gmail and Microsoft (mail today,
2
+ * calendar and files next), so a mailbox is reached by a token and never a
3
+ * stored password. It is the shape Thunderbird uses: keep IMAP/SMTP, but
4
+ * AUTHENTICATE with a bearer token instead of LOGIN, and get that token from an
5
+ * OAuth consent the person gives in their own browser.
6
+ *
7
+ * THE RUSE, AND WHY A CLIENT ID SITS HERE. Google and Microsoft only let a
8
+ * *registered* application ask for consent. Registering one per installation is
9
+ * exactly what an open, self-hosted project cannot ask of everybody, so instead
10
+ * we present ourselves with Thunderbird's own built-in client -- the id (and, for
11
+ * Google, the secret) that ship, in the open, in Mozilla's source
12
+ * (comm-central mailnews/base/src/OAuth2Providers.sys.mjs). These are installed-
13
+ * app / public-client credentials: not a secret in the real sense (anyone who has
14
+ * Thunderbird has them), and that is why reusing them is sound. Mozilla's file
15
+ * says "don't copy these for your own application" -- we copy them *as*
16
+ * Thunderbird, deliberately, with the operator's decision, and the consent window
17
+ * tells the person plainly that this is what Clavarium does. A deployment that
18
+ * would rather register its own application sets its own clientId/secret and
19
+ * never touches the defaults below.
20
+ *
21
+ * NOTHING AUTOMATES THE LOGIN. The person signs in to Google/Microsoft in their
22
+ * own real browser, however they normally do (password, passkey, YubiKey, 2FA) --
23
+ * we never see or type a credential. All we ever handle is the authorization
24
+ * `code` the provider hands back to a loopback address, and the refresh token we
25
+ * trade it for. Capturing that code is the host's business (a pasted address, or
26
+ * the browser extension): this file is only the OAuth arithmetic, and takes an
27
+ * injected `fetch` so it runs under test with no network.
28
+ */
29
+ import { createHash, randomBytes } from 'node:crypto';
30
+
31
+ const b64url = (buf) => Buffer.from(buf).toString('base64url');
32
+
33
+ /* Per-provider endpoints, the scopes we may ask for by kind, and Thunderbird's
34
+ built-in client as the default. `consentExtra` is what a provider needs on the
35
+ consent URL to return a refresh token; `offlineScope` is the same thing when it
36
+ is a scope rather than a parameter (Microsoft). */
37
+ export const PROVIDERS = {
38
+ google: {
39
+ label: 'Google',
40
+ auth: 'https://accounts.google.com/o/oauth2/auth',
41
+ token: 'https://oauth2.googleapis.com/token',
42
+ pkce: true,
43
+ consentExtra: { access_type: 'offline', prompt: 'consent' },
44
+ offlineScope: null,
45
+ scopes: {
46
+ mail: 'https://mail.google.com/',
47
+ calendar: 'https://www.googleapis.com/auth/calendar',
48
+ contacts: 'https://www.googleapis.com/auth/carddav',
49
+ },
50
+ thunderbird: {
51
+ clientId: '406964657835-aq8lmia8j95dhl1a2bvharmfk3t1hgqj.apps.googleusercontent.com',
52
+ clientSecret: 'kSmqreRr0qwBWJgbf5Y-PjSU',
53
+ },
54
+ },
55
+ microsoft: {
56
+ label: 'Microsoft',
57
+ auth: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
58
+ token: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
59
+ pkce: true,
60
+ consentExtra: {},
61
+ offlineScope: 'offline_access',
62
+ /* Mail (and SMTP) are outlook.office.com resources; calendar/files over
63
+ Graph are a different resource and a separate token -- added when those
64
+ connectors arrive, not mixed into one mail request. */
65
+ scopes: {
66
+ mail: 'https://outlook.office.com/IMAP.AccessAsUser.All',
67
+ smtp: 'https://outlook.office.com/SMTP.Send',
68
+ },
69
+ thunderbird: {
70
+ clientId: '9e5f94bc-e8a4-4e73-b8be-63364c29d753',
71
+ clientSecret: null,
72
+ },
73
+ },
74
+ };
75
+
76
+ export function provider(name) {
77
+ const p = PROVIDERS[name];
78
+ if (!p) throw new Error(`unknown oauth provider: ${name}`);
79
+ return p;
80
+ }
81
+
82
+ /* Thunderbird's built-in client for a provider -- the default when a deployment
83
+ has not set its own. Returned as a fresh object so a caller may override it. */
84
+ export function thunderbird(name) {
85
+ const { clientId, clientSecret } = provider(name).thunderbird;
86
+ return { clientId, clientSecret };
87
+ }
88
+
89
+ /* A PKCE pair: a random verifier kept by the host, and the S256 challenge put on
90
+ the consent URL. Both providers accept (Microsoft's public client needs) PKCE. */
91
+ export function pkcePair() {
92
+ const verifier = b64url(randomBytes(32));
93
+ const challenge = b64url(createHash('sha256').update(verifier).digest());
94
+ return { verifier, challenge, method: 'S256' };
95
+ }
96
+
97
+ /* An opaque value to tie a consent to the request that started it, so a stray or
98
+ forged redirect is refused (see extractCode). */
99
+ export function newState() { return b64url(randomBytes(16)); }
100
+
101
+ /* The scope string for the kinds wanted (default: mail), de-duplicated, with the
102
+ provider's offline scope appended when it has one. */
103
+ export function scopeFor(name, kinds = ['mail']) {
104
+ const p = provider(name);
105
+ const parts = [];
106
+ for (const k of kinds) {
107
+ const s = p.scopes[k];
108
+ if (!s) throw new Error(`${p.label} has no ${k} scope here`);
109
+ parts.push(...s.split(/\s+/).filter(Boolean));
110
+ }
111
+ if (p.offlineScope) parts.push(p.offlineScope);
112
+ return [...new Set(parts)].join(' ');
113
+ }
114
+
115
+ /* The URL the person's browser opens to sign in and allow. `redirectUri` is the
116
+ loopback the provider sends the code back to (http://127.0.0.1:PORT). */
117
+ export function consentUrl(name, { clientId, redirectUri, scope, state, codeChallenge, loginHint } = {}) {
118
+ const p = provider(name);
119
+ if (!clientId) throw new Error('consentUrl needs a clientId');
120
+ if (!redirectUri) throw new Error('consentUrl needs a redirectUri');
121
+ if (!scope) throw new Error('consentUrl needs a scope');
122
+ const q = new URLSearchParams({
123
+ client_id: clientId,
124
+ response_type: 'code',
125
+ redirect_uri: redirectUri,
126
+ scope,
127
+ ...(state ? { state } : {}),
128
+ ...p.consentExtra,
129
+ });
130
+ if (p.pkce) {
131
+ if (!codeChallenge) throw new Error(`${p.label} requires PKCE: pass codeChallenge`);
132
+ q.set('code_challenge', codeChallenge);
133
+ q.set('code_challenge_method', 'S256');
134
+ }
135
+ if (loginHint) q.set('login_hint', loginHint);
136
+ return `${p.auth}?${q.toString()}`;
137
+ }
138
+
139
+ /* Pull the authorization code out of the address the browser lands on after the
140
+ person allows -- the loopback URL they paste back, or that the extension reads.
141
+ Verifies the state when one is given, and turns an `error=...` redirect into a
142
+ plain message. */
143
+ export function extractCode(redirected, { state } = {}) {
144
+ let u;
145
+ try { u = new URL(String(redirected ?? '').trim()); } catch { throw new Error('that is not the address the provider sent back'); }
146
+ const q = u.searchParams;
147
+ const err = q.get('error');
148
+ if (err) throw new Error(`the provider refused: ${q.get('error_description') || err}`);
149
+ const code = q.get('code');
150
+ if (!code) throw new Error('that address carries no code -- paste the whole address from the bar after you allow');
151
+ const got = q.get('state');
152
+ if (state && got && got !== state) throw new Error('that address does not match this request (state mismatch)');
153
+ return code;
154
+ }
155
+
156
+ async function postToken(name, params, fetchImpl) {
157
+ const p = provider(name);
158
+ const body = new URLSearchParams(params);
159
+ let res;
160
+ try {
161
+ res = await fetchImpl(p.token, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
164
+ body: body.toString(),
165
+ });
166
+ } catch (e) { throw new Error(`cannot reach the ${p.label} token service: ${e?.message ?? e}`); }
167
+ const j = await res.json().catch(() => ({}));
168
+ if (!res.ok) throw new Error(`${p.label} token service refused (${res.status}): ${j.error_description || j.error || 'no detail'}`);
169
+ return j;
170
+ }
171
+
172
+ /* access_token + refresh_token + a wall-clock expiry (ms). */
173
+ function tokenShape(j) {
174
+ return {
175
+ accessToken: j.access_token,
176
+ refreshToken: j.refresh_token,
177
+ expiresAt: Date.now() + (Number(j.expires_in) || 3600) * 1000,
178
+ scope: j.scope,
179
+ };
180
+ }
181
+
182
+ /* Trade the authorization code for the first refresh token (consent, once per
183
+ mailbox). Google's installed-app client sends its secret and the PKCE verifier;
184
+ Microsoft's public client sends the verifier only. */
185
+ export async function exchangeCode(name, { clientId, clientSecret, redirectUri, code, codeVerifier, fetch: fetchImpl = globalThis.fetch } = {}) {
186
+ if (!clientId) throw new Error('exchangeCode needs a clientId');
187
+ if (!code) throw new Error('exchangeCode needs a code');
188
+ if (!redirectUri) throw new Error('exchangeCode needs the same redirectUri as the consent');
189
+ const params = { grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId };
190
+ if (clientSecret) params.client_secret = clientSecret;
191
+ if (codeVerifier) params.code_verifier = codeVerifier;
192
+ const t = tokenShape(await postToken(name, params, fetchImpl));
193
+ if (!t.accessToken) throw new Error(`${provider(name).label} gave no access token`);
194
+ if (!t.refreshToken) throw new Error(`${provider(name).label} gave no refresh token -- the consent must ask for offline access`);
195
+ return t;
196
+ }
197
+
198
+ /* Spend the refresh token for a fresh access token (every hour or so, at use).
199
+ The provider may or may not hand back a new refresh token; keep the old one if
200
+ it does not. */
201
+ export async function refreshAccess(name, { clientId, clientSecret, refreshToken, scope, fetch: fetchImpl = globalThis.fetch } = {}) {
202
+ if (!clientId) throw new Error('refreshAccess needs a clientId');
203
+ if (!refreshToken) throw new Error('refreshAccess needs a refreshToken');
204
+ const params = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId };
205
+ if (clientSecret) params.client_secret = clientSecret;
206
+ if (scope) params.scope = scope;
207
+ const t = tokenShape(await postToken(name, params, fetchImpl));
208
+ if (!t.accessToken) throw new Error(`${provider(name).label} gave no access token on refresh`);
209
+ if (!t.refreshToken) t.refreshToken = refreshToken;
210
+ return t;
211
+ }
212
+
213
+ /* The SASL XOAUTH2 initial response for an IMAP/SMTP AUTHENTICATE: the base64 of
214
+ `user=<mail>^Aauth=Bearer <token>^A^A`. The mail connector will use this in
215
+ place of LOGIN; kept here with the rest of the OAuth arithmetic. */
216
+ export function xoauth2(user, accessToken) {
217
+ if (!user || !accessToken) throw new Error('xoauth2 needs a user and an access token');
218
+ return Buffer.from(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`, 'utf8').toString('base64');
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium-tether",
3
- "version": "0.1.0-next.2",
3
+ "version": "0.1.0-next.4",
4
4
  "description": "Clavarium Tether: an AI agent acts outside its own machine on a tether: levels per target, a person's approval, a journal and a stop (a command and an MCP server for agents, a keeper and a screen for the application). One dependency: ssh2, for the SSH connector.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/clavarium/clavarium-tether#readme",
@@ -53,6 +53,10 @@
53
53
  "./connectors/ssh": "./host/connectors/ssh.mjs",
54
54
  "./ui": "./ui/ui.js",
55
55
  "./ui.css": "./ui/ui.css",
56
- "./ui/texts/*": "./ui/texts/*"
56
+ "./ui/texts/*": "./ui/texts/*",
57
+ "./connectors/webdav": "./host/connectors/webdav.mjs",
58
+ "./connectors/graph": "./host/connectors/graph.mjs",
59
+ "./connectors/browser": "./host/connectors/browser.mjs",
60
+ "./connectors/screen": "./host/connectors/screen.mjs"
57
61
  }
58
62
  }
package/server/name.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  export const NAME = 'tether';
7
7
  export const PRODUCT = 'Clavarium Tether';
8
8
  export const PACKAGE = `clavarium-${NAME}`;
9
- export const VERSION = '0.1.0-next.2';
9
+ export const VERSION = '0.1.0-next.4';
10
10
  /* The agent's folder: where its asks and the keeper's answers are written. */
11
11
  export const FOLDER = `.${NAME}`;
12
12
  /* $TETHER_DIR: the agent's folder; $TETHER_STORE: the keeper's store (serve). */