@onride/agent 0.0.0-stage → 0.5.1-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/dist/account_balances.js +435 -0
- package/dist/account_reads.js +199 -0
- package/dist/agent/adapter.js +115 -0
- package/dist/agent/analytics.js +174 -0
- package/dist/agent/backend.js +237 -0
- package/dist/agent/card_hosts.js +9 -0
- package/dist/agent/contracts.js +276 -0
- package/dist/agent/conversational.js +86 -0
- package/dist/agent/core_bridge.js +1155 -0
- package/dist/agent/deposits.js +309 -0
- package/dist/agent/discovery.js +205 -0
- package/dist/agent/domain.js +507 -0
- package/dist/agent/firebase.js +34 -0
- package/dist/agent/funding.js +230 -0
- package/dist/agent/http.js +40 -0
- package/dist/agent/human.js +298 -0
- package/dist/agent/human_handoffs.js +39 -0
- package/dist/agent/ledger.js +137 -0
- package/dist/agent/mcp.js +245 -0
- package/dist/agent/order_rules.js +61 -0
- package/dist/agent/perps_account.js +41 -0
- package/dist/agent/prediction.js +607 -0
- package/dist/agent/prediction_equity_history.js +910 -0
- package/dist/agent/prediction_history.js +336 -0
- package/dist/agent/preferences.js +114 -0
- package/dist/agent/repository.js +106 -0
- package/dist/agent/runner.js +683 -0
- package/dist/agent/service.js +1347 -0
- package/dist/backend.js +6 -0
- package/dist/bridge.js +6 -0
- package/dist/cli/clients.js +99 -0
- package/dist/cli/commands.js +332 -0
- package/dist/cli/onboarding.js +53 -0
- package/dist/cli/session.js +224 -0
- package/dist/cli/setup.js +256 -0
- package/dist/cli.js +16 -0
- package/dist/guide.js +92 -0
- package/dist/http_security.js +79 -0
- package/dist/mcp-cli.js +7 -0
- package/dist/oauth.js +1032 -0
- package/dist/server.js +171 -0
- package/dist/start.js +18 -0
- package/dist/version.js +25 -0
- package/docs/confirmation-cards.md +118 -0
- package/docs/conversation-v050.md +30 -0
- package/docs/conversational-v17.md +48 -0
- package/docs/deployment.md +58 -0
- package/docs/perps-leverage.md +43 -0
- package/docs/prediction-history.md +28 -0
- package/docs/preferences-funding-v042.md +17 -0
- package/docs/ride-agent-migration.md +127 -0
- package/docs/v17-completion-checklist.md +31 -0
- package/docs/v17-rollout.md +44 -0
- package/docs/widget-account-v043.md +17 -0
- package/package.json +75 -4
- package/public/agent.html +2313 -0
- package/public/confirm.html +2373 -0
- package/public/fund.html +563 -0
- package/skills/ride/SKILL.md +18 -0
- package/skills/ride/references/commands.md +47 -0
- package/skills/ride/references/workflow.md +47 -0
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { createClient } from "redis";
|
|
4
|
+
import { InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
5
|
+
export const RIDE_OAUTH_SCOPES = ["ride:read", "ride:trade"];
|
|
6
|
+
export const DEFAULT_RIDE_OAUTH_SCOPES = ["ride:read"];
|
|
7
|
+
function validateSupportedScopes(scopes) {
|
|
8
|
+
const normalized = [...new Set(scopes)];
|
|
9
|
+
const unsupported = normalized.filter((scope) => !RIDE_OAUTH_SCOPES.includes(scope));
|
|
10
|
+
if (unsupported.length) {
|
|
11
|
+
throw new InvalidScopeError(`Unsupported OAuth scope: ${unsupported.join(" ")}`);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
function normalizeRequestedScopes(scope) {
|
|
16
|
+
const requested = scope?.split(/\s+/).filter(Boolean) ?? [];
|
|
17
|
+
return validateSupportedScopes(requested.length ? requested : [...DEFAULT_RIDE_OAUTH_SCOPES]);
|
|
18
|
+
}
|
|
19
|
+
class InMemoryClientsStore {
|
|
20
|
+
clients = new Map();
|
|
21
|
+
async getClient(clientId) {
|
|
22
|
+
return this.clients.get(clientId);
|
|
23
|
+
}
|
|
24
|
+
async registerClient(client) {
|
|
25
|
+
this.clients.set(client.client_id, client);
|
|
26
|
+
return client;
|
|
27
|
+
}
|
|
28
|
+
size() {
|
|
29
|
+
return this.clients.size;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class InMemoryOAuthStateStore {
|
|
33
|
+
kind = "memory";
|
|
34
|
+
clientsStore = new InMemoryClientsStore();
|
|
35
|
+
authorizationCodes = new Map();
|
|
36
|
+
accessTokens = new Map();
|
|
37
|
+
refreshTokens = new Map();
|
|
38
|
+
async getAuthorizationCode(code) {
|
|
39
|
+
return this.authorizationCodes.get(code);
|
|
40
|
+
}
|
|
41
|
+
async setAuthorizationCode(code, record) {
|
|
42
|
+
this.authorizationCodes.set(code, record);
|
|
43
|
+
}
|
|
44
|
+
async deleteAuthorizationCode(code) {
|
|
45
|
+
this.authorizationCodes.delete(code);
|
|
46
|
+
}
|
|
47
|
+
async getAccessToken(token) {
|
|
48
|
+
return this.accessTokens.get(token);
|
|
49
|
+
}
|
|
50
|
+
async setAccessToken(token, record) {
|
|
51
|
+
this.accessTokens.set(token, record);
|
|
52
|
+
}
|
|
53
|
+
async deleteAccessToken(token) {
|
|
54
|
+
this.accessTokens.delete(token);
|
|
55
|
+
}
|
|
56
|
+
async getRefreshToken(token) {
|
|
57
|
+
return this.refreshTokens.get(token);
|
|
58
|
+
}
|
|
59
|
+
async setRefreshToken(token, record) {
|
|
60
|
+
this.refreshTokens.set(token, record);
|
|
61
|
+
}
|
|
62
|
+
async deleteRefreshToken(token) {
|
|
63
|
+
this.refreshTokens.delete(token);
|
|
64
|
+
}
|
|
65
|
+
async getStats() {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const accessRecords = [...this.accessTokens.values()].filter((record) => record.expiresAt > now);
|
|
68
|
+
const refreshRecords = [...this.refreshTokens.values()].filter((record) => record.expiresAt > now);
|
|
69
|
+
return buildOAuthStats({
|
|
70
|
+
store: this.kind,
|
|
71
|
+
registeredClients: this.clientsStore.size(),
|
|
72
|
+
pendingAuthorizationCodes: [...this.authorizationCodes.values()].filter((record) => record.expiresAt > now).length,
|
|
73
|
+
accessRecords,
|
|
74
|
+
refreshRecords,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
class RedisClientsStore {
|
|
79
|
+
client;
|
|
80
|
+
keyPrefix;
|
|
81
|
+
constructor(client, keyPrefix) {
|
|
82
|
+
this.client = client;
|
|
83
|
+
this.keyPrefix = keyPrefix;
|
|
84
|
+
}
|
|
85
|
+
async getClient(clientId) {
|
|
86
|
+
const value = await this.client.get(`${this.keyPrefix}:client:${clientId}`);
|
|
87
|
+
return value ? JSON.parse(value) : undefined;
|
|
88
|
+
}
|
|
89
|
+
async registerClient(client) {
|
|
90
|
+
await this.client.set(`${this.keyPrefix}:client:${client.client_id}`, JSON.stringify(client));
|
|
91
|
+
return client;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class RedisOAuthStateStore {
|
|
95
|
+
client;
|
|
96
|
+
keyPrefix;
|
|
97
|
+
kind = "redis";
|
|
98
|
+
clientsStore;
|
|
99
|
+
constructor(client, keyPrefix) {
|
|
100
|
+
this.client = client;
|
|
101
|
+
this.keyPrefix = keyPrefix;
|
|
102
|
+
this.clientsStore = new RedisClientsStore(client, keyPrefix);
|
|
103
|
+
}
|
|
104
|
+
async getAuthorizationCode(code) {
|
|
105
|
+
const value = await this.client.get(this.key("authorization-code", code));
|
|
106
|
+
return value ? deserializeAuthorizationCodeRecord(value) : undefined;
|
|
107
|
+
}
|
|
108
|
+
async setAuthorizationCode(code, record) {
|
|
109
|
+
await this.setExpiring(this.key("authorization-code", code), serializeAuthorizationCodeRecord(record), record.expiresAt);
|
|
110
|
+
}
|
|
111
|
+
async deleteAuthorizationCode(code) {
|
|
112
|
+
await this.client.del(this.key("authorization-code", code));
|
|
113
|
+
}
|
|
114
|
+
async getAccessToken(token) {
|
|
115
|
+
const value = await this.client.get(this.key("access-token", token));
|
|
116
|
+
return value ? deserializeAccessTokenRecord(value) : undefined;
|
|
117
|
+
}
|
|
118
|
+
async setAccessToken(token, record) {
|
|
119
|
+
await this.setExpiring(this.key("access-token", token), serializeAccessTokenRecord(record), record.expiresAt);
|
|
120
|
+
}
|
|
121
|
+
async deleteAccessToken(token) {
|
|
122
|
+
await this.client.del(this.key("access-token", token));
|
|
123
|
+
}
|
|
124
|
+
async getRefreshToken(token) {
|
|
125
|
+
const value = await this.client.get(this.key("refresh-token", token));
|
|
126
|
+
return value ? deserializeAccessTokenRecord(value) : undefined;
|
|
127
|
+
}
|
|
128
|
+
async setRefreshToken(token, record) {
|
|
129
|
+
await this.setExpiring(this.key("refresh-token", token), serializeAccessTokenRecord(record), record.expiresAt);
|
|
130
|
+
}
|
|
131
|
+
async deleteRefreshToken(token) {
|
|
132
|
+
await this.client.del(this.key("refresh-token", token));
|
|
133
|
+
}
|
|
134
|
+
async getStats() {
|
|
135
|
+
const [registeredClients, pendingAuthorizationCodes, accessRecords, refreshRecords,] = await Promise.all([
|
|
136
|
+
this.countKeys(`${this.keyPrefix}:client:*`),
|
|
137
|
+
this.countKeys(`${this.keyPrefix}:authorization-code:*`),
|
|
138
|
+
this.readAccessRecords(`${this.keyPrefix}:access-token:*`),
|
|
139
|
+
this.readAccessRecords(`${this.keyPrefix}:refresh-token:*`),
|
|
140
|
+
]);
|
|
141
|
+
return buildOAuthStats({
|
|
142
|
+
store: this.kind,
|
|
143
|
+
registeredClients,
|
|
144
|
+
pendingAuthorizationCodes,
|
|
145
|
+
accessRecords,
|
|
146
|
+
refreshRecords,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
key(type, id) {
|
|
150
|
+
return `${this.keyPrefix}:${type}:${id}`;
|
|
151
|
+
}
|
|
152
|
+
async setExpiring(key, value, expiresAt) {
|
|
153
|
+
const ttlSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
|
|
154
|
+
await this.client.setEx(key, ttlSeconds, value);
|
|
155
|
+
}
|
|
156
|
+
async scanKeys(pattern) {
|
|
157
|
+
const keys = [];
|
|
158
|
+
const iterator = this.client.scanIterator({ MATCH: pattern, COUNT: 100 });
|
|
159
|
+
for await (const item of iterator) {
|
|
160
|
+
if (Array.isArray(item)) {
|
|
161
|
+
keys.push(...item);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
keys.push(item);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return keys;
|
|
168
|
+
}
|
|
169
|
+
async countKeys(pattern) {
|
|
170
|
+
return (await this.scanKeys(pattern)).length;
|
|
171
|
+
}
|
|
172
|
+
async readAccessRecords(pattern) {
|
|
173
|
+
const keys = await this.scanKeys(pattern);
|
|
174
|
+
const records = [];
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const value = await this.client.get(key);
|
|
177
|
+
if (!value)
|
|
178
|
+
continue;
|
|
179
|
+
try {
|
|
180
|
+
const record = deserializeAccessTokenRecord(value);
|
|
181
|
+
if (record.expiresAt > Date.now()) {
|
|
182
|
+
records.push(record);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Ignore malformed legacy records; stats must not break auth.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return records;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function buildOAuthStats(input) {
|
|
193
|
+
const accessSubjects = new Set(input.accessRecords.map((record) => record.identity.subject));
|
|
194
|
+
const refreshSubjects = new Set(input.refreshRecords.map((record) => record.identity.subject));
|
|
195
|
+
const refreshClientSubjectPairs = new Set(input.refreshRecords.map((record) => `${record.clientId}:${record.identity.subject}`));
|
|
196
|
+
return {
|
|
197
|
+
store: input.store,
|
|
198
|
+
registeredClients: input.registeredClients,
|
|
199
|
+
pendingAuthorizationCodes: input.pendingAuthorizationCodes,
|
|
200
|
+
activeAccessTokens: input.accessRecords.length,
|
|
201
|
+
activeAccessTokenSubjects: accessSubjects.size,
|
|
202
|
+
activeRefreshTokens: input.refreshRecords.length,
|
|
203
|
+
activeRefreshTokenSubjects: refreshSubjects.size,
|
|
204
|
+
activeRefreshTokenClientSubjectPairs: refreshClientSubjectPairs.size,
|
|
205
|
+
countedAt: new Date().toISOString(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export async function createRedisOAuthStateStore(options) {
|
|
209
|
+
const client = createClient({ url: options.url });
|
|
210
|
+
client.on("error", (error) => {
|
|
211
|
+
console.error("Ride OAuth Redis error:", error);
|
|
212
|
+
});
|
|
213
|
+
await client.connect();
|
|
214
|
+
return new RedisOAuthStateStore(client, options.keyPrefix ?? "ride:mcp:oauth");
|
|
215
|
+
}
|
|
216
|
+
function serializeAuthorizationCodeRecord(record) {
|
|
217
|
+
return JSON.stringify({
|
|
218
|
+
...record,
|
|
219
|
+
params: serializeAuthorizationParams(record.params),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function deserializeAuthorizationCodeRecord(value) {
|
|
223
|
+
const parsed = JSON.parse(value);
|
|
224
|
+
return {
|
|
225
|
+
...parsed,
|
|
226
|
+
params: deserializeAuthorizationParams(parsed.params),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function serializeAccessTokenRecord(record) {
|
|
230
|
+
return JSON.stringify({
|
|
231
|
+
...record,
|
|
232
|
+
resource: record.resource?.toString(),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function deserializeAccessTokenRecord(value) {
|
|
236
|
+
const parsed = JSON.parse(value);
|
|
237
|
+
return {
|
|
238
|
+
...parsed,
|
|
239
|
+
resource: parsed.resource ? new URL(parsed.resource) : undefined,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function serializeAuthorizationParams(params) {
|
|
243
|
+
return {
|
|
244
|
+
...params,
|
|
245
|
+
resource: params.resource?.toString(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function deserializeAuthorizationParams(params) {
|
|
249
|
+
return {
|
|
250
|
+
...params,
|
|
251
|
+
resource: params.resource ? new URL(params.resource) : undefined,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export class RideOAuthProvider {
|
|
255
|
+
options;
|
|
256
|
+
clientsStore;
|
|
257
|
+
store;
|
|
258
|
+
accessTokenTtlSeconds;
|
|
259
|
+
refreshTokenTtlSeconds;
|
|
260
|
+
constructor(options) {
|
|
261
|
+
this.options = options;
|
|
262
|
+
this.store = options.store ?? new InMemoryOAuthStateStore();
|
|
263
|
+
this.clientsStore = this.store.clientsStore;
|
|
264
|
+
this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 60 * 60;
|
|
265
|
+
this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? 30 * 24 * 60 * 60;
|
|
266
|
+
}
|
|
267
|
+
async authorize(client, params, res) {
|
|
268
|
+
params.scopes = validateSupportedScopes(params.scopes ?? [...DEFAULT_RIDE_OAUTH_SCOPES]);
|
|
269
|
+
res.status(200).type("html").send(renderAuthorizePage(client, params, this.options.authPage));
|
|
270
|
+
}
|
|
271
|
+
async completeAuthorization(input) {
|
|
272
|
+
const client = await this.clientsStore.getClient(input.clientId);
|
|
273
|
+
if (!client) {
|
|
274
|
+
throw new InvalidRequestError("Unknown OAuth client.");
|
|
275
|
+
}
|
|
276
|
+
await this.validateAuthorizationRedirect(input.clientId, input.redirectUri);
|
|
277
|
+
const scopes = normalizeRequestedScopes(input.scope);
|
|
278
|
+
const identity = await this.options.validateRideToken(input.rideAuthToken);
|
|
279
|
+
const code = randomUUID();
|
|
280
|
+
await this.store.setAuthorizationCode(code, {
|
|
281
|
+
client,
|
|
282
|
+
params: {
|
|
283
|
+
codeChallenge: input.codeChallenge,
|
|
284
|
+
redirectUri: input.redirectUri,
|
|
285
|
+
state: input.state,
|
|
286
|
+
scopes,
|
|
287
|
+
resource: input.resource ? new URL(input.resource) : undefined,
|
|
288
|
+
},
|
|
289
|
+
rideAuthToken: input.rideAuthToken,
|
|
290
|
+
identity,
|
|
291
|
+
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
292
|
+
});
|
|
293
|
+
return code;
|
|
294
|
+
}
|
|
295
|
+
async completePrivyAuthorization(input) {
|
|
296
|
+
const client = await this.clientsStore.getClient(input.clientId);
|
|
297
|
+
if (!client) {
|
|
298
|
+
throw new InvalidRequestError("Unknown OAuth client.");
|
|
299
|
+
}
|
|
300
|
+
await this.validateAuthorizationRedirect(input.clientId, input.redirectUri);
|
|
301
|
+
const scopes = normalizeRequestedScopes(input.scope);
|
|
302
|
+
const exchanged = await this.options.exchangePrivyToken(input.privy);
|
|
303
|
+
const code = randomUUID();
|
|
304
|
+
await this.store.setAuthorizationCode(code, {
|
|
305
|
+
client,
|
|
306
|
+
params: {
|
|
307
|
+
codeChallenge: input.codeChallenge,
|
|
308
|
+
redirectUri: input.redirectUri,
|
|
309
|
+
state: input.state,
|
|
310
|
+
scopes,
|
|
311
|
+
resource: input.resource ? new URL(input.resource) : undefined,
|
|
312
|
+
},
|
|
313
|
+
rideAuthToken: exchanged.rideAuthToken,
|
|
314
|
+
identity: exchanged.identity,
|
|
315
|
+
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
316
|
+
});
|
|
317
|
+
return code;
|
|
318
|
+
}
|
|
319
|
+
async challengeForAuthorizationCode(_client, authorizationCode) {
|
|
320
|
+
const record = await this.store.getAuthorizationCode(authorizationCode);
|
|
321
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
322
|
+
throw new InvalidGrantError("Invalid or expired authorization code.");
|
|
323
|
+
}
|
|
324
|
+
return record.params.codeChallenge;
|
|
325
|
+
}
|
|
326
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
327
|
+
const record = await this.store.getAuthorizationCode(authorizationCode);
|
|
328
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
329
|
+
throw new InvalidGrantError("Invalid or expired authorization code.");
|
|
330
|
+
}
|
|
331
|
+
if (record.client.client_id !== client.client_id) {
|
|
332
|
+
throw new InvalidGrantError("Authorization code was issued to a different client.");
|
|
333
|
+
}
|
|
334
|
+
if (redirectUri && redirectUri !== record.params.redirectUri) {
|
|
335
|
+
throw new InvalidGrantError("redirect_uri does not match the authorization request.");
|
|
336
|
+
}
|
|
337
|
+
if (resource && record.params.resource?.toString() !== resource.toString()) {
|
|
338
|
+
throw new InvalidGrantError("resource does not match the authorization request.");
|
|
339
|
+
}
|
|
340
|
+
await this.store.deleteAuthorizationCode(authorizationCode);
|
|
341
|
+
return this.issueTokens({
|
|
342
|
+
clientId: client.client_id,
|
|
343
|
+
scopes: record.params.scopes ?? [...DEFAULT_RIDE_OAUTH_SCOPES],
|
|
344
|
+
rideAuthToken: record.rideAuthToken,
|
|
345
|
+
identity: record.identity,
|
|
346
|
+
resource: record.params.resource,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
350
|
+
const record = await this.store.getRefreshToken(refreshToken);
|
|
351
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
352
|
+
throw new InvalidGrantError("Invalid or expired refresh token.");
|
|
353
|
+
}
|
|
354
|
+
if (record.clientId !== client.client_id) {
|
|
355
|
+
throw new InvalidGrantError("Refresh token was issued to a different client.");
|
|
356
|
+
}
|
|
357
|
+
if (resource && record.resource?.toString() !== resource.toString()) {
|
|
358
|
+
throw new InvalidGrantError("resource does not match the refresh token.");
|
|
359
|
+
}
|
|
360
|
+
const requestedScopes = scopes?.length
|
|
361
|
+
? validateSupportedScopes(scopes)
|
|
362
|
+
: record.scopes;
|
|
363
|
+
const expandedScopes = requestedScopes.filter((scope) => !record.scopes.includes(scope));
|
|
364
|
+
if (expandedScopes.length) {
|
|
365
|
+
throw new InvalidScopeError(`Refresh token cannot add scope: ${expandedScopes.join(" ")}`);
|
|
366
|
+
}
|
|
367
|
+
await this.store.deleteRefreshToken(refreshToken);
|
|
368
|
+
return this.issueTokens({
|
|
369
|
+
...record,
|
|
370
|
+
scopes: requestedScopes,
|
|
371
|
+
resource: resource ?? record.resource,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async validateAuthorizationRedirect(clientId, redirectUri) {
|
|
375
|
+
const client = await this.clientsStore.getClient(clientId);
|
|
376
|
+
if (!client) {
|
|
377
|
+
throw new InvalidRequestError("Unknown OAuth client.");
|
|
378
|
+
}
|
|
379
|
+
if (!redirectUriAllowed(redirectUri, client.redirect_uris)) {
|
|
380
|
+
throw new InvalidRequestError("Unregistered redirect_uri.");
|
|
381
|
+
}
|
|
382
|
+
return new URL(redirectUri);
|
|
383
|
+
}
|
|
384
|
+
async verifyAccessToken(token) {
|
|
385
|
+
const record = await this.store.getAccessToken(token);
|
|
386
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
387
|
+
throw new InvalidTokenError("Invalid or expired access token.");
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
token,
|
|
391
|
+
clientId: record.clientId,
|
|
392
|
+
scopes: record.scopes,
|
|
393
|
+
expiresAt: Math.floor(record.expiresAt / 1000),
|
|
394
|
+
resource: record.resource,
|
|
395
|
+
extra: {
|
|
396
|
+
rideAuthToken: record.rideAuthToken,
|
|
397
|
+
subject: record.identity.subject,
|
|
398
|
+
displayName: record.identity.displayName,
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
async revokeToken(_client, request) {
|
|
403
|
+
await this.store.deleteAccessToken(request.token);
|
|
404
|
+
await this.store.deleteRefreshToken(request.token);
|
|
405
|
+
}
|
|
406
|
+
async issueTokens(input) {
|
|
407
|
+
const accessToken = `ride_at_${randomUUID()}`;
|
|
408
|
+
const refreshToken = `ride_rt_${randomUUID()}`;
|
|
409
|
+
const accessRecord = {
|
|
410
|
+
...input,
|
|
411
|
+
expiresAt: Date.now() + this.accessTokenTtlSeconds * 1000,
|
|
412
|
+
};
|
|
413
|
+
const refreshRecord = {
|
|
414
|
+
...input,
|
|
415
|
+
expiresAt: Date.now() + this.refreshTokenTtlSeconds * 1000,
|
|
416
|
+
};
|
|
417
|
+
await this.store.setAccessToken(accessToken, accessRecord);
|
|
418
|
+
await this.store.setRefreshToken(refreshToken, refreshRecord);
|
|
419
|
+
return {
|
|
420
|
+
access_token: accessToken,
|
|
421
|
+
token_type: "bearer",
|
|
422
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
423
|
+
refresh_token: refreshToken,
|
|
424
|
+
scope: input.scopes.join(" "),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
export function createRideOAuthApprovalRouter(provider) {
|
|
429
|
+
const router = express.Router();
|
|
430
|
+
router.use(express.json({ limit: "32kb" }));
|
|
431
|
+
router.use(express.urlencoded({ extended: false, limit: "32kb" }));
|
|
432
|
+
router.get("/auth/privy/oauth/callback", (_req, res) => {
|
|
433
|
+
res.status(200).type("html").send(renderPrivyOAuthCallbackPage());
|
|
434
|
+
});
|
|
435
|
+
router.post("/auth/privy/approve", async (req, res) => {
|
|
436
|
+
try {
|
|
437
|
+
const code = await provider.completePrivyAuthorization({
|
|
438
|
+
clientId: requiredField(req.body.client_id, "client_id"),
|
|
439
|
+
redirectUri: requiredField(req.body.redirect_uri, "redirect_uri"),
|
|
440
|
+
codeChallenge: requiredField(req.body.code_challenge, "code_challenge"),
|
|
441
|
+
state: stringField(req.body.state),
|
|
442
|
+
scope: stringField(req.body.scope),
|
|
443
|
+
resource: stringField(req.body.resource),
|
|
444
|
+
privy: {
|
|
445
|
+
privyId: requiredField(req.body.privy_id, "privy_id"),
|
|
446
|
+
privyAccessToken: requiredField(req.body.privy_access_token, "privy_access_token"),
|
|
447
|
+
email: stringField(req.body.email),
|
|
448
|
+
walletAddress: stringField(req.body.wallet_address),
|
|
449
|
+
privyWalletId: stringField(req.body.privy_wallet_id),
|
|
450
|
+
authProvider: stringField(req.body.auth_provider),
|
|
451
|
+
accessCode: stringField(req.body.access_code),
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
const target = new URL(requiredField(req.body.redirect_uri, "redirect_uri"));
|
|
455
|
+
target.searchParams.set("code", code);
|
|
456
|
+
const state = stringField(req.body.state);
|
|
457
|
+
if (state)
|
|
458
|
+
target.searchParams.set("state", state);
|
|
459
|
+
res.json({ redirectUrl: target.toString() });
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
const message = error instanceof Error ? error.message : "Authorization failed.";
|
|
463
|
+
res.status(400).json({ error: "authorization_failed", message });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
router.post("/auth/approve", async (req, res) => {
|
|
467
|
+
let validatedRedirect;
|
|
468
|
+
try {
|
|
469
|
+
const action = stringField(req.body.action);
|
|
470
|
+
const clientId = requiredField(req.body.client_id, "client_id");
|
|
471
|
+
const redirectUri = requiredField(req.body.redirect_uri, "redirect_uri");
|
|
472
|
+
const state = stringField(req.body.state);
|
|
473
|
+
validatedRedirect = await provider.validateAuthorizationRedirect(clientId, redirectUri);
|
|
474
|
+
if (action === "deny") {
|
|
475
|
+
redirectWithOAuthError(res, validatedRedirect, "access_denied", "Authorization was denied.", state);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const code = await provider.completeAuthorization({
|
|
479
|
+
clientId,
|
|
480
|
+
redirectUri,
|
|
481
|
+
codeChallenge: requiredField(req.body.code_challenge, "code_challenge"),
|
|
482
|
+
state,
|
|
483
|
+
scope: stringField(req.body.scope),
|
|
484
|
+
resource: stringField(req.body.resource),
|
|
485
|
+
rideAuthToken: requiredField(req.body.ride_token, "ride_token"),
|
|
486
|
+
});
|
|
487
|
+
const target = new URL(validatedRedirect);
|
|
488
|
+
target.searchParams.set("code", code);
|
|
489
|
+
if (state)
|
|
490
|
+
target.searchParams.set("state", state);
|
|
491
|
+
res.redirect(302, target.toString());
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
if (validatedRedirect) {
|
|
495
|
+
const description = error instanceof Error ? error.message : "Authorization failed.";
|
|
496
|
+
redirectWithOAuthError(res, validatedRedirect, "invalid_request", description, stringField(req.body.state));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
res.status(400).send("Invalid authorization request.");
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
return router;
|
|
503
|
+
}
|
|
504
|
+
function requiredField(value, name) {
|
|
505
|
+
const parsed = stringField(value);
|
|
506
|
+
if (!parsed) {
|
|
507
|
+
throw new InvalidRequestError(`${name} is required.`);
|
|
508
|
+
}
|
|
509
|
+
return parsed;
|
|
510
|
+
}
|
|
511
|
+
function stringField(value) {
|
|
512
|
+
if (Array.isArray(value))
|
|
513
|
+
return stringField(value[0]);
|
|
514
|
+
if (typeof value !== "string")
|
|
515
|
+
return undefined;
|
|
516
|
+
const trimmed = value.trim();
|
|
517
|
+
return trimmed || undefined;
|
|
518
|
+
}
|
|
519
|
+
function redirectWithOAuthError(res, redirectUri, error, description, state) {
|
|
520
|
+
const target = new URL(redirectUri);
|
|
521
|
+
target.searchParams.set("error", error);
|
|
522
|
+
target.searchParams.set("error_description", description);
|
|
523
|
+
if (state)
|
|
524
|
+
target.searchParams.set("state", state);
|
|
525
|
+
res.redirect(302, target.toString());
|
|
526
|
+
}
|
|
527
|
+
function redirectUriAllowed(redirectUri, registeredUris) {
|
|
528
|
+
return registeredUris.some((registered) => {
|
|
529
|
+
if (redirectUri === registered)
|
|
530
|
+
return true;
|
|
531
|
+
try {
|
|
532
|
+
const requestedUrl = new URL(redirectUri);
|
|
533
|
+
const registeredUrl = new URL(registered);
|
|
534
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
535
|
+
return (loopbackHosts.has(requestedUrl.hostname) &&
|
|
536
|
+
loopbackHosts.has(registeredUrl.hostname) &&
|
|
537
|
+
requestedUrl.protocol === registeredUrl.protocol &&
|
|
538
|
+
requestedUrl.hostname === registeredUrl.hostname &&
|
|
539
|
+
requestedUrl.pathname === registeredUrl.pathname &&
|
|
540
|
+
requestedUrl.search === registeredUrl.search);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function renderAuthorizePage(client, params, config) {
|
|
548
|
+
const clientName = client.client_name || "MCP Client";
|
|
549
|
+
const scope = (params.scopes || [...DEFAULT_RIDE_OAUTH_SCOPES]).join(" ");
|
|
550
|
+
const fields = {
|
|
551
|
+
client_id: client.client_id,
|
|
552
|
+
redirect_uri: params.redirectUri,
|
|
553
|
+
code_challenge: params.codeChallenge,
|
|
554
|
+
state: params.state,
|
|
555
|
+
scope,
|
|
556
|
+
resource: params.resource?.toString(),
|
|
557
|
+
};
|
|
558
|
+
const hiddenInputs = Object.entries(fields)
|
|
559
|
+
.map(([name, value]) => value ? `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">` : "")
|
|
560
|
+
.join("\n ");
|
|
561
|
+
const oauthPayload = JSON.stringify(fields).replaceAll("<", "\\u003c");
|
|
562
|
+
return `<!doctype html>
|
|
563
|
+
<html lang="en">
|
|
564
|
+
<head>
|
|
565
|
+
<meta charset="utf-8">
|
|
566
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
567
|
+
<title>Authorize Ride Agent</title>
|
|
568
|
+
<style>
|
|
569
|
+
:root {
|
|
570
|
+
color-scheme: light;
|
|
571
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
572
|
+
color: #1f2933;
|
|
573
|
+
background: #f6f7f9;
|
|
574
|
+
}
|
|
575
|
+
body {
|
|
576
|
+
margin: 0;
|
|
577
|
+
min-height: 100vh;
|
|
578
|
+
display: grid;
|
|
579
|
+
place-items: center;
|
|
580
|
+
padding: 24px;
|
|
581
|
+
}
|
|
582
|
+
main {
|
|
583
|
+
width: min(100%, 460px);
|
|
584
|
+
background: #ffffff;
|
|
585
|
+
border: 1px solid #dde3ea;
|
|
586
|
+
border-radius: 8px;
|
|
587
|
+
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.12);
|
|
588
|
+
padding: 28px;
|
|
589
|
+
}
|
|
590
|
+
h1 {
|
|
591
|
+
margin: 0 0 8px;
|
|
592
|
+
font-size: 24px;
|
|
593
|
+
line-height: 1.2;
|
|
594
|
+
letter-spacing: 0;
|
|
595
|
+
}
|
|
596
|
+
p {
|
|
597
|
+
margin: 0 0 18px;
|
|
598
|
+
color: #52616f;
|
|
599
|
+
line-height: 1.55;
|
|
600
|
+
}
|
|
601
|
+
.scope {
|
|
602
|
+
margin: 18px 0;
|
|
603
|
+
padding: 12px;
|
|
604
|
+
border: 1px solid #dde3ea;
|
|
605
|
+
border-radius: 6px;
|
|
606
|
+
background: #f9fafb;
|
|
607
|
+
font-size: 13px;
|
|
608
|
+
color: #394b59;
|
|
609
|
+
}
|
|
610
|
+
label {
|
|
611
|
+
display: block;
|
|
612
|
+
font-size: 13px;
|
|
613
|
+
font-weight: 650;
|
|
614
|
+
margin-bottom: 8px;
|
|
615
|
+
}
|
|
616
|
+
input, textarea {
|
|
617
|
+
width: 100%;
|
|
618
|
+
box-sizing: border-box;
|
|
619
|
+
border: 1px solid #c7d0d9;
|
|
620
|
+
border-radius: 6px;
|
|
621
|
+
padding: 10px 12px;
|
|
622
|
+
font: 14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
623
|
+
color: #1f2933;
|
|
624
|
+
min-height: 42px;
|
|
625
|
+
}
|
|
626
|
+
textarea {
|
|
627
|
+
min-height: 96px;
|
|
628
|
+
resize: vertical;
|
|
629
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
630
|
+
font-size: 13px;
|
|
631
|
+
}
|
|
632
|
+
.actions {
|
|
633
|
+
display: flex;
|
|
634
|
+
gap: 10px;
|
|
635
|
+
margin-top: 18px;
|
|
636
|
+
}
|
|
637
|
+
button {
|
|
638
|
+
appearance: none;
|
|
639
|
+
border: 1px solid transparent;
|
|
640
|
+
border-radius: 6px;
|
|
641
|
+
padding: 10px 14px;
|
|
642
|
+
font-weight: 700;
|
|
643
|
+
cursor: pointer;
|
|
644
|
+
min-height: 42px;
|
|
645
|
+
}
|
|
646
|
+
button[type="submit"], button.primary {
|
|
647
|
+
flex: 1;
|
|
648
|
+
background: #1677ff;
|
|
649
|
+
color: #ffffff;
|
|
650
|
+
}
|
|
651
|
+
button.secondary, button[name="action"][value="deny"] {
|
|
652
|
+
background: #ffffff;
|
|
653
|
+
border-color: #c7d0d9;
|
|
654
|
+
color: #394b59;
|
|
655
|
+
}
|
|
656
|
+
button.google {
|
|
657
|
+
width: 100%;
|
|
658
|
+
display: flex;
|
|
659
|
+
align-items: center;
|
|
660
|
+
justify-content: center;
|
|
661
|
+
gap: 10px;
|
|
662
|
+
background: #ffffff;
|
|
663
|
+
border-color: #c7d0d9;
|
|
664
|
+
color: #1f2933;
|
|
665
|
+
font-weight: 700;
|
|
666
|
+
}
|
|
667
|
+
.google-mark {
|
|
668
|
+
display: inline-grid;
|
|
669
|
+
place-items: center;
|
|
670
|
+
width: 20px;
|
|
671
|
+
height: 20px;
|
|
672
|
+
border-radius: 50%;
|
|
673
|
+
border: 1px solid #dde3ea;
|
|
674
|
+
font-weight: 800;
|
|
675
|
+
font-size: 13px;
|
|
676
|
+
color: #1677ff;
|
|
677
|
+
}
|
|
678
|
+
.field {
|
|
679
|
+
margin-top: 14px;
|
|
680
|
+
}
|
|
681
|
+
.status {
|
|
682
|
+
min-height: 20px;
|
|
683
|
+
margin-top: 14px;
|
|
684
|
+
font-size: 13px;
|
|
685
|
+
color: #52616f;
|
|
686
|
+
line-height: 1.45;
|
|
687
|
+
}
|
|
688
|
+
.status.error {
|
|
689
|
+
color: #b42318;
|
|
690
|
+
}
|
|
691
|
+
.hidden {
|
|
692
|
+
display: none;
|
|
693
|
+
}
|
|
694
|
+
.fallback {
|
|
695
|
+
margin-top: 24px;
|
|
696
|
+
padding-top: 18px;
|
|
697
|
+
border-top: 1px solid #dde3ea;
|
|
698
|
+
}
|
|
699
|
+
.fallback summary {
|
|
700
|
+
cursor: pointer;
|
|
701
|
+
color: #52616f;
|
|
702
|
+
font-size: 13px;
|
|
703
|
+
font-weight: 650;
|
|
704
|
+
}
|
|
705
|
+
</style>
|
|
706
|
+
</head>
|
|
707
|
+
<body>
|
|
708
|
+
<main>
|
|
709
|
+
<h1>Authorize Ride Agent</h1>
|
|
710
|
+
<p>${escapeHtml(clientName)} wants access to your Ride account for Auto-Ride tools.</p>
|
|
711
|
+
<div class="scope">Scopes: ${escapeHtml(scope)}</div>
|
|
712
|
+
|
|
713
|
+
<form id="google-form">
|
|
714
|
+
<div class="field">
|
|
715
|
+
<label for="access-code">Access code, if this is a new Ride account</label>
|
|
716
|
+
<input id="access-code" name="access-code" type="text" autocomplete="off">
|
|
717
|
+
</div>
|
|
718
|
+
<div class="actions">
|
|
719
|
+
<button id="google-login" class="google" type="button">
|
|
720
|
+
<span class="google-mark">G</span>
|
|
721
|
+
Continue with Google
|
|
722
|
+
</button>
|
|
723
|
+
</div>
|
|
724
|
+
<div id="status" class="status">Continue with Google to connect your Ride account.</div>
|
|
725
|
+
</form>
|
|
726
|
+
|
|
727
|
+
${config.manualTokenAuthEnabled
|
|
728
|
+
? `<details class="fallback">
|
|
729
|
+
<summary>Developer fallback: paste Ride access token</summary>
|
|
730
|
+
<form method="post" action="/auth/approve">
|
|
731
|
+
${hiddenInputs}
|
|
732
|
+
<div class="field">
|
|
733
|
+
<label for="ride_token">Ride API bearer token</label>
|
|
734
|
+
<textarea id="ride_token" name="ride_token" autocomplete="off" spellcheck="false" required></textarea>
|
|
735
|
+
</div>
|
|
736
|
+
<div class="actions">
|
|
737
|
+
<button type="submit" name="action" value="approve">Authorize</button>
|
|
738
|
+
<button type="submit" name="action" value="deny">Deny</button>
|
|
739
|
+
</div>
|
|
740
|
+
</form>
|
|
741
|
+
</details>`
|
|
742
|
+
: ""}
|
|
743
|
+
</main>
|
|
744
|
+
<script type="module">
|
|
745
|
+
import Privy, {LocalStorage} from '${escapeJs(config.jsSdkUrl)}';
|
|
746
|
+
|
|
747
|
+
const config = {
|
|
748
|
+
privyAppId: '${escapeJs(config.privyAppId)}',
|
|
749
|
+
privyClientId: '${escapeJs(config.privyClientId)}',
|
|
750
|
+
oauth: ${oauthPayload}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const ACCESS_CODE_KEY = 'ride:mcp:oauth_access_code';
|
|
754
|
+
const statusEl = document.getElementById('status');
|
|
755
|
+
const googleLoginButton = document.getElementById('google-login');
|
|
756
|
+
const accessCodeInput = document.getElementById('access-code');
|
|
757
|
+
|
|
758
|
+
let privy;
|
|
759
|
+
let privyInitialization;
|
|
760
|
+
|
|
761
|
+
function setStatus(message, isError = false) {
|
|
762
|
+
statusEl.textContent = message;
|
|
763
|
+
statusEl.classList.toggle('error', isError);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function formatAuthError(error) {
|
|
767
|
+
const message = error?.message || String(error);
|
|
768
|
+
if (message.includes('Redirect URL is not allowed')) {
|
|
769
|
+
return 'Redirect URL is not allowed. Add ' + window.location.origin + '/auth/privy/oauth/callback in Privy Advanced -> Allowed OAuth redirect URLs.';
|
|
770
|
+
}
|
|
771
|
+
if (error?.name === 'AbortError' || error?.name === 'TimeoutError' || /aborted|timed? ?out/i.test(message)) {
|
|
772
|
+
return 'The connection to Privy timed out. Check your network or proxy, then retry Continue with Google. Your account has not been changed.';
|
|
773
|
+
}
|
|
774
|
+
return message;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function setBusy(button, busy) {
|
|
778
|
+
button.disabled = busy;
|
|
779
|
+
button.style.opacity = busy ? '0.65' : '1';
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function initPrivy() {
|
|
783
|
+
if (privy) return privy;
|
|
784
|
+
if (privyInitialization) return privyInitialization;
|
|
785
|
+
if (!config.privyAppId) {
|
|
786
|
+
throw new Error('Ride Agent is missing Privy configuration.');
|
|
787
|
+
}
|
|
788
|
+
const options = {
|
|
789
|
+
appId: config.privyAppId,
|
|
790
|
+
storage: new LocalStorage()
|
|
791
|
+
};
|
|
792
|
+
if (config.privyClientId) {
|
|
793
|
+
options.clientId = config.privyClientId;
|
|
794
|
+
}
|
|
795
|
+
privyInitialization = (async () => {
|
|
796
|
+
const client = new Privy(options);
|
|
797
|
+
await client.initialize();
|
|
798
|
+
privy = client;
|
|
799
|
+
return client;
|
|
800
|
+
})().catch((error) => {
|
|
801
|
+
privyInitialization = undefined;
|
|
802
|
+
throw error;
|
|
803
|
+
});
|
|
804
|
+
return privyInitialization;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function linkedAccounts(user) {
|
|
808
|
+
const camel = Array.isArray(user?.linkedAccounts) ? user.linkedAccounts : [];
|
|
809
|
+
const snake = Array.isArray(user?.linked_accounts) ? user.linked_accounts : [];
|
|
810
|
+
return camel.length ? camel : snake;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function accountValue(account, camelKey, snakeKey) {
|
|
814
|
+
return account?.[camelKey] ?? account?.[snakeKey];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function getEmailFromUser(user) {
|
|
818
|
+
if (typeof user?.email === 'string') return user.email;
|
|
819
|
+
if (typeof user?.email?.address === 'string') return user.email.address;
|
|
820
|
+
if (typeof user?.emailAddress === 'string') return user.emailAddress;
|
|
821
|
+
const accounts = linkedAccounts(user);
|
|
822
|
+
const emailAccount = accounts.find((account) =>
|
|
823
|
+
account?.email || account?.address || account?.emailAddress || account?.email_address
|
|
824
|
+
);
|
|
825
|
+
return emailAccount?.email || emailAccount?.address || emailAccount?.emailAddress || emailAccount?.email_address || undefined;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function getEmbeddedEthereumWallet(user) {
|
|
829
|
+
const accounts = linkedAccounts(user);
|
|
830
|
+
const wallets = accounts.filter((account) => {
|
|
831
|
+
const chainType = accountValue(account, 'chainType', 'chain_type');
|
|
832
|
+
return (
|
|
833
|
+
account?.type === 'wallet' &&
|
|
834
|
+
chainType === 'ethereum' &&
|
|
835
|
+
account?.address &&
|
|
836
|
+
(
|
|
837
|
+
accountValue(account, 'walletClientType', 'wallet_client_type') === 'privy' ||
|
|
838
|
+
accountValue(account, 'connectorType', 'connector_type') === 'embedded'
|
|
839
|
+
)
|
|
840
|
+
);
|
|
841
|
+
});
|
|
842
|
+
wallets.sort((a, b) => {
|
|
843
|
+
const left = accountValue(a, 'walletIndex', 'wallet_index') ?? 0;
|
|
844
|
+
const right = accountValue(b, 'walletIndex', 'wallet_index') ?? 0;
|
|
845
|
+
return Number(left) - Number(right);
|
|
846
|
+
});
|
|
847
|
+
return wallets[0];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function ensureEmbeddedEthereumWallet(client, user) {
|
|
851
|
+
let wallet = getEmbeddedEthereumWallet(user);
|
|
852
|
+
if (wallet) return {user, wallet};
|
|
853
|
+
|
|
854
|
+
if (!client.embeddedWallet?.add) {
|
|
855
|
+
throw new Error('Privy login did not return an embedded wallet.');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
setStatus('Creating Ride wallet...');
|
|
859
|
+
const result = await client.embeddedWallet.add({chainType: 'ethereum'});
|
|
860
|
+
const refreshedUser = result?.user || (await client.user.get()).user || user;
|
|
861
|
+
wallet = getEmbeddedEthereumWallet(refreshedUser);
|
|
862
|
+
if (!wallet?.address) {
|
|
863
|
+
throw new Error('Privy did not return an embedded wallet address.');
|
|
864
|
+
}
|
|
865
|
+
return {user: refreshedUser, wallet};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function getStoredAccessCode() {
|
|
869
|
+
return accessCodeInput.value.trim() || sessionStorage.getItem(ACCESS_CODE_KEY) || undefined;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function approveWithPrivy(user, privyAccessToken, wallet, authProvider) {
|
|
873
|
+
const response = await fetch('/auth/privy/approve', {
|
|
874
|
+
method: 'POST',
|
|
875
|
+
headers: {'Content-Type': 'application/json'},
|
|
876
|
+
body: JSON.stringify({
|
|
877
|
+
...config.oauth,
|
|
878
|
+
privy_id: user.id,
|
|
879
|
+
privy_access_token: privyAccessToken,
|
|
880
|
+
email: getEmailFromUser(user),
|
|
881
|
+
wallet_address: wallet?.address,
|
|
882
|
+
privy_wallet_id: wallet?.id,
|
|
883
|
+
auth_provider: authProvider,
|
|
884
|
+
access_code: getStoredAccessCode()
|
|
885
|
+
})
|
|
886
|
+
});
|
|
887
|
+
const payload = await response.json().catch(() => ({}));
|
|
888
|
+
if (!response.ok) {
|
|
889
|
+
throw new Error(payload.message || 'Ride authorization failed.');
|
|
890
|
+
}
|
|
891
|
+
sessionStorage.removeItem(ACCESS_CODE_KEY);
|
|
892
|
+
window.location.assign(payload.redirectUrl);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function finishPrivySession(session, authProvider) {
|
|
896
|
+
const client = await initPrivy();
|
|
897
|
+
let user = session?.user || (await client.user.get()).user;
|
|
898
|
+
if (!user?.id) throw new Error('Privy login did not return a user.');
|
|
899
|
+
const walletResult = await ensureEmbeddedEthereumWallet(client, user);
|
|
900
|
+
user = walletResult.user;
|
|
901
|
+
setStatus('Getting Ride authorization...');
|
|
902
|
+
const privyAccessToken = await client.getAccessToken();
|
|
903
|
+
if (!privyAccessToken) throw new Error('Could not get Privy access token.');
|
|
904
|
+
await approveWithPrivy(user, privyAccessToken, walletResult.wallet, authProvider);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function googleRedirectUrl() {
|
|
908
|
+
return window.location.origin + '/auth/privy/oauth/callback';
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function handleGoogleCallback() {
|
|
912
|
+
const url = new URL(window.location.href);
|
|
913
|
+
const authorizationCode = url.searchParams.get('privy_oauth_code');
|
|
914
|
+
const stateCode = url.searchParams.get('privy_oauth_state');
|
|
915
|
+
if (!authorizationCode && !stateCode) return;
|
|
916
|
+
if (!authorizationCode || !stateCode) {
|
|
917
|
+
throw new Error('Google login returned incomplete OAuth credentials.');
|
|
918
|
+
}
|
|
919
|
+
setStatus('Completing Google login...');
|
|
920
|
+
const client = await initPrivy();
|
|
921
|
+
const session = await client.auth.oauth.loginWithCode(
|
|
922
|
+
authorizationCode,
|
|
923
|
+
stateCode,
|
|
924
|
+
'google'
|
|
925
|
+
);
|
|
926
|
+
await finishPrivySession(session, 'GOOGLE');
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
googleLoginButton.addEventListener('click', async () => {
|
|
930
|
+
try {
|
|
931
|
+
setBusy(googleLoginButton, true);
|
|
932
|
+
setStatus('Starting Google login...');
|
|
933
|
+
const accessCode = accessCodeInput.value.trim();
|
|
934
|
+
if (accessCode) sessionStorage.setItem(ACCESS_CODE_KEY, accessCode);
|
|
935
|
+
sessionStorage.setItem('ride:mcp:oauth_authorize_url', window.location.href);
|
|
936
|
+
const client = await initPrivy();
|
|
937
|
+
const response = await client.auth.oauth.generateURL('google', googleRedirectUrl());
|
|
938
|
+
if (!response?.url) throw new Error('Google login URL was not returned.');
|
|
939
|
+
window.location.assign(response.url);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
setStatus(formatAuthError(error), true);
|
|
942
|
+
setBusy(googleLoginButton, false);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
handleGoogleCallback().catch((error) => {
|
|
947
|
+
setStatus(formatAuthError(error), true);
|
|
948
|
+
});
|
|
949
|
+
</script>
|
|
950
|
+
</body>
|
|
951
|
+
</html>`;
|
|
952
|
+
}
|
|
953
|
+
function renderPrivyOAuthCallbackPage() {
|
|
954
|
+
return `<!doctype html>
|
|
955
|
+
<html lang="en">
|
|
956
|
+
<head>
|
|
957
|
+
<meta charset="utf-8">
|
|
958
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
959
|
+
<title>Completing Ride Login</title>
|
|
960
|
+
<style>
|
|
961
|
+
:root {
|
|
962
|
+
color-scheme: light;
|
|
963
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
964
|
+
color: #1f2933;
|
|
965
|
+
background: #f6f7f9;
|
|
966
|
+
}
|
|
967
|
+
body {
|
|
968
|
+
margin: 0;
|
|
969
|
+
min-height: 100vh;
|
|
970
|
+
display: grid;
|
|
971
|
+
place-items: center;
|
|
972
|
+
padding: 24px;
|
|
973
|
+
}
|
|
974
|
+
main {
|
|
975
|
+
width: min(100%, 440px);
|
|
976
|
+
background: #ffffff;
|
|
977
|
+
border: 1px solid #dde3ea;
|
|
978
|
+
border-radius: 8px;
|
|
979
|
+
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.12);
|
|
980
|
+
padding: 28px;
|
|
981
|
+
}
|
|
982
|
+
h1 {
|
|
983
|
+
margin: 0 0 8px;
|
|
984
|
+
font-size: 22px;
|
|
985
|
+
line-height: 1.25;
|
|
986
|
+
letter-spacing: 0;
|
|
987
|
+
}
|
|
988
|
+
p {
|
|
989
|
+
margin: 0;
|
|
990
|
+
color: #52616f;
|
|
991
|
+
line-height: 1.55;
|
|
992
|
+
}
|
|
993
|
+
.error {
|
|
994
|
+
color: #b42318;
|
|
995
|
+
}
|
|
996
|
+
</style>
|
|
997
|
+
</head>
|
|
998
|
+
<body>
|
|
999
|
+
<main>
|
|
1000
|
+
<h1>Completing Ride Login</h1>
|
|
1001
|
+
<p id="status">Returning to Ride authorization...</p>
|
|
1002
|
+
</main>
|
|
1003
|
+
<script>
|
|
1004
|
+
const statusEl = document.getElementById('status');
|
|
1005
|
+
const originalAuthorizeUrl = sessionStorage.getItem('ride:mcp:oauth_authorize_url');
|
|
1006
|
+
if (!originalAuthorizeUrl) {
|
|
1007
|
+
statusEl.textContent = 'Missing original Ride authorization request. Please restart authorization from your AI client.';
|
|
1008
|
+
statusEl.classList.add('error');
|
|
1009
|
+
} else {
|
|
1010
|
+
const currentUrl = new URL(window.location.href);
|
|
1011
|
+
const target = new URL(originalAuthorizeUrl);
|
|
1012
|
+
for (const key of ['privy_oauth_code', 'privy_oauth_state', 'error', 'error_description']) {
|
|
1013
|
+
const value = currentUrl.searchParams.get(key);
|
|
1014
|
+
if (value) target.searchParams.set(key, value);
|
|
1015
|
+
}
|
|
1016
|
+
window.location.replace(target.toString());
|
|
1017
|
+
}
|
|
1018
|
+
</script>
|
|
1019
|
+
</body>
|
|
1020
|
+
</html>`;
|
|
1021
|
+
}
|
|
1022
|
+
function escapeHtml(value) {
|
|
1023
|
+
return value
|
|
1024
|
+
.replaceAll("&", "&")
|
|
1025
|
+
.replaceAll("<", "<")
|
|
1026
|
+
.replaceAll(">", ">")
|
|
1027
|
+
.replaceAll('"', """)
|
|
1028
|
+
.replaceAll("'", "'");
|
|
1029
|
+
}
|
|
1030
|
+
function escapeJs(value) {
|
|
1031
|
+
return value.replaceAll("\\", "\\\\").replaceAll("'", "\\'").replaceAll("\n", "\\n");
|
|
1032
|
+
}
|