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 +219 -0
- package/package.json +6 -2
- package/server/name.mjs +1 -1
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.
|
|
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.
|
|
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). */
|