@open-webapp/drive-sync 0.5.6 → 0.6.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/README.md +53 -0
- package/SPEC.md +23 -6
- package/dist/connection.d.ts +29 -2
- package/dist/connection.js +83 -4
- package/dist/envelope.d.ts +73 -0
- package/dist/envelope.js +231 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +8 -0
- package/dist/files.d.ts +6 -0
- package/dist/files.js +10 -0
- package/dist/gis.d.ts +26 -0
- package/dist/gis.js +118 -1
- package/dist/http.d.ts +9 -0
- package/dist/http.js +31 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +14 -5
- package/dist/permissions.d.ts +6 -0
- package/dist/permissions.js +4 -0
- package/dist/refresh.d.ts +9 -0
- package/dist/refresh.js +11 -2
- package/dist/storage.d.ts +5 -2
- package/dist/storage.js +14 -0
- package/dist/testing/gisFake.d.ts +51 -0
- package/dist/testing/gisFake.js +62 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/tokenExchangeFake.d.ts +67 -0
- package/dist/testing/tokenExchangeFake.js +253 -0
- package/dist/token.d.ts +14 -0
- package/dist/token.js +92 -16
- package/dist/types.d.ts +28 -0
- package/package.json +1 -1
package/dist/testing/gisFake.js
CHANGED
|
@@ -17,7 +17,11 @@ export function createGisFake() {
|
|
|
17
17
|
const responseQueue = [];
|
|
18
18
|
const popupErrorQueue = [];
|
|
19
19
|
const popupClosedRaceQueue = [];
|
|
20
|
+
let silenceQueue = 0;
|
|
20
21
|
const calls = [];
|
|
22
|
+
const codeResponseQueue = [];
|
|
23
|
+
const codeErrorQueue = [];
|
|
24
|
+
const codeCalls = [];
|
|
21
25
|
let previousGoogle;
|
|
22
26
|
let hadGoogle = false;
|
|
23
27
|
function nextResponse() {
|
|
@@ -27,6 +31,45 @@ export function createGisFake() {
|
|
|
27
31
|
// Default: a generic successful token response.
|
|
28
32
|
return { access_token: 'fake-access-token', expires_in: 3600, scope: '' };
|
|
29
33
|
}
|
|
34
|
+
function nextCodeResponse() {
|
|
35
|
+
const next = codeResponseQueue.shift();
|
|
36
|
+
if (next)
|
|
37
|
+
return next;
|
|
38
|
+
// Default: a generic successful auth-code response.
|
|
39
|
+
return { code: 'fake-auth-code' };
|
|
40
|
+
}
|
|
41
|
+
function initCodeClient(config) {
|
|
42
|
+
return {
|
|
43
|
+
requestCode() {
|
|
44
|
+
const hint = config.hint;
|
|
45
|
+
const scope = config.scope ?? '';
|
|
46
|
+
codeCalls.push({ scope, hint });
|
|
47
|
+
if (silenceQueue > 0) {
|
|
48
|
+
silenceQueue -= 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const codeError = codeErrorQueue.shift();
|
|
52
|
+
if (codeError) {
|
|
53
|
+
const errorCallback = config.error_callback;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
errorCallback?.({ type: codeError });
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const response = nextCodeResponse();
|
|
60
|
+
const callback = config.callback;
|
|
61
|
+
// Deliver asynchronously (microtask), matching the real GIS code
|
|
62
|
+
// client's callback-based, non-synchronous delivery.
|
|
63
|
+
queueMicrotask(() => {
|
|
64
|
+
if (response.error) {
|
|
65
|
+
callback?.({ error: response.error });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
callback?.(response);
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
30
73
|
function initTokenClient(config) {
|
|
31
74
|
return {
|
|
32
75
|
requestAccessToken(overrideConfig) {
|
|
@@ -34,6 +77,10 @@ export function createGisFake() {
|
|
|
34
77
|
const hint = overrideConfig?.hint ?? config.hint;
|
|
35
78
|
const scope = overrideConfig?.scope ?? config.scope ?? '';
|
|
36
79
|
calls.push({ prompt, hint, scope });
|
|
80
|
+
if (silenceQueue > 0) {
|
|
81
|
+
silenceQueue -= 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
37
84
|
const popupClosedRace = popupClosedRaceQueue.shift();
|
|
38
85
|
if (popupClosedRace) {
|
|
39
86
|
const errorCallback = config.error_callback;
|
|
@@ -73,15 +120,25 @@ export function createGisFake() {
|
|
|
73
120
|
}
|
|
74
121
|
return {
|
|
75
122
|
calls,
|
|
123
|
+
codeCalls,
|
|
76
124
|
queueResponse(response) {
|
|
77
125
|
responseQueue.push(response);
|
|
78
126
|
},
|
|
127
|
+
queueCodeResponse(response) {
|
|
128
|
+
codeResponseQueue.push(response);
|
|
129
|
+
},
|
|
130
|
+
queueCodeError(type) {
|
|
131
|
+
codeErrorQueue.push(type);
|
|
132
|
+
},
|
|
79
133
|
queuePopupError(type) {
|
|
80
134
|
popupErrorQueue.push(type);
|
|
81
135
|
},
|
|
82
136
|
queuePopupClosedRace(response, delayMs) {
|
|
83
137
|
popupClosedRaceQueue.push({ response, delayMs });
|
|
84
138
|
},
|
|
139
|
+
queueSilence() {
|
|
140
|
+
silenceQueue += 1;
|
|
141
|
+
},
|
|
85
142
|
install() {
|
|
86
143
|
const w = globalThis;
|
|
87
144
|
hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
|
|
@@ -96,6 +153,7 @@ export function createGisFake() {
|
|
|
96
153
|
w.google.accounts.oauth2 = {};
|
|
97
154
|
}
|
|
98
155
|
w.google.accounts.oauth2.initTokenClient = initTokenClient;
|
|
156
|
+
w.google.accounts.oauth2.initCodeClient = initCodeClient;
|
|
99
157
|
},
|
|
100
158
|
uninstall() {
|
|
101
159
|
const w = globalThis;
|
|
@@ -110,7 +168,11 @@ export function createGisFake() {
|
|
|
110
168
|
responseQueue.length = 0;
|
|
111
169
|
popupErrorQueue.length = 0;
|
|
112
170
|
popupClosedRaceQueue.length = 0;
|
|
171
|
+
silenceQueue = 0;
|
|
113
172
|
calls.length = 0;
|
|
173
|
+
codeResponseQueue.length = 0;
|
|
174
|
+
codeErrorQueue.length = 0;
|
|
175
|
+
codeCalls.length = 0;
|
|
114
176
|
},
|
|
115
177
|
};
|
|
116
178
|
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { createGisFake } from './gisFake.js';
|
|
2
|
-
export type { GisFake, GisTokenResponse, GisRecordedCall } from './gisFake.js';
|
|
2
|
+
export type { GisFake, GisTokenResponse, GisRecordedCall, GisCodeResponse, GisCodeRecordedCall, GisCodeClientConfig, GisCodeClient, } from './gisFake.js';
|
|
3
3
|
export { createDriveFake } from './driveFake.js';
|
|
4
4
|
export type { DriveFake, DriveFakeFile, DriveFakePermission, StatusOverrideOptions } from './driveFake.js';
|
|
5
5
|
export { createPickerFake } from './pickerFake.js';
|
|
6
6
|
export type { PickerFake, PickerFakeFile, PickerRecordedCall } from './pickerFake.js';
|
|
7
|
+
export { createTokenExchangeFake } from './tokenExchangeFake.js';
|
|
8
|
+
export type { TokenExchangeFake, TokenExchangeRecordedCall, CreateTokenExchangeFakeOptions, } from './tokenExchangeFake.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory fake of the server-mediated token-exchange endpoint that
|
|
3
|
+
* drive-sync talks to when `DriveSyncOptions.tokenExchangeUrl` is set.
|
|
4
|
+
*
|
|
5
|
+
* The real server accepts `POST {tokenExchangeUrl}` with a body that is
|
|
6
|
+
* EXACTLY one of `{ code }` (new grant) or `{ envelope }` (refresh) and
|
|
7
|
+
* responds `200 { envelope }`. On a refresh it echoes the envelope
|
|
8
|
+
* unchanged while it is still fresh (`Date.now() < expiry_date - 60000`),
|
|
9
|
+
* otherwise it mints a new `payload` + `sig` under the same `guid`.
|
|
10
|
+
*
|
|
11
|
+
* Intended use:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const tokenExchange = createTokenExchangeFake({ now: () => clock })
|
|
15
|
+
* tokenExchange.install()
|
|
16
|
+
* // ... exercise code under test ...
|
|
17
|
+
* expect(tokenExchange.calls[0].kind).toBe('code')
|
|
18
|
+
* tokenExchange.uninstall()
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Only requests whose URL matches the configured endpoint are intercepted;
|
|
22
|
+
* everything else falls through to whatever `globalThis.fetch` was before
|
|
23
|
+
* `install()`.
|
|
24
|
+
*/
|
|
25
|
+
import type { Envelope } from '../types.js';
|
|
26
|
+
/** One recorded request against the fake endpoint. */
|
|
27
|
+
export interface TokenExchangeRecordedCall {
|
|
28
|
+
/** `'code'` for a new-grant body, `'envelope'` for a refresh body. */
|
|
29
|
+
kind: 'code' | 'envelope';
|
|
30
|
+
/** The parsed JSON request body, exactly as received. */
|
|
31
|
+
body: unknown;
|
|
32
|
+
}
|
|
33
|
+
export interface TokenExchangeFake {
|
|
34
|
+
/** Swap `globalThis.fetch` for the intercepting handler. Idempotent. */
|
|
35
|
+
install(): void;
|
|
36
|
+
/** Restore the exact `globalThis.fetch` that was present at `install()`. Idempotent. */
|
|
37
|
+
uninstall(): void;
|
|
38
|
+
/** Every intercepted request, in call order. */
|
|
39
|
+
readonly calls: TokenExchangeRecordedCall[];
|
|
40
|
+
/** The most recently minted / echoed envelope, or `null` before the first call. */
|
|
41
|
+
readonly lastEnvelope: Envelope | null;
|
|
42
|
+
/** Make the next `times` (default 1) calls fail `410 refresh_token_revoked`. */
|
|
43
|
+
fail410(times?: number): void;
|
|
44
|
+
/** Make the next `times` (default 1) calls fail `502 google_unavailable`. */
|
|
45
|
+
fail502(times?: number): void;
|
|
46
|
+
/** Make the next `times` (default 1) calls fail `400 malformed_request`. */
|
|
47
|
+
failMalformed(times?: number): void;
|
|
48
|
+
/** Make the next `times` (default 1) calls fail `401 invalid_envelope_signature`. */
|
|
49
|
+
failInvalidSig(times?: number): void;
|
|
50
|
+
/** Make the next `times` (default 1) calls fail `404 unknown_guid`. */
|
|
51
|
+
failUnknownGuid(times?: number): void;
|
|
52
|
+
/**
|
|
53
|
+
* Re-stamp `lastEnvelope`'s expiry to `now() + msFromNow` (and re-sign it),
|
|
54
|
+
* so a subsequent replay of that envelope is treated as stale. Throws if no
|
|
55
|
+
* envelope has been minted yet.
|
|
56
|
+
*/
|
|
57
|
+
setExpiry(msFromNow: number): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export interface CreateTokenExchangeFakeOptions {
|
|
60
|
+
/** HMAC secret used to sign minted envelopes. Defaults to a fixed test value. */
|
|
61
|
+
secret?: string;
|
|
62
|
+
/** Injectable clock; defaults to `Date.now`. */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
/** Which crypto backend the last signing operation used. Exposed for diagnostics. */
|
|
66
|
+
export declare let lastCryptoBackend: 'crypto.subtle' | 'node:crypto' | null;
|
|
67
|
+
export declare function createTokenExchangeFake(opts?: CreateTokenExchangeFakeOptions): TokenExchangeFake;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory fake of the server-mediated token-exchange endpoint that
|
|
3
|
+
* drive-sync talks to when `DriveSyncOptions.tokenExchangeUrl` is set.
|
|
4
|
+
*
|
|
5
|
+
* The real server accepts `POST {tokenExchangeUrl}` with a body that is
|
|
6
|
+
* EXACTLY one of `{ code }` (new grant) or `{ envelope }` (refresh) and
|
|
7
|
+
* responds `200 { envelope }`. On a refresh it echoes the envelope
|
|
8
|
+
* unchanged while it is still fresh (`Date.now() < expiry_date - 60000`),
|
|
9
|
+
* otherwise it mints a new `payload` + `sig` under the same `guid`.
|
|
10
|
+
*
|
|
11
|
+
* Intended use:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const tokenExchange = createTokenExchangeFake({ now: () => clock })
|
|
15
|
+
* tokenExchange.install()
|
|
16
|
+
* // ... exercise code under test ...
|
|
17
|
+
* expect(tokenExchange.calls[0].kind).toBe('code')
|
|
18
|
+
* tokenExchange.uninstall()
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Only requests whose URL matches the configured endpoint are intercepted;
|
|
22
|
+
* everything else falls through to whatever `globalThis.fetch` was before
|
|
23
|
+
* `install()`.
|
|
24
|
+
*/
|
|
25
|
+
/** Default endpoint drive-sync ships with. */
|
|
26
|
+
const DEFAULT_TOKEN_EXCHANGE_URL = 'https://open-webapp.duckdns.org/callback';
|
|
27
|
+
/** Scope minted into every fake payload. */
|
|
28
|
+
const FAKE_SCOPE = 'https://www.googleapis.com/auth/drive.file';
|
|
29
|
+
/** Lifetime, in ms, of a freshly minted payload. */
|
|
30
|
+
const TOKEN_LIFETIME_MS = 3_600_000;
|
|
31
|
+
/** Server-side freshness skew: an envelope inside this window is echoed as-is. */
|
|
32
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
33
|
+
/** Canonical JSON: object keys sorted recursively, no insignificant whitespace. */
|
|
34
|
+
function canonicalJson(value) {
|
|
35
|
+
if (value === null || typeof value !== 'object')
|
|
36
|
+
return JSON.stringify(value);
|
|
37
|
+
if (Array.isArray(value))
|
|
38
|
+
return '[' + value.map(canonicalJson).join(',') + ']';
|
|
39
|
+
const entries = Object.keys(value)
|
|
40
|
+
.sort()
|
|
41
|
+
.map((key) => JSON.stringify(key) + ':' + canonicalJson(value[key]));
|
|
42
|
+
return '{' + entries.join(',') + '}';
|
|
43
|
+
}
|
|
44
|
+
function base64UrlFromBytes(bytes) {
|
|
45
|
+
let binary = '';
|
|
46
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
47
|
+
binary += String.fromCharCode(bytes[i]);
|
|
48
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
49
|
+
}
|
|
50
|
+
/** Which crypto backend the last signing operation used. Exposed for diagnostics. */
|
|
51
|
+
export let lastCryptoBackend = null;
|
|
52
|
+
async function hmacSha256Base64Url(secret, message) {
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
const subtle = globalThis.crypto?.subtle;
|
|
55
|
+
if (subtle) {
|
|
56
|
+
lastCryptoBackend = 'crypto.subtle';
|
|
57
|
+
const key = await subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
58
|
+
'sign',
|
|
59
|
+
]);
|
|
60
|
+
const mac = await subtle.sign('HMAC', key, encoder.encode(message));
|
|
61
|
+
return base64UrlFromBytes(new Uint8Array(mac));
|
|
62
|
+
}
|
|
63
|
+
// Fall back to Node's crypto when Web Crypto is unavailable in the test env.
|
|
64
|
+
// Computed specifier keeps TS from trying to resolve `node:crypto` types.
|
|
65
|
+
lastCryptoBackend = 'node:crypto';
|
|
66
|
+
const specifier = 'node:' + 'crypto';
|
|
67
|
+
const nodeCrypto = await import(/* @vite-ignore */ specifier);
|
|
68
|
+
return nodeCrypto.createHmac('sha256', secret).update(message).digest('base64url');
|
|
69
|
+
}
|
|
70
|
+
function randomUuid() {
|
|
71
|
+
const webCrypto = globalThis.crypto;
|
|
72
|
+
if (webCrypto?.randomUUID)
|
|
73
|
+
return webCrypto.randomUUID();
|
|
74
|
+
// Extremely defensive fallback; the test env always has crypto.randomUUID.
|
|
75
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
76
|
+
const r = (Math.random() * 16) | 0;
|
|
77
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
78
|
+
return v.toString(16);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function randomAccessToken() {
|
|
82
|
+
const webCrypto = globalThis.crypto;
|
|
83
|
+
const bytes = new Uint8Array(24);
|
|
84
|
+
if (webCrypto?.getRandomValues)
|
|
85
|
+
webCrypto.getRandomValues(bytes);
|
|
86
|
+
else
|
|
87
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
88
|
+
bytes[i] = (Math.random() * 256) | 0;
|
|
89
|
+
let hex = '';
|
|
90
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
91
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
92
|
+
return 'ya29.' + hex;
|
|
93
|
+
}
|
|
94
|
+
function jsonResponse(status, body) {
|
|
95
|
+
return new Response(JSON.stringify(body), {
|
|
96
|
+
status,
|
|
97
|
+
headers: { 'content-type': 'application/json' },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function errorResponse(status, code, message) {
|
|
101
|
+
return jsonResponse(status, { error: { code, message } });
|
|
102
|
+
}
|
|
103
|
+
const OVERRIDE_RESPONSES = {
|
|
104
|
+
'410': () => errorResponse(410, 'refresh_token_revoked', 'The refresh token has been revoked'),
|
|
105
|
+
'502': () => errorResponse(502, 'google_unavailable', 'Upstream Google token endpoint is unavailable'),
|
|
106
|
+
malformed: () => errorResponse(400, 'malformed_request', 'The request body was malformed'),
|
|
107
|
+
invalidSig: () => errorResponse(401, 'invalid_envelope_signature', 'The envelope signature did not verify'),
|
|
108
|
+
unknownGuid: () => errorResponse(404, 'unknown_guid', 'No grant exists for that guid'),
|
|
109
|
+
};
|
|
110
|
+
function urlOf(input) {
|
|
111
|
+
if (typeof input === 'string')
|
|
112
|
+
return input;
|
|
113
|
+
if (input instanceof URL)
|
|
114
|
+
return input.href;
|
|
115
|
+
if (typeof Request !== 'undefined' && input instanceof Request)
|
|
116
|
+
return input.url;
|
|
117
|
+
return String(input?.url ?? input);
|
|
118
|
+
}
|
|
119
|
+
async function bodyTextOf(input, init) {
|
|
120
|
+
if (init && typeof init.body === 'string')
|
|
121
|
+
return init.body;
|
|
122
|
+
if (init && init.body != null)
|
|
123
|
+
return String(init.body);
|
|
124
|
+
if (typeof Request !== 'undefined' && input instanceof Request)
|
|
125
|
+
return input.clone().text();
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
export function createTokenExchangeFake(opts = {}) {
|
|
129
|
+
const secret = opts.secret ?? 'token-exchange-fake-secret';
|
|
130
|
+
const now = opts.now ?? (() => Date.now());
|
|
131
|
+
const calls = [];
|
|
132
|
+
const overrideQueue = [];
|
|
133
|
+
let lastEnvelope = null;
|
|
134
|
+
let installed = false;
|
|
135
|
+
let previousFetch = globalThis.fetch;
|
|
136
|
+
function matchesEndpoint(rawUrl) {
|
|
137
|
+
if (rawUrl === DEFAULT_TOKEN_EXCHANGE_URL)
|
|
138
|
+
return true;
|
|
139
|
+
try {
|
|
140
|
+
return new URL(rawUrl).pathname === '/callback';
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return rawUrl.endsWith('/callback');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function mintEnvelope(guid) {
|
|
147
|
+
const payload = {
|
|
148
|
+
access_token: randomAccessToken(),
|
|
149
|
+
expiry_date: now() + TOKEN_LIFETIME_MS,
|
|
150
|
+
token_type: 'Bearer',
|
|
151
|
+
scope: FAKE_SCOPE,
|
|
152
|
+
};
|
|
153
|
+
const sig = await hmacSha256Base64Url(secret, canonicalJson({ v: 2, guid, payload }));
|
|
154
|
+
return { v: 2, guid, payload, sig };
|
|
155
|
+
}
|
|
156
|
+
function enqueue(kind, times) {
|
|
157
|
+
const n = Math.max(0, Math.floor(times));
|
|
158
|
+
for (let i = 0; i < n; i += 1)
|
|
159
|
+
overrideQueue.push(kind);
|
|
160
|
+
}
|
|
161
|
+
const handler = (async (input, init) => {
|
|
162
|
+
const rawUrl = urlOf(input);
|
|
163
|
+
if (!matchesEndpoint(rawUrl))
|
|
164
|
+
return previousFetch(input, init);
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
const text = await bodyTextOf(input, init);
|
|
168
|
+
parsed = text ? JSON.parse(text) : {};
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return errorResponse(400, 'malformed_request', 'The request body was not valid JSON');
|
|
172
|
+
}
|
|
173
|
+
const record = parsed !== null && typeof parsed === 'object' ? parsed : {};
|
|
174
|
+
const hasCode = record.code != null;
|
|
175
|
+
const hasEnvelope = record.envelope != null;
|
|
176
|
+
const kind = hasEnvelope && !hasCode ? 'envelope' : 'code';
|
|
177
|
+
calls.push({ kind, body: parsed });
|
|
178
|
+
const override = overrideQueue.shift();
|
|
179
|
+
if (override)
|
|
180
|
+
return OVERRIDE_RESPONSES[override]();
|
|
181
|
+
if (hasCode && hasEnvelope) {
|
|
182
|
+
return errorResponse(400, 'code_and_envelope_exclusive', 'Provide exactly one of `code` or `envelope`');
|
|
183
|
+
}
|
|
184
|
+
if (!hasCode && !hasEnvelope) {
|
|
185
|
+
return errorResponse(400, 'code_or_envelope_required', 'Provide exactly one of `code` or `envelope`');
|
|
186
|
+
}
|
|
187
|
+
if (hasCode) {
|
|
188
|
+
const minted = await mintEnvelope(randomUuid());
|
|
189
|
+
lastEnvelope = minted;
|
|
190
|
+
return jsonResponse(200, { envelope: minted });
|
|
191
|
+
}
|
|
192
|
+
const incoming = record.envelope;
|
|
193
|
+
if (incoming === null ||
|
|
194
|
+
typeof incoming !== 'object' ||
|
|
195
|
+
typeof incoming.payload !== 'object' ||
|
|
196
|
+
incoming.payload === null ||
|
|
197
|
+
typeof incoming.payload.expiry_date !== 'number' ||
|
|
198
|
+
typeof incoming.guid !== 'string') {
|
|
199
|
+
return errorResponse(400, 'malformed_request', 'The `envelope` was not a well-formed envelope');
|
|
200
|
+
}
|
|
201
|
+
if (now() < incoming.payload.expiry_date - REFRESH_SKEW_MS) {
|
|
202
|
+
lastEnvelope = incoming;
|
|
203
|
+
return jsonResponse(200, { envelope: incoming });
|
|
204
|
+
}
|
|
205
|
+
const refreshed = await mintEnvelope(incoming.guid);
|
|
206
|
+
lastEnvelope = refreshed;
|
|
207
|
+
return jsonResponse(200, { envelope: refreshed });
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
install() {
|
|
211
|
+
if (installed)
|
|
212
|
+
return;
|
|
213
|
+
previousFetch = globalThis.fetch;
|
|
214
|
+
globalThis.fetch = handler;
|
|
215
|
+
installed = true;
|
|
216
|
+
},
|
|
217
|
+
uninstall() {
|
|
218
|
+
if (!installed)
|
|
219
|
+
return;
|
|
220
|
+
globalThis.fetch = previousFetch;
|
|
221
|
+
installed = false;
|
|
222
|
+
},
|
|
223
|
+
get calls() {
|
|
224
|
+
return calls;
|
|
225
|
+
},
|
|
226
|
+
get lastEnvelope() {
|
|
227
|
+
return lastEnvelope;
|
|
228
|
+
},
|
|
229
|
+
fail410(times = 1) {
|
|
230
|
+
enqueue('410', times);
|
|
231
|
+
},
|
|
232
|
+
fail502(times = 1) {
|
|
233
|
+
enqueue('502', times);
|
|
234
|
+
},
|
|
235
|
+
failMalformed(times = 1) {
|
|
236
|
+
enqueue('malformed', times);
|
|
237
|
+
},
|
|
238
|
+
failInvalidSig(times = 1) {
|
|
239
|
+
enqueue('invalidSig', times);
|
|
240
|
+
},
|
|
241
|
+
failUnknownGuid(times = 1) {
|
|
242
|
+
enqueue('unknownGuid', times);
|
|
243
|
+
},
|
|
244
|
+
async setExpiry(msFromNow) {
|
|
245
|
+
if (!lastEnvelope) {
|
|
246
|
+
throw new Error('createTokenExchangeFake: setExpiry() called before any envelope was minted');
|
|
247
|
+
}
|
|
248
|
+
const payload = { ...lastEnvelope.payload, expiry_date: now() + msFromNow };
|
|
249
|
+
const sig = await hmacSha256Base64Url(secret, canonicalJson({ v: 2, guid: lastEnvelope.guid, payload }));
|
|
250
|
+
lastEnvelope = { v: 2, guid: lastEnvelope.guid, payload, sig };
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
package/dist/token.d.ts
CHANGED
|
@@ -31,6 +31,20 @@ export interface AcquireTokenOptions {
|
|
|
31
31
|
* turns out to already be usable.
|
|
32
32
|
*/
|
|
33
33
|
export declare function notifyExternalTokenRefresh(projectId: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Records that another tab just persisted a fresh envelope for `projectId`.
|
|
36
|
+
* The next {@link import('./envelope.js').refreshEnvelope} call for this
|
|
37
|
+
* project drains the signal and re-reads the stored envelope before deciding
|
|
38
|
+
* whether a network round-trip is needed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function notifyExternalEnvelopeRefresh(projectId: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Consume (one-shot) a pending cross-tab envelope-refresh signal for
|
|
43
|
+
* `projectId`. Returns `true` when a signal was pending (and clears it),
|
|
44
|
+
* `false` otherwise. Mirrors the inline `externallyRefreshed.delete(...)`
|
|
45
|
+
* check the legacy `acquireToken` path performs.
|
|
46
|
+
*/
|
|
47
|
+
export declare function consumeExternalEnvelopeRefresh(projectId: string): boolean;
|
|
34
48
|
/**
|
|
35
49
|
* Single entry point for acquiring a Drive access token, used by BOTH the
|
|
36
50
|
* interactive "connect" path (interactive: true -> prompt: '', i.e. no
|
package/dist/token.js
CHANGED
|
@@ -24,6 +24,26 @@ const POPUP_CLOSED_GRACE_MS = 2000;
|
|
|
24
24
|
*/
|
|
25
25
|
const PROBE_ATTEMPTS = 3;
|
|
26
26
|
const PROBE_RETRY_DELAY_MS = 350;
|
|
27
|
+
/**
|
|
28
|
+
* Hard ceiling on a single GIS token request.
|
|
29
|
+
*
|
|
30
|
+
* GIS settles a request ONLY by invoking `callback` or `error_callback`, and
|
|
31
|
+
* in the field it sometimes does neither: a completed flow whose result is
|
|
32
|
+
* never posted back to this page (most reliably a silent `prompt: 'none'`
|
|
33
|
+
* request in a browser that blocks silent token issuance) leaves both
|
|
34
|
+
* callbacks unfired. Without a ceiling that request stays pending forever —
|
|
35
|
+
* `connect()` never settles, the host app is stuck mid-connect with no error
|
|
36
|
+
* to show, and the in-flight entry in `inFlight` is never released, so every
|
|
37
|
+
* later retry joins the same dead promise and no popup ever opens again.
|
|
38
|
+
*
|
|
39
|
+
* Interactive requests get a generous ceiling because the user is legitimately
|
|
40
|
+
* typing a password inside the popup; silent requests have no UI and must
|
|
41
|
+
* either answer quickly or be treated as failed.
|
|
42
|
+
*/
|
|
43
|
+
const INTERACTIVE_REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
44
|
+
const SILENT_REQUEST_TIMEOUT_MS = 10_000;
|
|
45
|
+
/** Probes run up to PROBE_ATTEMPTS times, so each one has to fail fast. */
|
|
46
|
+
const PROBE_REQUEST_TIMEOUT_MS = 4_000;
|
|
27
47
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
48
|
/**
|
|
29
49
|
* Persists a freshly-acquired GIS token response as a StoredToken, deriving
|
|
@@ -41,12 +61,19 @@ export async function persistTokenResponse(appId, projectId, response) {
|
|
|
41
61
|
return token;
|
|
42
62
|
}
|
|
43
63
|
/**
|
|
44
|
-
* Key used for in-flight coalescing: per (projectId, sorted-scope-set
|
|
45
|
-
* global. This is what keeps concurrent calls for different
|
|
46
|
-
* different scope requirements within the same project) from
|
|
64
|
+
* Key used for in-flight coalescing: per (projectId, sorted-scope-set,
|
|
65
|
+
* interactive), NOT global. This is what keeps concurrent calls for different
|
|
66
|
+
* projects (or different scope requirements within the same project) from
|
|
67
|
+
* colliding.
|
|
68
|
+
*
|
|
69
|
+
* `interactive` is part of the key because the two modes are not
|
|
70
|
+
* interchangeable: a user-initiated `connect()` must run its own OAuth flow,
|
|
71
|
+
* and must never be handed the outcome of a silent background refresh that
|
|
72
|
+
* happens to be in flight — that resolves (or rejects) the user's click with
|
|
73
|
+
* no flow shown at all, which is indistinguishable from a dead button.
|
|
47
74
|
*/
|
|
48
|
-
function coalesceKey(projectId, scopes) {
|
|
49
|
-
return `${projectId}|${scopes.slice().sort().join(' ')}`;
|
|
75
|
+
function coalesceKey(projectId, scopes, interactive) {
|
|
76
|
+
return `${projectId}|${interactive ? 'i' : 's'}|${scopes.slice().sort().join(' ')}`;
|
|
50
77
|
}
|
|
51
78
|
const inFlight = new Map();
|
|
52
79
|
/**
|
|
@@ -69,6 +96,34 @@ const externallyRefreshed = new Set();
|
|
|
69
96
|
export function notifyExternalTokenRefresh(projectId) {
|
|
70
97
|
externallyRefreshed.add(projectId);
|
|
71
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Parallel-set mirror of {@link externallyRefreshed} for the server-mediated
|
|
101
|
+
* token-exchange path (envelope.ts `refreshEnvelope`). Kept deliberately
|
|
102
|
+
* separate from the legacy set so the two flows never consume each other's
|
|
103
|
+
* cross-tab signals: a `token` broadcast that originated from an envelope
|
|
104
|
+
* refresh drains this set, and one from a legacy GIS acquisition drains the
|
|
105
|
+
* other. Populated by {@link notifyExternalEnvelopeRefresh}, drained by
|
|
106
|
+
* {@link consumeExternalEnvelopeRefresh}.
|
|
107
|
+
*/
|
|
108
|
+
const externallyRefreshedEnvelope = new Set();
|
|
109
|
+
/**
|
|
110
|
+
* Records that another tab just persisted a fresh envelope for `projectId`.
|
|
111
|
+
* The next {@link import('./envelope.js').refreshEnvelope} call for this
|
|
112
|
+
* project drains the signal and re-reads the stored envelope before deciding
|
|
113
|
+
* whether a network round-trip is needed.
|
|
114
|
+
*/
|
|
115
|
+
export function notifyExternalEnvelopeRefresh(projectId) {
|
|
116
|
+
externallyRefreshedEnvelope.add(projectId);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Consume (one-shot) a pending cross-tab envelope-refresh signal for
|
|
120
|
+
* `projectId`. Returns `true` when a signal was pending (and clears it),
|
|
121
|
+
* `false` otherwise. Mirrors the inline `externallyRefreshed.delete(...)`
|
|
122
|
+
* check the legacy `acquireToken` path performs.
|
|
123
|
+
*/
|
|
124
|
+
export function consumeExternalEnvelopeRefresh(projectId) {
|
|
125
|
+
return externallyRefreshedEnvelope.delete(projectId);
|
|
126
|
+
}
|
|
72
127
|
/**
|
|
73
128
|
* Single entry point for acquiring a Drive access token, used by BOTH the
|
|
74
129
|
* interactive "connect" path (interactive: true -> prompt: '', i.e. no
|
|
@@ -101,7 +156,7 @@ export async function acquireToken(opts) {
|
|
|
101
156
|
return stored;
|
|
102
157
|
}
|
|
103
158
|
}
|
|
104
|
-
const key = coalesceKey(opts.projectId, opts.scopes);
|
|
159
|
+
const key = coalesceKey(opts.projectId, opts.scopes, opts.interactive);
|
|
105
160
|
const existing = inFlight.get(key);
|
|
106
161
|
if (existing) {
|
|
107
162
|
return existing;
|
|
@@ -140,7 +195,7 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
|
|
|
140
195
|
try {
|
|
141
196
|
// No grace window: `prompt: 'none'` never opens a popup, so there is no
|
|
142
197
|
// popup-closed poll to race and nothing to wait out on failure.
|
|
143
|
-
const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0);
|
|
198
|
+
const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0, PROBE_REQUEST_TIMEOUT_MS);
|
|
144
199
|
opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
|
|
145
200
|
projectId: opts.projectId,
|
|
146
201
|
attempt,
|
|
@@ -167,9 +222,33 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
|
|
|
167
222
|
* captured in THIS call's closure only — never on a module-level variable — so
|
|
168
223
|
* a second concurrent call cannot clobber the first caller's promise.
|
|
169
224
|
*/
|
|
170
|
-
function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS) {
|
|
225
|
+
function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS, timeoutMs = INTERACTIVE_REQUEST_TIMEOUT_MS) {
|
|
171
226
|
return new Promise((resolve, reject) => {
|
|
172
227
|
let settled = false;
|
|
228
|
+
// Neither GIS callback is guaranteed to fire; see the timeout constants.
|
|
229
|
+
const timeout = setTimeout(() => {
|
|
230
|
+
if (settled)
|
|
231
|
+
return;
|
|
232
|
+
settled = true;
|
|
233
|
+
opts.logger?.warn('drive-sync: GIS never returned a result; timing out the request', {
|
|
234
|
+
projectId: opts.projectId,
|
|
235
|
+
prompt: override.prompt,
|
|
236
|
+
timeoutMs,
|
|
237
|
+
});
|
|
238
|
+
reject(new NeedsReauthError('Google sign-in did not return a result', {
|
|
239
|
+
reason: 'gis_timeout',
|
|
240
|
+
}));
|
|
241
|
+
}, timeoutMs);
|
|
242
|
+
const succeed = (res) => {
|
|
243
|
+
settled = true;
|
|
244
|
+
clearTimeout(timeout);
|
|
245
|
+
resolve(res);
|
|
246
|
+
};
|
|
247
|
+
const fail = (err) => {
|
|
248
|
+
settled = true;
|
|
249
|
+
clearTimeout(timeout);
|
|
250
|
+
reject(err);
|
|
251
|
+
};
|
|
173
252
|
const client = initTokenClient({
|
|
174
253
|
client_id: opts.clientId,
|
|
175
254
|
scope: opts.scopes.join(' '),
|
|
@@ -185,12 +264,11 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
|
|
|
185
264
|
});
|
|
186
265
|
return;
|
|
187
266
|
}
|
|
188
|
-
settled = true;
|
|
189
267
|
if (res.error) {
|
|
190
|
-
|
|
268
|
+
fail(new Error(`GIS token request failed: ${res.error}`));
|
|
191
269
|
return;
|
|
192
270
|
}
|
|
193
|
-
|
|
271
|
+
succeed(res);
|
|
194
272
|
},
|
|
195
273
|
// Without this, a popup that the browser blocks or the user closes
|
|
196
274
|
// settles NOTHING: GIS reports those through error_callback only, so
|
|
@@ -216,15 +294,13 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
|
|
|
216
294
|
setTimeout(() => {
|
|
217
295
|
if (settled)
|
|
218
296
|
return;
|
|
219
|
-
|
|
220
|
-
reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
|
|
297
|
+
fail(new NeedsReauthError('Google sign-in popup was closed before completing', {
|
|
221
298
|
reason: 'popup_closed',
|
|
222
299
|
}));
|
|
223
300
|
}, popupClosedGraceMs);
|
|
224
301
|
return;
|
|
225
302
|
}
|
|
226
|
-
|
|
227
|
-
reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
|
|
303
|
+
fail(new NeedsReauthError(err?.type === 'popup_failed_to_open'
|
|
228
304
|
? 'Google sign-in popup was blocked by the browser'
|
|
229
305
|
: `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
|
|
230
306
|
},
|
|
@@ -262,7 +338,7 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
262
338
|
// the chooser too.
|
|
263
339
|
prompt: opts.interactive ? '' : 'none',
|
|
264
340
|
hint: opts.hint,
|
|
265
|
-
});
|
|
341
|
+
}, POPUP_CLOSED_GRACE_MS, opts.interactive ? INTERACTIVE_REQUEST_TIMEOUT_MS : SILENT_REQUEST_TIMEOUT_MS);
|
|
266
342
|
}
|
|
267
343
|
catch (err) {
|
|
268
344
|
if (!opts.interactive) {
|