@open-webapp/drive-sync 0.5.7 → 0.7.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 +22 -4
- 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 +14 -2
- 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/driveFake.d.ts +16 -0
- package/dist/testing/driveFake.js +2 -0
- package/dist/testing/gisFake.d.ts +44 -0
- package/dist/testing/gisFake.js +53 -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 +28 -0
- package/dist/types.d.ts +38 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,59 @@ See `SPEC.md` for the full design: the 36 resolved decisions, storage layout,
|
|
|
42
42
|
and refresh state machine. `SPEC.md` is descriptive, written from the shipped
|
|
43
43
|
code — if it ever disagrees with the source, the source wins.
|
|
44
44
|
|
|
45
|
+
## Server-facilitated token exchange
|
|
46
|
+
|
|
47
|
+
By default the library runs the legacy GIS flow: an implicit-style access token
|
|
48
|
+
acquired in the browser, refreshed silently through GIS, with no
|
|
49
|
+
`refresh_token` anywhere. Pass `tokenExchangeUrl` to `createDriveSync` to opt in
|
|
50
|
+
to the server-facilitated variant instead:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { createDriveSync } from '@open-webapp/drive-sync'
|
|
54
|
+
|
|
55
|
+
const drive = createDriveSync({
|
|
56
|
+
appId: 'my-app',
|
|
57
|
+
clientId: 'xxx.apps.googleusercontent.com',
|
|
58
|
+
folderPath: ['MyApp', 'Data'],
|
|
59
|
+
tokenExchangeUrl: 'https://open-webapp.duckdns.org/callback',
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Code → envelope → replay.** With `tokenExchangeUrl` set, `connect()` uses
|
|
64
|
+
GIS `initCodeClient` (popup) to obtain a one-time authorization **code**, then
|
|
65
|
+
`POST {tokenExchangeUrl}` with `{ code }`. The server does the code→token
|
|
66
|
+
exchange, keeps the `refresh_token` server-side, and returns a signed, opaque
|
|
67
|
+
**envelope** `{ v, guid, payload, sig }` whose `payload` carries the
|
|
68
|
+
`access_token`, `expiry_date`, and `scope`. The client persists the whole
|
|
69
|
+
envelope verbatim and derives its normal `token` record from `payload`.
|
|
70
|
+
|
|
71
|
+
**Replay for refresh.** When the access token is stale, the client does not
|
|
72
|
+
talk to GIS — it replays the stored envelope byte-for-byte:
|
|
73
|
+
`POST {tokenExchangeUrl}` with `{ envelope }`. The server mints a fresh access
|
|
74
|
+
token from its stored `refresh_token` and returns a new envelope, which
|
|
75
|
+
replaces the stored one. A local freshness check skips the network entirely
|
|
76
|
+
while the current token is still outside the 5-minute refresh buffer, and
|
|
77
|
+
concurrent refreshes for the same `projectId` are coalesced.
|
|
78
|
+
|
|
79
|
+
**`refresh_token` stays server-side.** The client never sees or stores a
|
|
80
|
+
`refresh_token`; it only ever holds the opaque envelope and replays it. The
|
|
81
|
+
`sig` is a server signature that is **never** verified client-side — the
|
|
82
|
+
envelope is treated as fully opaque.
|
|
83
|
+
|
|
84
|
+
**Opt-in / legacy path unchanged.** When `tokenExchangeUrl` is absent,
|
|
85
|
+
`connect()`, refresh, and `disconnect()` behave exactly as before (GIS token
|
|
86
|
+
client, silent refresh, no envelope). Nothing about the legacy flow changes.
|
|
87
|
+
|
|
88
|
+
**CORS.** The reference exchange server's CORS allowlist is
|
|
89
|
+
`https://notesdiary.github.io` and `https://open-webapp.github.io` **only**,
|
|
90
|
+
with credentials disabled. `localhost` is not on the allowlist, so the
|
|
91
|
+
`drive-sync-oauth-tester` app runs its requests through a Vite dev-server proxy
|
|
92
|
+
that rewrites the `Origin` header to `https://open-webapp.github.io` (an
|
|
93
|
+
Origin-spoof proxy) — without it the browser blocks the exchange call.
|
|
94
|
+
|
|
95
|
+
The full request/response contract is documented at
|
|
96
|
+
`https://open-webapp.duckdns.org/callback-api.md`.
|
|
97
|
+
|
|
45
98
|
## Testing
|
|
46
99
|
|
|
47
100
|
Import fakes for GIS and Drive from the `./testing` subpath:
|
package/SPEC.md
CHANGED
|
@@ -50,7 +50,7 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
50
50
|
|
|
51
51
|
`getAccessToken()` is the one deliberate exception to `Connection` never exposing secret material (types.ts): it exists solely so an app can feed the token to Google Picker (`setOAuthToken()`), which runs outside this library's control and has no other way to read it. Reuses a cached token while it has more than 5 minutes left; otherwise acquires one (interactive by default, since callers use this to drive a UI the user is actively interacting with).
|
|
52
52
|
|
|
53
|
-
## 2. The
|
|
53
|
+
## 2. The 41 resolved design decisions
|
|
54
54
|
|
|
55
55
|
**Bugs fixed (both source apps carried these):**
|
|
56
56
|
|
|
@@ -101,14 +101,23 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
101
101
|
|
|
102
102
|
38. **`files.update()` provides metadata-only, baseline-preserving updates** — `files.ts` now exports an `update()` method that rewrites a file's metadata (name, description, mimeType, etc.) without modifying its content or version history. The update is guaranteed to preserve the file's baseline content: if a concurrent write to the same file completes between the read and update, the `update()` call will fail with a conflict error rather than silently clobbering the concurrent change. This gives apps a way to rename/reclassify a file after upload without risking accidental content loss. The method is content-agnostic, accepting only metadata fields and refusing any content-bearing parameter.
|
|
103
103
|
|
|
104
|
+
39. **Server-facilitated token exchange is opt-in via `tokenExchangeUrl`** — `DriveSyncOptions.tokenExchangeUrl` (`types.ts`). When absent, every path (`connect()`, refresh, `disconnect()`) is the legacy GIS implicit-token flow, unchanged. When set, `connection.ts`'s `connect()` branches to `connectViaEnvelope`: `gis.ts`'s `acquireAuthCode` runs GIS `google.accounts.oauth2.initCodeClient` (popup) to get a one-time authorization **code** — no `redirect_uri`, no `state`, no `prompt` knob — then `envelope.ts`'s `postExchange`/`postExchangeWithRetry` does `POST {tokenExchangeUrl}` with `{ code }` (`Content-Type: application/json`, no credentials, no custom headers) and parses a `{ envelope }` response. The whole `Envelope` (`{ v: 2, guid, payload, sig }`, `types.ts`) is persisted verbatim under a new `envelope` key in the per-project `auth` store; the normal `token` record is derived from `payload` via `deriveToken`. The external request/response contract is the callback API doc at `https://open-webapp.duckdns.org/callback-api.md`.
|
|
105
|
+
|
|
106
|
+
40. **`refreshEnvelope` — freshness-gated, coalesced, cross-tab envelope replay** — `envelope.ts`'s `refreshEnvelope` is the refresh path when `tokenExchangeUrl` is set; `acquireToken`/GIS is never reached, so no popup can appear. It (1) drains any cross-tab envelope-refresh signal for the project, (2) reads the stored envelope — absent envelope → `NeedsReauthError` (`reason: 'exchange_failed'`), (3) if `payload.expiry_date` is still outside the `REFRESH_BUFFER_MS` (5-minute) window, returns the derived token with **no network call**, (4) otherwise replays the stored envelope byte-for-byte: `POST {tokenExchangeUrl}` with `{ envelope }`, persists the returned envelope + derived token, and fires a cross-tab `token` broadcast. Concurrent calls for the same `projectId` are coalesced onto one in-flight promise (`inFlightEnvelope` map, mirroring `token.ts`'s `inFlight`).
|
|
107
|
+
|
|
108
|
+
41. **Envelope error mapping, opaque `sig`, no schema bump** — `envelope.ts` maps exchange failures onto typed reauth reasons: `410` → clear conn+token+envelope, then `NeedsReauthError` (`reason: 'refresh_token_revoked'`, surfaced internally as the distinguishable `EnvelopeRevokedError` subclass); `502` / other `5xx` / network throw / unparseable body → up to two retries (500ms, 1500ms) then `NeedsReauthError` (`reason: 'exchange_unavailable'`); other non-2xx (`400`/`401`/`404`/`403`/`409`) → `NeedsReauthError` (`reason: 'exchange_failed'`), no retry. The envelope's `sig` is a server signature that is **never** verified client-side — the structure is treated as fully opaque and only `payload` is read. The `envelope` key is added to the existing `auth` store with **no IndexedDB version bump** (still version 1).
|
|
109
|
+
|
|
110
|
+
42. **`list()` passes through `thumbnailLink` + `imageMediaMetadata`, unfiltered and verbatim** — `files.ts`'s `list()` extends the same `fields` mask touched in #36's `modifiedTime` change to also request `thumbnailLink` and `imageMediaMetadata(width,height,rotation)`; `FileRef` (`types.ts`) gains three optional fields — `mimeType?` (already fetched, previously just untyped), `thumbnailLink?`, and `imageMediaMetadata?: { width?; height?; rotation? }` — all `fields`-gated and may be absent on older or partial responses. `list()` stays unfiltered: it returns every file of any MIME type with no `image/` check and no opt-in flag, and `thumbnailLink` is passed through exactly as Drive returns it — no blob fetch, no URL rewrite, no `=s220` size munging, and no `files.thumbnail()` helper. Caveat: `thumbnailLink` is a short-lived URL (good for only ~hours) that can require the browser to be carrying Google auth context for the file's owning account, so a cross-origin bare `<img src>` may 403; rendering is the consuming app's responsibility, and it can fall back to `getAccessToken()` + fetch-to-blob itself. (Label is `42` though this is only the 41st entry — the section carries a duplicate `7.` label and a merged `25–27.` entry, so the labels have always run one ahead of the item count; no existing entry is renumbered.)
|
|
111
|
+
|
|
104
112
|
## 3. Storage layout
|
|
105
113
|
|
|
106
|
-
Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds
|
|
114
|
+
Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds up to three keys (`conn`/`token` always; `envelope` only in server-facilitated token-exchange mode):
|
|
107
115
|
|
|
108
116
|
| Key | Shape | Lifetime |
|
|
109
117
|
|---|---|---|
|
|
110
|
-
| `conn` | `{ email, grantedScopes: string[], connectedAt: number }` | Durable — survives token expiry. Written by `connect()`. Cleared only by `disconnect()
|
|
111
|
-
| `token` | `{ accessToken, expiresAt, grantedScopes: string[] }` | Ephemeral. Written by `persistTokenResponse()` on every successful token acquisition. Cleared on 401 (`http.ts`), on `ScopeInsufficientError` (`http.ts`), on a detected wrong-account mismatch (`connection.ts`'s `refreshSilently`), and by `disconnect()`. |
|
|
118
|
+
| `conn` | `{ email, grantedScopes: string[], connectedAt: number }` | Durable — survives token expiry. Written by `connect()`. Cleared only by `disconnect()` (and by the `410` envelope-revoked path). |
|
|
119
|
+
| `token` | `{ accessToken, expiresAt, grantedScopes: string[] }` | Ephemeral. Written by `persistTokenResponse()` on every successful token acquisition, and by `refreshEnvelope`/`connectViaEnvelope` (derived from the envelope `payload`) in token-exchange mode. Cleared on 401 (`http.ts`), on `ScopeInsufficientError` (`http.ts`), on a detected wrong-account mismatch (`connection.ts`'s `refreshSilently`), on the `410` envelope-revoked path, and by `disconnect()`. |
|
|
120
|
+
| `envelope` | `{ v: 2, guid, payload, sig }` (the whole opaque `Envelope`) | Present only when `tokenExchangeUrl` is configured. Written verbatim by `connectViaEnvelope()` and replaced on every successful `refreshEnvelope()`. Cleared by `disconnect()` (unconditionally) and on the `410` envelope-revoked path. Never version-bumps the store. |
|
|
112
121
|
|
|
113
122
|
Open handles are cached in-process in a `Map<string, Promise<IDBPDatabase>>` keyed by `${appId}:${projectId}` (`storage.ts`'s `dbCache`), so repeated calls for the same project reuse one connection. `evictDbHandle` closes and drops that cache entry without deleting the underlying database — the deletion itself only happens in `reconcile.ts`.
|
|
114
123
|
|
|
@@ -156,6 +165,8 @@ Concretely, by module:
|
|
|
156
165
|
- **`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.
|
|
157
166
|
- **`index.ts`**'s top-level `activate()` layers one global listener pair over `refresh.ts`'s per-call logic: it tracks every `projectId` ever passed to `.project(id)` in a `Set` and, on each visibility/pageshow event, calls `warmUpIfNeeded` for all of them (read live at fire time, so late-registered projects are still covered).
|
|
158
167
|
|
|
168
|
+
**Envelope branch (server-facilitated token-exchange mode).** When `tokenExchangeUrl` is configured, `connection.ts`'s `refreshSilently` short-circuits to `envelope.ts`'s `refreshEnvelope` and the entire GIS state machine above is bypassed — no `initTokenClient`, no `prompt:'none'`, no popup. `refreshEnvelope` does a local freshness check against the stored envelope's `payload.expiry_date` (± `REFRESH_BUFFER_MS`); if fresh it returns the derived token with no network call, otherwise it echoes-or-replays the stored envelope via `POST {tokenExchangeUrl}` with `{ envelope }`, persists the new envelope + derived `token`, and broadcasts a cross-tab `token` message. A `410` (server-side refresh token revoked) clears `conn` + `token` + `envelope` and throws `NeedsReauthError` (`reason: 'refresh_token_revoked'`); `502`/`5xx`/network → retried then `exchange_unavailable`; a missing envelope or other `4xx` → `exchange_failed`. In this mode there is no wrong-account check (the server owns the identity) and the interactive `connect()` path is `connectViaEnvelope` (auth-code leg + `POST { code }`) rather than an implicit token grant.
|
|
169
|
+
|
|
159
170
|
Wrong-account detection therefore covers exactly two silent paths — the 401-retry-once in `http.ts` and the proactive warm-up in `refresh.ts`/`index.ts` — both of which are wired through `refreshSilently`. It does **not** cover the interactive `connect()` path (a user consenting is trusted at face value) nor any refresh path where the caller omitted `fetchEmail` (the `refresh.ts` fallback branch and any hand-rolled use of `acquireToken` directly).
|
|
160
171
|
|
|
161
172
|
## 5. Known limitations / accepted tradeoffs
|
|
@@ -165,3 +176,10 @@ Wrong-account detection therefore covers exactly two silent paths — the 401-re
|
|
|
165
176
|
- **Wrong-account detection is not universal.** As detailed in §4, it is implemented once, inside `connection.ts`'s `refreshSilently`, and is only reached via two call sites: the 401-triggered silent refresh in `http.ts`, and the proactive warm-up in `refresh.ts` (when a `fetchEmail` resolver is supplied — `index.ts` always supplies one). It is **not** checked on the interactive `connect()` path, and the `refresh.ts` fallback branch that calls `acquireToken` directly (used only when no `fetchEmail` is configured) bypasses it entirely. A non-401 Drive call that succeeds against a token silently swapped to the wrong account (rather than expiring first) would not be caught until some later 401 or explicit `getConnection()`/email check.
|
|
166
177
|
- **Cross-tab token sharing is best-effort, not a guarantee.** `notifyExternalTokenRefresh` (§4, decision #31) only ever skips ONE subsequent GIS round-trip per `token` broadcast received — a one-shot flag, not a durable "this project is externally fresh" cache. If two tabs both attempt a refresh in the same narrow window, both can still end up making their own GIS calls.
|
|
167
178
|
- **`ensureFolderPath()`'s root-level lookup has no anchor.** Because the library only holds the `drive.file` scope, the first path segment is searched for by name/mimeType with no `in parents` constraint (every subsequent level is unambiguous, anchored to the previous level's id). Two folders with the same name at the top level anywhere the app can see are indistinguishable to this lookup; the first match wins.
|
|
179
|
+
|
|
180
|
+
**Server-facilitated token-exchange mode (`tokenExchangeUrl` set):**
|
|
181
|
+
|
|
182
|
+
- **Orphaned server-side `<guid>/perm-token.json` is never cleaned up.** The exchange server persists one `refresh_token` file per envelope `guid`, and there is no revoke endpoint. `disconnect()` only revokes the current *access* token (killing the live grant); it cannot tell the server to delete the stored `refresh_token`. Every `connect()` that mints a new `guid` leaves the previous server-side record behind indefinitely.
|
|
183
|
+
- **A re-`connect()` may be un-refreshable server-side.** Google returns a `refresh_token` only on the *first* consent per (user, client), and GIS `initCodeClient` exposes no `prompt` knob to force re-consent. If a later `connect()` produces a fresh `guid` but Google returns no `refresh_token` for it, the server has nothing to replay and that envelope cannot be refreshed — the user is stuck on the access token's lifetime until a consent screen is shown by some other means.
|
|
184
|
+
- **The tester needs an Origin-spoof proxy.** The reference server's CORS allowlist is `https://notesdiary.github.io` and `https://open-webapp.github.io` only, with credentials disabled; `localhost` is not allowed. `apps/drive-sync-oauth-tester` therefore routes exchange requests through a Vite dev-server proxy that rewrites the `Origin` header to `https://open-webapp.github.io`. See `https://open-webapp.duckdns.org/callback-api.md` for the contract.
|
|
185
|
+
- **The server's `GOOGLE_REDIRECT_URI` is assumed to be `postmessage`.** Popup-mode code exchange (`initCodeClient` → `POST { code }`) only works if the server exchanges the code against `redirect_uri = 'postmessage'`. This is a server-side assumption that has not been verified against the deployed server; a mismatch would make `connectViaEnvelope` fail at the exchange step.
|
package/dist/connection.d.ts
CHANGED
|
@@ -13,11 +13,22 @@ export interface ConnectOptions {
|
|
|
13
13
|
* against the Google userinfo endpoint once http.ts exists.
|
|
14
14
|
*/
|
|
15
15
|
fetchEmail: (accessToken: string) => Promise<string>;
|
|
16
|
+
/**
|
|
17
|
+
* When set, `connect()` takes the server-mediated token-exchange path:
|
|
18
|
+
* it acquires a one-time auth CODE via GIS (never an access token) and
|
|
19
|
+
* POSTs it to this endpoint, which returns a signed {@link Envelope}. The
|
|
20
|
+
* durable refresh token stays server-side. Absent, `connect()` runs the
|
|
21
|
+
* legacy client-side `initTokenClient` flow unchanged.
|
|
22
|
+
*/
|
|
23
|
+
tokenExchangeUrl?: string;
|
|
16
24
|
}
|
|
17
25
|
/**
|
|
18
26
|
* Interactive connection flow: acquires a token without forcing a consent
|
|
19
27
|
* screen, resolves the account email, and persists the durable Connection
|
|
20
28
|
* record.
|
|
29
|
+
*
|
|
30
|
+
* With `opts.tokenExchangeUrl` set, runs the envelope (server-mediated
|
|
31
|
+
* token-exchange) variant instead — see {@link connectViaEnvelope}.
|
|
21
32
|
*/
|
|
22
33
|
export declare function connect(opts: ConnectOptions): Promise<Connection>;
|
|
23
34
|
export interface RefreshSilentlyOptions {
|
|
@@ -72,6 +83,15 @@ export interface GetAccessTokenOptions {
|
|
|
72
83
|
/** Whether an interactive (popup) auth flow may be triggered if no usable cached token exists. */
|
|
73
84
|
interactive: boolean;
|
|
74
85
|
logger?: Logger;
|
|
86
|
+
/**
|
|
87
|
+
* When set, `getAccessToken()` takes the server-mediated token-exchange
|
|
88
|
+
* path: a stale/absent cached token is renewed via {@link refreshEnvelope}
|
|
89
|
+
* (which POSTs the stored envelope to this endpoint) rather than an
|
|
90
|
+
* interactive GIS code/token flow. A missing envelope surfaces as a
|
|
91
|
+
* `NeedsReauthError` from `refreshEnvelope` — no popup is ever shown.
|
|
92
|
+
* Absent, the legacy client-side flow runs unchanged.
|
|
93
|
+
*/
|
|
94
|
+
tokenExchangeUrl?: string;
|
|
75
95
|
}
|
|
76
96
|
/**
|
|
77
97
|
* Returns a raw OAuth access token for callers that must hand it directly to
|
|
@@ -100,10 +120,17 @@ export interface DisconnectOptions {
|
|
|
100
120
|
* revoke.
|
|
101
121
|
*/
|
|
102
122
|
revokeFn?: (accessToken: string) => Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Accepted for symmetry with the other flows (connect / getAccessToken).
|
|
125
|
+
* `disconnect` clears the `envelope` key unconditionally regardless of
|
|
126
|
+
* whether this is set, so this field is currently informational only.
|
|
127
|
+
*/
|
|
128
|
+
tokenExchangeUrl?: string;
|
|
103
129
|
}
|
|
104
130
|
/**
|
|
105
131
|
* Disconnects a project: revokes the cached token (if any and if a
|
|
106
|
-
* revokeFn was supplied), then unconditionally clears
|
|
107
|
-
* connection
|
|
132
|
+
* revokeFn was supplied), then unconditionally clears the durable
|
|
133
|
+
* connection, the cached token, and the stored envelope key, and
|
|
134
|
+
* broadcasts a logout to other tabs.
|
|
108
135
|
*/
|
|
109
136
|
export declare function disconnect(opts: DisconnectOptions): Promise<void>;
|
package/dist/connection.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import { getConn, setConn, clearConn, getToken, clearToken } from './storage.js';
|
|
1
|
+
import { getConn, setConn, clearConn, getToken, clearToken, setEnvelope, setToken, clearEnvelope, } from './storage.js';
|
|
2
2
|
import { createBroadcast } from './broadcast.js';
|
|
3
3
|
import { acquireToken } from './token.js';
|
|
4
|
-
import {
|
|
4
|
+
import { acquireAuthCode } from './gis.js';
|
|
5
|
+
import { deriveToken, postExchange, postExchangeWithRetry, refreshEnvelope, EnvelopeRevokedError, } from './envelope.js';
|
|
6
|
+
import { NeedsReauthError, WrongAccountError } from './errors.js';
|
|
5
7
|
/** Mirrors refresh.ts's own buffer: a cached token this close to expiry is treated as unusable. */
|
|
6
8
|
const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
|
|
7
9
|
/**
|
|
8
10
|
* Interactive connection flow: acquires a token without forcing a consent
|
|
9
11
|
* screen, resolves the account email, and persists the durable Connection
|
|
10
12
|
* record.
|
|
13
|
+
*
|
|
14
|
+
* With `opts.tokenExchangeUrl` set, runs the envelope (server-mediated
|
|
15
|
+
* token-exchange) variant instead — see {@link connectViaEnvelope}.
|
|
11
16
|
*/
|
|
12
17
|
export async function connect(opts) {
|
|
18
|
+
if (opts.tokenExchangeUrl) {
|
|
19
|
+
return connectViaEnvelope(opts, opts.tokenExchangeUrl);
|
|
20
|
+
}
|
|
13
21
|
// On a re-auth the previous connection's email is the account the user is
|
|
14
22
|
// expected to consent as again; pass it as a hint so the popup_closed
|
|
15
23
|
// recovery probe (token.ts) can resolve a completed grant silently even
|
|
@@ -37,6 +45,63 @@ export async function connect(opts) {
|
|
|
37
45
|
expiresAt: token.expiresAt,
|
|
38
46
|
};
|
|
39
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Server-mediated token-exchange connect:
|
|
50
|
+
* 1. read the stored conn (for the account `hint` only);
|
|
51
|
+
* 2. acquire a one-time auth CODE via GIS (never touches `acquireToken` /
|
|
52
|
+
* `initTokenClient`);
|
|
53
|
+
* 3. POST the code to the exchange endpoint for a signed envelope, retrying
|
|
54
|
+
* transient failures via `postExchangeWithRetry`;
|
|
55
|
+
* 4. persist envelope + derived token + durable conn;
|
|
56
|
+
* 5. resolve the email once (no wrong-account compare — a fresh interactive
|
|
57
|
+
* grant is authoritative about which account it belongs to).
|
|
58
|
+
*
|
|
59
|
+
* An {@link EnvelopeRevokedError} (410) additionally clears conn+token+envelope
|
|
60
|
+
* before propagating; every other {@link NeedsReauthError} propagates as-is.
|
|
61
|
+
*/
|
|
62
|
+
async function connectViaEnvelope(opts, tokenExchangeUrl) {
|
|
63
|
+
const existing = await getConn(opts.appId, opts.projectId);
|
|
64
|
+
const code = await acquireAuthCode({
|
|
65
|
+
clientId: opts.clientId,
|
|
66
|
+
scopes: opts.scopes,
|
|
67
|
+
hint: existing?.email,
|
|
68
|
+
logger: opts.logger,
|
|
69
|
+
});
|
|
70
|
+
let envelope;
|
|
71
|
+
try {
|
|
72
|
+
envelope = await postExchange(tokenExchangeUrl, { code }, opts.logger);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof EnvelopeRevokedError) {
|
|
76
|
+
await clearConn(opts.appId, opts.projectId);
|
|
77
|
+
await clearToken(opts.appId, opts.projectId);
|
|
78
|
+
await clearEnvelope(opts.appId, opts.projectId);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
// A non-retryable rejection (400/401/404) surfaces as a plain
|
|
82
|
+
// NeedsReauthError from postExchange — propagate it untouched.
|
|
83
|
+
if (err instanceof NeedsReauthError) {
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
// Anything else is a transient (network / 502 / unparseable) failure:
|
|
87
|
+
// fall through to the retrying variant.
|
|
88
|
+
envelope = await postExchangeWithRetry(tokenExchangeUrl, { code }, opts.logger);
|
|
89
|
+
}
|
|
90
|
+
await setEnvelope(opts.appId, opts.projectId, envelope);
|
|
91
|
+
const token = deriveToken(envelope.payload);
|
|
92
|
+
await setToken(opts.appId, opts.projectId, token);
|
|
93
|
+
const email = await opts.fetchEmail(token.accessToken);
|
|
94
|
+
await setConn(opts.appId, opts.projectId, {
|
|
95
|
+
email,
|
|
96
|
+
grantedScopes: token.grantedScopes,
|
|
97
|
+
connectedAt: Date.now(),
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
email,
|
|
101
|
+
needsReauth: false,
|
|
102
|
+
expiresAt: token.expiresAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
40
105
|
/**
|
|
41
106
|
* Wraps a non-interactive `acquireToken` call with account-identity
|
|
42
107
|
* verification: after GIS hands back a token, resolves the email it
|
|
@@ -103,6 +168,18 @@ export async function getAccessToken(opts) {
|
|
|
103
168
|
if (cached && cached.expiresAt > Date.now() + TOKEN_REUSE_BUFFER_MS) {
|
|
104
169
|
return cached.accessToken;
|
|
105
170
|
}
|
|
171
|
+
// Server-mediated token-exchange mode: renew via the stored envelope, never
|
|
172
|
+
// an interactive code/token flow. A missing envelope surfaces as a
|
|
173
|
+
// NeedsReauthError from refreshEnvelope — acquireToken is never reached.
|
|
174
|
+
if (opts.tokenExchangeUrl) {
|
|
175
|
+
const token = await refreshEnvelope({
|
|
176
|
+
appId: opts.appId,
|
|
177
|
+
projectId: opts.projectId,
|
|
178
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
179
|
+
logger: opts.logger,
|
|
180
|
+
});
|
|
181
|
+
return token.accessToken;
|
|
182
|
+
}
|
|
106
183
|
// Same rationale as connect(): hand the known account email to the
|
|
107
184
|
// popup_closed recovery probe so it can pick up a completed grant silently.
|
|
108
185
|
const existing = await getConn(opts.appId, opts.projectId);
|
|
@@ -119,8 +196,9 @@ export async function getAccessToken(opts) {
|
|
|
119
196
|
}
|
|
120
197
|
/**
|
|
121
198
|
* Disconnects a project: revokes the cached token (if any and if a
|
|
122
|
-
* revokeFn was supplied), then unconditionally clears
|
|
123
|
-
* connection
|
|
199
|
+
* revokeFn was supplied), then unconditionally clears the durable
|
|
200
|
+
* connection, the cached token, and the stored envelope key, and
|
|
201
|
+
* broadcasts a logout to other tabs.
|
|
124
202
|
*/
|
|
125
203
|
export async function disconnect(opts) {
|
|
126
204
|
const token = await getToken(opts.appId, opts.projectId);
|
|
@@ -129,5 +207,6 @@ export async function disconnect(opts) {
|
|
|
129
207
|
}
|
|
130
208
|
await clearConn(opts.appId, opts.projectId);
|
|
131
209
|
await clearToken(opts.appId, opts.projectId);
|
|
210
|
+
await clearEnvelope(opts.appId, opts.projectId);
|
|
132
211
|
createBroadcast(opts.appId).postLogout(opts.projectId);
|
|
133
212
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { NeedsReauthError } from './errors.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
import type { Envelope, EnvelopePayload, StoredToken } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Distinguishable 410 sentinel: the server-side refresh token was revoked, so
|
|
6
|
+
* every stored credential for this project is now worthless. It IS a
|
|
7
|
+
* `NeedsReauthError` (`reason: 'refresh_token_revoked'`) so a caller that does
|
|
8
|
+
* not special-case it still does the right, if lossy, thing; callers that want
|
|
9
|
+
* to also wipe conn+token+envelope test `err instanceof EnvelopeRevokedError`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class EnvelopeRevokedError extends NeedsReauthError {
|
|
12
|
+
constructor(message?: string);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Project an {@link EnvelopePayload} onto the {@link StoredToken} shape the
|
|
16
|
+
* rest of drive-sync persists. Pure; no I/O.
|
|
17
|
+
*/
|
|
18
|
+
export declare function deriveToken(payload: EnvelopePayload): StoredToken;
|
|
19
|
+
/**
|
|
20
|
+
* POST `body` to the token-exchange endpoint once and return the `envelope`
|
|
21
|
+
* from a 2xx `{ envelope }` response.
|
|
22
|
+
*
|
|
23
|
+
* Throws:
|
|
24
|
+
* - {@link EnvelopeRevokedError} on a 410 (`code === 'refresh_token_revoked'`
|
|
25
|
+
* or a bare 410) — no retry.
|
|
26
|
+
* - {@link EnvelopeRetryableError} on a 502 / 5xx / network throw / unparseable
|
|
27
|
+
* body — the retry wrapper handles these.
|
|
28
|
+
* - {@link NeedsReauthError} (`reason: 'exchange_failed'`) on a 400 / 401 / 404
|
|
29
|
+
* — logs and does not retry.
|
|
30
|
+
*/
|
|
31
|
+
export declare function postExchange(url: string, body: {
|
|
32
|
+
code: string;
|
|
33
|
+
} | {
|
|
34
|
+
envelope: Envelope;
|
|
35
|
+
}, logger?: Logger): Promise<Envelope>;
|
|
36
|
+
/**
|
|
37
|
+
* {@link postExchange} with transient-failure retries: on a retryable error
|
|
38
|
+
* wait 500ms and retry, on a second retryable error wait 1500ms and retry,
|
|
39
|
+
* then give up with {@link NeedsReauthError} (`reason: 'exchange_unavailable'`).
|
|
40
|
+
*
|
|
41
|
+
* Non-retryable errors ({@link EnvelopeRevokedError}, the `exchange_failed`
|
|
42
|
+
* {@link NeedsReauthError}) propagate immediately, unretried.
|
|
43
|
+
*/
|
|
44
|
+
export declare function postExchangeWithRetry(url: string, body: {
|
|
45
|
+
code: string;
|
|
46
|
+
} | {
|
|
47
|
+
envelope: Envelope;
|
|
48
|
+
}, logger?: Logger): Promise<Envelope>;
|
|
49
|
+
export interface RefreshEnvelopeOptions {
|
|
50
|
+
appId: string;
|
|
51
|
+
projectId: string;
|
|
52
|
+
tokenExchangeUrl: string;
|
|
53
|
+
logger?: Logger;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Freshness-gated, coalesced envelope refresh for the server-mediated
|
|
57
|
+
* token-exchange mode.
|
|
58
|
+
*
|
|
59
|
+
* 1. Drains any pending cross-tab envelope-refresh signal for this project
|
|
60
|
+
* (so a fresh envelope another tab just persisted is picked up here).
|
|
61
|
+
* 2. Reads the stored envelope. No envelope means "not connected in this
|
|
62
|
+
* mode" -> {@link NeedsReauthError} (`reason: 'exchange_failed'`).
|
|
63
|
+
* 3. If the stored access token is still outside the {@link REFRESH_BUFFER_MS}
|
|
64
|
+
* window, returns {@link deriveToken}(payload) with NO network call.
|
|
65
|
+
* 4. Otherwise POSTs `{ envelope }` via {@link postExchangeWithRetry}. On the
|
|
66
|
+
* 410 {@link EnvelopeRevokedError} sentinel it clears conn + token +
|
|
67
|
+
* envelope and re-throws a plain `NeedsReauthError`
|
|
68
|
+
* (`reason: 'refresh_token_revoked'`); on success it persists the new
|
|
69
|
+
* envelope + derived token, fires a cross-tab `token` broadcast, and
|
|
70
|
+
* returns the derived token.
|
|
71
|
+
* 5. Concurrent calls for the same `projectId` are coalesced onto one promise.
|
|
72
|
+
*/
|
|
73
|
+
export declare function refreshEnvelope(opts: RefreshEnvelopeOptions): Promise<StoredToken>;
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-mediated token-exchange transport.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the HTTP conversation with the token-exchange endpoint
|
|
5
|
+
* (`DriveSyncOptions.tokenExchangeUrl`): POSTing an auth `code` or a stored
|
|
6
|
+
* `envelope`, parsing the `{ envelope }` response, mapping failures onto typed
|
|
7
|
+
* {@link NeedsReauthError}s, and retrying the transient ones.
|
|
8
|
+
*
|
|
9
|
+
* It deliberately has NO knowledge of storage: it never clears the connection,
|
|
10
|
+
* token, or envelope records. The one failure that requires a clear — a `410`
|
|
11
|
+
* saying the server-side refresh token was revoked — is surfaced as a
|
|
12
|
+
* distinguishable {@link EnvelopeRevokedError} (a tagged subclass of
|
|
13
|
+
* `NeedsReauthError`, `reason: 'refresh_token_revoked'`). T4/T6 callers detect
|
|
14
|
+
* it with `instanceof EnvelopeRevokedError` and run the
|
|
15
|
+
* clear-conn+token+envelope themselves.
|
|
16
|
+
*/
|
|
17
|
+
import { createBroadcast } from './broadcast.js';
|
|
18
|
+
import { NeedsReauthError } from './errors.js';
|
|
19
|
+
import { REFRESH_BUFFER_MS } from './refresh.js';
|
|
20
|
+
import { clearConn, clearEnvelope, clearToken, getEnvelope, setEnvelope, setToken, } from './storage.js';
|
|
21
|
+
import { consumeExternalEnvelopeRefresh } from './token.js';
|
|
22
|
+
/**
|
|
23
|
+
* Distinguishable 410 sentinel: the server-side refresh token was revoked, so
|
|
24
|
+
* every stored credential for this project is now worthless. It IS a
|
|
25
|
+
* `NeedsReauthError` (`reason: 'refresh_token_revoked'`) so a caller that does
|
|
26
|
+
* not special-case it still does the right, if lossy, thing; callers that want
|
|
27
|
+
* to also wipe conn+token+envelope test `err instanceof EnvelopeRevokedError`.
|
|
28
|
+
*/
|
|
29
|
+
export class EnvelopeRevokedError extends NeedsReauthError {
|
|
30
|
+
constructor(message = 'Server-side refresh token was revoked') {
|
|
31
|
+
super(message, { status: 410, reason: 'refresh_token_revoked' });
|
|
32
|
+
this.name = 'EnvelopeRevokedError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Internal marker for a failure worth retrying (a 502, a 5xx, a network-level
|
|
37
|
+
* throw, or an unparseable body). Never escapes this module: the retry wrapper
|
|
38
|
+
* either retries past it or converts it to `NeedsReauthError`
|
|
39
|
+
* (`reason: 'exchange_unavailable'`).
|
|
40
|
+
*/
|
|
41
|
+
class EnvelopeRetryableError extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'EnvelopeRetryableError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Real-timer delay. Vitest fake timers still drive this via advanceTimersByTimeAsync. */
|
|
48
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
|
+
const RETRY_DELAYS_MS = [500, 1500];
|
|
50
|
+
/**
|
|
51
|
+
* Project an {@link EnvelopePayload} onto the {@link StoredToken} shape the
|
|
52
|
+
* rest of drive-sync persists. Pure; no I/O.
|
|
53
|
+
*/
|
|
54
|
+
export function deriveToken(payload) {
|
|
55
|
+
return {
|
|
56
|
+
accessToken: payload.access_token,
|
|
57
|
+
expiresAt: payload.expiry_date,
|
|
58
|
+
grantedScopes: payload.scope.split(' ').filter(Boolean),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* POST `body` to the token-exchange endpoint once and return the `envelope`
|
|
63
|
+
* from a 2xx `{ envelope }` response.
|
|
64
|
+
*
|
|
65
|
+
* Throws:
|
|
66
|
+
* - {@link EnvelopeRevokedError} on a 410 (`code === 'refresh_token_revoked'`
|
|
67
|
+
* or a bare 410) — no retry.
|
|
68
|
+
* - {@link EnvelopeRetryableError} on a 502 / 5xx / network throw / unparseable
|
|
69
|
+
* body — the retry wrapper handles these.
|
|
70
|
+
* - {@link NeedsReauthError} (`reason: 'exchange_failed'`) on a 400 / 401 / 404
|
|
71
|
+
* — logs and does not retry.
|
|
72
|
+
*/
|
|
73
|
+
export async function postExchange(url, body, logger) {
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
// Network-level failure (DNS, offline, connection reset, CORS abort).
|
|
84
|
+
throw new EnvelopeRetryableError(`token exchange request failed: ${err?.message ?? String(err)}`);
|
|
85
|
+
}
|
|
86
|
+
if (res.ok) {
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = (await res.json());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new EnvelopeRetryableError('token exchange returned a non-JSON 2xx body');
|
|
93
|
+
}
|
|
94
|
+
return parsed.envelope;
|
|
95
|
+
}
|
|
96
|
+
// Non-2xx: try to read a structured { error: { code } }.
|
|
97
|
+
let errBody;
|
|
98
|
+
let bodyParsed = false;
|
|
99
|
+
try {
|
|
100
|
+
errBody = (await res.json());
|
|
101
|
+
bodyParsed = true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
bodyParsed = false;
|
|
105
|
+
}
|
|
106
|
+
const code = errBody?.error?.code;
|
|
107
|
+
if (res.status === 410) {
|
|
108
|
+
// Revoked server-side refresh token — everything stored is dead.
|
|
109
|
+
throw new EnvelopeRevokedError(code === 'refresh_token_revoked'
|
|
110
|
+
? 'Server reported the refresh token was revoked (410)'
|
|
111
|
+
: 'Token exchange endpoint returned 410');
|
|
112
|
+
}
|
|
113
|
+
if (res.status === 400 || res.status === 401 || res.status === 404) {
|
|
114
|
+
logger?.error(`drive-sync: token exchange failed (${res.status}${code ? ` ${code}` : ''}); reauth required`);
|
|
115
|
+
throw new NeedsReauthError('Token exchange rejected the request', {
|
|
116
|
+
status: res.status,
|
|
117
|
+
reason: 'exchange_failed',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// 502, other 5xx, or an unreadable body: worth another try.
|
|
121
|
+
if (res.status === 502 || res.status >= 500 || !bodyParsed) {
|
|
122
|
+
throw new EnvelopeRetryableError(`token exchange transient failure (${res.status})`);
|
|
123
|
+
}
|
|
124
|
+
// Any other unexpected non-2xx (e.g. 403, 409): treat as non-retryable reauth.
|
|
125
|
+
logger?.error(`drive-sync: token exchange failed (${res.status}${code ? ` ${code}` : ''}); reauth required`);
|
|
126
|
+
throw new NeedsReauthError('Token exchange rejected the request', {
|
|
127
|
+
status: res.status,
|
|
128
|
+
reason: 'exchange_failed',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* {@link postExchange} with transient-failure retries: on a retryable error
|
|
133
|
+
* wait 500ms and retry, on a second retryable error wait 1500ms and retry,
|
|
134
|
+
* then give up with {@link NeedsReauthError} (`reason: 'exchange_unavailable'`).
|
|
135
|
+
*
|
|
136
|
+
* Non-retryable errors ({@link EnvelopeRevokedError}, the `exchange_failed`
|
|
137
|
+
* {@link NeedsReauthError}) propagate immediately, unretried.
|
|
138
|
+
*/
|
|
139
|
+
export async function postExchangeWithRetry(url, body, logger) {
|
|
140
|
+
for (let attempt = 0;; attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
return await postExchange(url, body, logger);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (!(err instanceof EnvelopeRetryableError))
|
|
146
|
+
throw err;
|
|
147
|
+
if (attempt >= RETRY_DELAYS_MS.length) {
|
|
148
|
+
throw new NeedsReauthError('Token exchange service is unavailable', {
|
|
149
|
+
reason: 'exchange_unavailable',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
await delay(RETRY_DELAYS_MS[attempt]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Per-`projectId` coalescing map for {@link refreshEnvelope}, mirroring the
|
|
158
|
+
* `inFlight` pattern in token.ts: concurrent callers for the same project
|
|
159
|
+
* share a single in-flight promise, and the entry is removed in a `finally`
|
|
160
|
+
* once it settles (success or failure) so the next call starts fresh.
|
|
161
|
+
*/
|
|
162
|
+
const inFlightEnvelope = new Map();
|
|
163
|
+
/**
|
|
164
|
+
* Freshness-gated, coalesced envelope refresh for the server-mediated
|
|
165
|
+
* token-exchange mode.
|
|
166
|
+
*
|
|
167
|
+
* 1. Drains any pending cross-tab envelope-refresh signal for this project
|
|
168
|
+
* (so a fresh envelope another tab just persisted is picked up here).
|
|
169
|
+
* 2. Reads the stored envelope. No envelope means "not connected in this
|
|
170
|
+
* mode" -> {@link NeedsReauthError} (`reason: 'exchange_failed'`).
|
|
171
|
+
* 3. If the stored access token is still outside the {@link REFRESH_BUFFER_MS}
|
|
172
|
+
* window, returns {@link deriveToken}(payload) with NO network call.
|
|
173
|
+
* 4. Otherwise POSTs `{ envelope }` via {@link postExchangeWithRetry}. On the
|
|
174
|
+
* 410 {@link EnvelopeRevokedError} sentinel it clears conn + token +
|
|
175
|
+
* envelope and re-throws a plain `NeedsReauthError`
|
|
176
|
+
* (`reason: 'refresh_token_revoked'`); on success it persists the new
|
|
177
|
+
* envelope + derived token, fires a cross-tab `token` broadcast, and
|
|
178
|
+
* returns the derived token.
|
|
179
|
+
* 5. Concurrent calls for the same `projectId` are coalesced onto one promise.
|
|
180
|
+
*/
|
|
181
|
+
export async function refreshEnvelope(opts) {
|
|
182
|
+
const existing = inFlightEnvelope.get(opts.projectId);
|
|
183
|
+
if (existing) {
|
|
184
|
+
return existing;
|
|
185
|
+
}
|
|
186
|
+
const promise = refreshEnvelopeUncoalesced(opts);
|
|
187
|
+
inFlightEnvelope.set(opts.projectId, promise);
|
|
188
|
+
try {
|
|
189
|
+
return await promise;
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
inFlightEnvelope.delete(opts.projectId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function refreshEnvelopeUncoalesced(opts) {
|
|
196
|
+
const { appId, projectId, tokenExchangeUrl, logger } = opts;
|
|
197
|
+
// A cross-tab signal only tells us to re-read storage; the durable copy is
|
|
198
|
+
// IndexedDB. Draining it here is a no-op beyond the getEnvelope() below,
|
|
199
|
+
// but keeps the "consume the one-shot signal" contract explicit.
|
|
200
|
+
consumeExternalEnvelopeRefresh(projectId);
|
|
201
|
+
const envelope = await getEnvelope(appId, projectId);
|
|
202
|
+
if (!envelope) {
|
|
203
|
+
throw new NeedsReauthError('No stored envelope; connect is required', {
|
|
204
|
+
reason: 'exchange_failed',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (Date.now() < envelope.payload.expiry_date - REFRESH_BUFFER_MS) {
|
|
208
|
+
return deriveToken(envelope.payload);
|
|
209
|
+
}
|
|
210
|
+
let refreshed;
|
|
211
|
+
try {
|
|
212
|
+
refreshed = await postExchangeWithRetry(tokenExchangeUrl, { envelope }, logger);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err instanceof EnvelopeRevokedError) {
|
|
216
|
+
await clearConn(appId, projectId);
|
|
217
|
+
await clearToken(appId, projectId);
|
|
218
|
+
await clearEnvelope(appId, projectId);
|
|
219
|
+
throw new NeedsReauthError('Server-side refresh token was revoked', {
|
|
220
|
+
status: 410,
|
|
221
|
+
reason: 'refresh_token_revoked',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
const token = deriveToken(refreshed.payload);
|
|
227
|
+
await setEnvelope(appId, projectId, refreshed);
|
|
228
|
+
await setToken(appId, projectId, token);
|
|
229
|
+
createBroadcast(appId).postToken(projectId);
|
|
230
|
+
return token;
|
|
231
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -16,6 +16,14 @@ export declare class DriveSyncError extends Error {
|
|
|
16
16
|
/**
|
|
17
17
|
* Thrown when no usable token exists and the requested call is
|
|
18
18
|
* non-interactive (so no popup/redirect flow may be triggered to obtain one).
|
|
19
|
+
*
|
|
20
|
+
* Known `reason` values:
|
|
21
|
+
* - `popup_closed`, `gis_timeout`, `gis_error` — legacy GIS implicit flow (token.ts).
|
|
22
|
+
* - `refresh_token_revoked` — server-mediated exchange, 410; carried by the
|
|
23
|
+
* `EnvelopeRevokedError` subclass (envelope.ts). Caller must additionally
|
|
24
|
+
* clear conn+token+envelope.
|
|
25
|
+
* - `exchange_failed` — server-mediated exchange, 400/401/404; not retryable.
|
|
26
|
+
* - `exchange_unavailable` — server-mediated exchange, still 502/5xx after retries.
|
|
19
27
|
*/
|
|
20
28
|
export declare class NeedsReauthError extends DriveSyncError {
|
|
21
29
|
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
package/dist/errors.js
CHANGED
|
@@ -17,6 +17,14 @@ export class DriveSyncError extends Error {
|
|
|
17
17
|
/**
|
|
18
18
|
* Thrown when no usable token exists and the requested call is
|
|
19
19
|
* non-interactive (so no popup/redirect flow may be triggered to obtain one).
|
|
20
|
+
*
|
|
21
|
+
* Known `reason` values:
|
|
22
|
+
* - `popup_closed`, `gis_timeout`, `gis_error` — legacy GIS implicit flow (token.ts).
|
|
23
|
+
* - `refresh_token_revoked` — server-mediated exchange, 410; carried by the
|
|
24
|
+
* `EnvelopeRevokedError` subclass (envelope.ts). Caller must additionally
|
|
25
|
+
* clear conn+token+envelope.
|
|
26
|
+
* - `exchange_failed` — server-mediated exchange, 400/401/404; not retryable.
|
|
27
|
+
* - `exchange_unavailable` — server-mediated exchange, still 502/5xx after retries.
|
|
20
28
|
*/
|
|
21
29
|
export class NeedsReauthError extends DriveSyncError {
|
|
22
30
|
constructor(message = 'Reauthentication required', opts) {
|