@open-webapp/drive-sync 0.5.2 → 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 +91 -18
- 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
|
@@ -8,6 +8,10 @@ import { createBroadcast } from './broadcast.js';
|
|
|
8
8
|
* 300ms proved too tight in production: on a real network round-trip the
|
|
9
9
|
* success token can arrive well after GIS's popup-closed poll fires,
|
|
10
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.
|
|
11
15
|
*/
|
|
12
16
|
const POPUP_CLOSED_GRACE_MS = 2000;
|
|
13
17
|
/**
|
|
@@ -99,26 +103,62 @@ export async function acquireToken(opts) {
|
|
|
99
103
|
inFlight.delete(key);
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
110
128
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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) => {
|
|
115
146
|
let settled = false;
|
|
116
147
|
const client = initTokenClient({
|
|
117
148
|
client_id: opts.clientId,
|
|
118
149
|
scope: opts.scopes.join(' '),
|
|
119
150
|
callback: (res) => {
|
|
120
|
-
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
|
+
});
|
|
121
160
|
return;
|
|
161
|
+
}
|
|
122
162
|
settled = true;
|
|
123
163
|
if (res.error) {
|
|
124
164
|
reject(new Error(`GIS token request failed: ${res.error}`));
|
|
@@ -131,6 +171,13 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
131
171
|
// the promise below would stay pending forever and every awaiting
|
|
132
172
|
// Drive call would hang until the caller's own timeout (if any).
|
|
133
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
|
+
});
|
|
134
181
|
if (settled)
|
|
135
182
|
return;
|
|
136
183
|
if (err?.type === 'popup_closed') {
|
|
@@ -147,7 +194,7 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
147
194
|
reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
|
|
148
195
|
reason: 'popup_closed',
|
|
149
196
|
}));
|
|
150
|
-
},
|
|
197
|
+
}, popupClosedGraceMs);
|
|
151
198
|
return;
|
|
152
199
|
}
|
|
153
200
|
settled = true;
|
|
@@ -159,17 +206,43 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
159
206
|
opts.logger?.debug('drive-sync: requesting access token', {
|
|
160
207
|
projectId: opts.projectId,
|
|
161
208
|
interactive: opts.interactive,
|
|
209
|
+
prompt: override.prompt,
|
|
162
210
|
});
|
|
163
|
-
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, {
|
|
164
226
|
prompt: opts.interactive ? 'consent' : 'none',
|
|
165
227
|
hint: !opts.interactive ? opts.hint : undefined,
|
|
166
228
|
});
|
|
167
|
-
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
168
231
|
if (!opts.interactive) {
|
|
169
232
|
throw new NeedsReauthError('Silent token acquisition failed', { reason: 'gis_error' });
|
|
170
233
|
}
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|
|
173
246
|
const token = await persistTokenResponse(opts.appId, opts.projectId, response);
|
|
174
247
|
// Single choke point for the cross-tab "fresh token available" signal:
|
|
175
248
|
// every acquisition path (interactive connect(), silent refreshSilently(),
|