@open-webapp/drive-sync 0.5.1 → 0.5.3
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/SPEC.md +5 -0
- package/dist/token.js +95 -19
- package/package.json +1 -1
package/SPEC.md
CHANGED
|
@@ -145,6 +145,11 @@ Token acquisition always funnels through `token.ts`'s `acquireToken`, which is c
|
|
|
145
145
|
Concretely, by module:
|
|
146
146
|
|
|
147
147
|
- **`token.ts`** is the only place that talks to GIS's `initTokenClient`. It does not know about "wrong account" — it just returns whatever token GIS hands back for the requested `(scopes, prompt, hint)`.
|
|
148
|
+
- **`token.ts`'s `popup_closed` handling** is deliberately two-stage, because GIS reports `popup_closed` for *completed* sign-ins as well as cancelled ones:
|
|
149
|
+
1. **Grace window** (`POPUP_CLOSED_GRACE_MS`, 2000ms) — wait for a success `callback` that is merely late to still win.
|
|
150
|
+
2. **Completed-grant probe** (interactive path only) — if no token arrived, issue one `prompt:'none'` request before giving up. A completed consent leaves a live grant at Google, so this resolves with no popup; a cancelled sign-in leaves none, and the original `NeedsReauthError{reason:'popup_closed'}` is rethrown unchanged (never the probe's own error).
|
|
151
|
+
|
|
152
|
+
Stage 2 exists because the success `callback` sometimes never arrives *at all* for a completed sign-in, which no grace window can fix. The silent path skips the probe — it is already a `prompt:'none'` request.
|
|
148
153
|
- **`connection.ts`**'s `refreshSilently` is the *only* place that adds wrong-account verification: after `acquireToken({interactive:false, hint:expectedEmail})` resolves, it calls the injected `fetchEmail(token.accessToken)` and compares the result against `expectedEmail`. Mismatch → `clearToken()` then throw `WrongAccountError`; match → return the token.
|
|
149
154
|
- **`http.ts`**'s `driveFetch`/`performFetch` is the 401 path: on a first 401 (not already a retry, not an interactive call), it clears the token and, if a `fetchEmail` was supplied and a connection's email is known, calls `refreshSilently`; otherwise falls back to a bare `acquireToken`. It retries the original request exactly once (`isRetryAfter401` flag) with whatever token comes back. A second 401, or any 401 on an interactive call, throws `NeedsReauthError` without retrying again. A `WrongAccountError` from `refreshSilently` is re-thrown as-is rather than being swallowed into `NeedsReauthError`.
|
|
150
155
|
- **`refresh.ts`**'s `warmUpIfNeeded` is the proactive path: fired from `visibilitychange`→`visible` and `pageshow`(persisted, not hidden) listeners attached by `activate()`. It only acts if a `conn` record exists **and** the cached token is missing or within `REFRESH_BUFFER_MS` (5 minutes) of `expiresAt`. When a `fetchEmail` is configured it goes through `refreshSilently` (so wrong-account detection also covers this path); otherwise it falls back to a bare `acquireToken`. It never *starts* a new attempt while the document is hidden — visibility is checked before it is ever called, so an attempt already in flight from before the tab hid is left to finish on its own.
|
package/dist/token.js
CHANGED
|
@@ -5,8 +5,15 @@ import { createBroadcast } from './broadcast.js';
|
|
|
5
5
|
/**
|
|
6
6
|
* How long to wait, after GIS reports `popup_closed`, for the success
|
|
7
7
|
* `callback` to still win the race before treating it as a real failure.
|
|
8
|
+
* 300ms proved too tight in production: on a real network round-trip the
|
|
9
|
+
* success token can arrive well after GIS's popup-closed poll fires,
|
|
10
|
+
* causing genuinely successful sign-ins to be reported as NeedsReauthError.
|
|
11
|
+
*
|
|
12
|
+
* The grace window alone is NOT sufficient: in the field there are completed
|
|
13
|
+
* sign-ins where the success `callback` never arrives at all, so no window is
|
|
14
|
+
* long enough. `probeForCompletedGrant` below is what actually recovers those.
|
|
8
15
|
*/
|
|
9
|
-
const POPUP_CLOSED_GRACE_MS =
|
|
16
|
+
const POPUP_CLOSED_GRACE_MS = 2000;
|
|
10
17
|
/**
|
|
11
18
|
* Persists a freshly-acquired GIS token response as a StoredToken, deriving
|
|
12
19
|
* expiresAt from the response's own expires_in (never hardcoded) and
|
|
@@ -96,26 +103,62 @@ export async function acquireToken(opts) {
|
|
|
96
103
|
inFlight.delete(key);
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
function isPopupClosedError(err) {
|
|
107
|
+
return err instanceof NeedsReauthError && err.reason === 'popup_closed';
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Issues ONE `prompt: 'none'` request to find out whether the sign-in that
|
|
111
|
+
* GIS reported as `popup_closed` actually completed. Resolves with the token
|
|
112
|
+
* response when a live grant is found; otherwise rethrows `popupClosedError`
|
|
113
|
+
* — the original interactive failure — so callers see the cancellation they
|
|
114
|
+
* would have seen before, never a confusing silent-path error.
|
|
115
|
+
*/
|
|
116
|
+
async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
|
|
117
|
+
opts.logger?.debug('drive-sync: popup_closed with no token; probing for a completed grant', {
|
|
118
|
+
projectId: opts.projectId,
|
|
119
|
+
});
|
|
120
|
+
try {
|
|
121
|
+
// No grace window: `prompt: 'none'` never opens a popup, so there is no
|
|
122
|
+
// popup-closed poll to race and nothing to wait out on failure.
|
|
123
|
+
const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0);
|
|
124
|
+
opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
|
|
125
|
+
projectId: opts.projectId,
|
|
126
|
+
});
|
|
127
|
+
return response;
|
|
107
128
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
catch (probeError) {
|
|
130
|
+
opts.logger?.debug('drive-sync: no live grant after popup_closed; treating as cancelled', {
|
|
131
|
+
projectId: opts.projectId,
|
|
132
|
+
probeError,
|
|
133
|
+
});
|
|
134
|
+
throw popupClosedError;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Wraps a single GIS token request in a promise.
|
|
139
|
+
*
|
|
140
|
+
* Every call creates a FRESH `initTokenClient`, and the resolve/reject pair is
|
|
141
|
+
* captured in THIS call's closure only — never on a module-level variable — so
|
|
142
|
+
* a second concurrent call cannot clobber the first caller's promise.
|
|
143
|
+
*/
|
|
144
|
+
function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
112
146
|
let settled = false;
|
|
113
147
|
const client = initTokenClient({
|
|
114
148
|
client_id: opts.clientId,
|
|
115
149
|
scope: opts.scopes.join(' '),
|
|
116
150
|
callback: (res) => {
|
|
117
|
-
if (settled)
|
|
151
|
+
if (settled) {
|
|
152
|
+
// Diagnostic only: a token arriving after we gave up is the exact
|
|
153
|
+
// signature of a grace window that was too short, and is worth
|
|
154
|
+
// distinguishing from one that never arrived at all.
|
|
155
|
+
opts.logger?.debug('drive-sync: GIS token callback arrived after settle', {
|
|
156
|
+
projectId: opts.projectId,
|
|
157
|
+
prompt: override.prompt,
|
|
158
|
+
hadError: Boolean(res.error),
|
|
159
|
+
});
|
|
118
160
|
return;
|
|
161
|
+
}
|
|
119
162
|
settled = true;
|
|
120
163
|
if (res.error) {
|
|
121
164
|
reject(new Error(`GIS token request failed: ${res.error}`));
|
|
@@ -128,6 +171,13 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
128
171
|
// the promise below would stay pending forever and every awaiting
|
|
129
172
|
// Drive call would hang until the caller's own timeout (if any).
|
|
130
173
|
error_callback: (err) => {
|
|
174
|
+
opts.logger?.debug('drive-sync: GIS error_callback', {
|
|
175
|
+
projectId: opts.projectId,
|
|
176
|
+
prompt: override.prompt,
|
|
177
|
+
type: err?.type,
|
|
178
|
+
message: err?.message,
|
|
179
|
+
settled,
|
|
180
|
+
});
|
|
131
181
|
if (settled)
|
|
132
182
|
return;
|
|
133
183
|
if (err?.type === 'popup_closed') {
|
|
@@ -144,7 +194,7 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
144
194
|
reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
|
|
145
195
|
reason: 'popup_closed',
|
|
146
196
|
}));
|
|
147
|
-
},
|
|
197
|
+
}, popupClosedGraceMs);
|
|
148
198
|
return;
|
|
149
199
|
}
|
|
150
200
|
settled = true;
|
|
@@ -156,17 +206,43 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
156
206
|
opts.logger?.debug('drive-sync: requesting access token', {
|
|
157
207
|
projectId: opts.projectId,
|
|
158
208
|
interactive: opts.interactive,
|
|
209
|
+
prompt: override.prompt,
|
|
159
210
|
});
|
|
160
|
-
client.requestAccessToken(
|
|
211
|
+
client.requestAccessToken(override);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function acquireTokenUncoalesced(opts) {
|
|
215
|
+
await waitForGoogleIdentityServices(opts.logger);
|
|
216
|
+
const w = globalThis;
|
|
217
|
+
const initTokenClient = w.google?.accounts?.oauth2?.initTokenClient;
|
|
218
|
+
if (!initTokenClient) {
|
|
219
|
+
// waitForGoogleIdentityServices resolved, so this should not happen in
|
|
220
|
+
// practice; guard anyway rather than throwing an obscure TypeError.
|
|
221
|
+
throw new NeedsReauthError('Google Identity Services is unavailable');
|
|
222
|
+
}
|
|
223
|
+
let response;
|
|
224
|
+
try {
|
|
225
|
+
response = await requestGisToken(initTokenClient, opts, {
|
|
161
226
|
prompt: opts.interactive ? 'consent' : 'none',
|
|
162
227
|
hint: !opts.interactive ? opts.hint : undefined,
|
|
163
228
|
});
|
|
164
|
-
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
165
231
|
if (!opts.interactive) {
|
|
166
232
|
throw new NeedsReauthError('Silent token acquisition failed', { reason: 'gis_error' });
|
|
167
233
|
}
|
|
168
|
-
|
|
169
|
-
|
|
234
|
+
if (!isPopupClosedError(err)) {
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
// GIS said the popup closed and never delivered a token, but that is NOT
|
|
238
|
+
// proof the user cancelled: a completed consent whose success message is
|
|
239
|
+
// never posted back to this page looks identical from here. The two cases
|
|
240
|
+
// ARE distinguishable at Google, though — a completed consent leaves a
|
|
241
|
+
// live grant behind, so a `prompt: 'none'` request now succeeds with no
|
|
242
|
+
// popup at all. Probe for it; a cancelled sign-in leaves no grant and the
|
|
243
|
+
// probe fails, in which case we surface the original popup_closed error.
|
|
244
|
+
response = await probeForCompletedGrant(initTokenClient, opts, err);
|
|
245
|
+
}
|
|
170
246
|
const token = await persistTokenResponse(opts.appId, opts.projectId, response);
|
|
171
247
|
// Single choke point for the cross-tab "fresh token available" signal:
|
|
172
248
|
// every acquisition path (interactive connect(), silent refreshSilently(),
|