@flytedesk/app-kit 0.8.0 → 2.0.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 +27 -0
- package/dist/auth/client/httpClient.d.ts +3 -1
- package/dist/auth/client/httpClient.js +5 -3
- package/dist/auth/client/httpClient.js.map +1 -1
- package/dist/auth/client/index.d.ts +2 -1
- package/dist/auth/client/index.js +1 -0
- package/dist/auth/client/index.js.map +1 -1
- package/dist/auth/client/silentAuth.d.ts +17 -0
- package/dist/auth/client/silentAuth.js +106 -0
- package/dist/auth/client/silentAuth.js.map +1 -0
- package/dist/auth/errors.d.ts +12 -0
- package/dist/auth/errors.js +15 -0
- package/dist/auth/errors.js.map +1 -1
- package/dist/auth/index.d.ts +23 -8
- package/dist/auth/index.js +20 -5
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oidc-client.d.ts +40 -0
- package/dist/auth/oidc-client.js +61 -17
- package/dist/auth/oidc-client.js.map +1 -1
- package/dist/auth/plugin.js +303 -94
- package/dist/auth/plugin.js.map +1 -1
- package/dist/auth/shared-types.d.ts +24 -0
- package/dist/auth/shared-types.js +19 -0
- package/dist/auth/shared-types.js.map +1 -1
- package/dist/auth/testing/fake-idp.d.ts +35 -0
- package/dist/auth/testing/fake-idp.js +101 -6
- package/dist/auth/testing/fake-idp.js.map +1 -1
- package/dist/auth/types.d.ts +153 -2
- package/package.json +5 -1
package/dist/auth/types.d.ts
CHANGED
|
@@ -29,6 +29,20 @@ export interface RefreshTokenRow {
|
|
|
29
29
|
idpSessionId: string;
|
|
30
30
|
revokedAt: Date | null;
|
|
31
31
|
expiresAt: Date;
|
|
32
|
+
/**
|
|
33
|
+
* AK-14 (gap 3; MP-127): whether some OTHER row's `replacesId` points at this one —
|
|
34
|
+
* i.e. this token's presentation was, at some point, followed by a successful
|
|
35
|
+
* rotation, as opposed to being revoked directly (a logout, or reuse detection
|
|
36
|
+
* itself). Only a rotated token's LATE replay (`revokedAt` already set when
|
|
37
|
+
* `findRefreshTokenByHash` read this row, outside `refreshReuseGraceMs`) can be a
|
|
38
|
+
* concurrent-tab race; `../plugin.ts`'s `POST {routePrefix}/refresh` reads this
|
|
39
|
+
* straight off the row it already fetched — never recomputed on any other code path —
|
|
40
|
+
* specifically so its answer reflects a fully-committed state, not one still
|
|
41
|
+
* in-flight in a sibling request (see that route's doc comment for why a same-instant
|
|
42
|
+
* race is instead decided entirely by `claimRefreshTokenForRotation`'s atomicity,
|
|
43
|
+
* not by this field).
|
|
44
|
+
*/
|
|
45
|
+
rotated: boolean;
|
|
32
46
|
}
|
|
33
47
|
export interface CreateRefreshTokenInput {
|
|
34
48
|
id: string;
|
|
@@ -41,6 +55,14 @@ export interface CreateRefreshTokenInput {
|
|
|
41
55
|
idpSessionId: string;
|
|
42
56
|
userAgent: string | null;
|
|
43
57
|
ip: string;
|
|
58
|
+
/**
|
|
59
|
+
* The id of the refresh token this one was rotated FROM, or `null` for the first
|
|
60
|
+
* token of a family (issued at login). AK-14: this is what `RefreshTokenRow.rotated`
|
|
61
|
+
* (above) answers — see that field's doc comment and `../plugin.ts`'s
|
|
62
|
+
* `refreshReuseGraceMs` for how a late replay of a rotated token is told apart from
|
|
63
|
+
* reuse.
|
|
64
|
+
*/
|
|
65
|
+
replacesId: string | null;
|
|
44
66
|
}
|
|
45
67
|
export interface IdpSessionRow {
|
|
46
68
|
id: string;
|
|
@@ -87,17 +109,86 @@ export interface AuthStore {
|
|
|
87
109
|
* the CALLER can't close this gap no matter how carefully it's written — the
|
|
88
110
|
* atomicity has to live in this one storage operation. Never implement this as a
|
|
89
111
|
* separate read followed by an unconditional write.
|
|
112
|
+
*
|
|
113
|
+
* AK-14 (gap 3; MP-127): `../plugin.ts`'s `POST {routePrefix}/refresh` only ever calls
|
|
114
|
+
* this for a token whose `findRefreshTokenByHash` read, moments earlier in the SAME
|
|
115
|
+
* request, showed `revokedAt: null` — i.e. as far as this request could tell, the
|
|
116
|
+
* token still looked live. A `false` result here therefore means a SIBLING request
|
|
117
|
+
* claimed it in the tiny window since that read: a genuine, same-instant
|
|
118
|
+
* concurrent-tab race, decided by this method's atomicity alone — unconditionally
|
|
119
|
+
* treated as a race (401, nothing revoked), with no timing/rotated check needed,
|
|
120
|
+
* exactly matching media-planner's own `updateMany({revokedAt: null}); if (count !==
|
|
121
|
+
* 1) return false` shape. A LATE replay of an already (and by now long-since)
|
|
122
|
+
* rotated-away token is a different code path entirely: it never reaches this method
|
|
123
|
+
* at all, because `findRefreshTokenByHash`'s OWN `revokedAt`/`rotated` answer (read
|
|
124
|
+
* before this is ever called) already resolves it — see that field's doc comment.
|
|
90
125
|
*/
|
|
91
126
|
claimRefreshTokenForRotation(id: string): Promise<boolean>;
|
|
92
|
-
/**
|
|
93
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Revoke every row sharing this familyId — called for reuse detection, sign-out, and
|
|
129
|
+
* an authorization rejection on refresh (see `RevokeFamilyReason`, AK-20 gap 2327).
|
|
130
|
+
* `reason` carries WHY, so a consumer's implementation can write its own audit record
|
|
131
|
+
* (e.g. media-planner's reuse-detection log) without the plugin knowing anything about
|
|
132
|
+
* that consumer's audit schema — see `RevokeFamilyReason`'s doc comment for what each
|
|
133
|
+
* variant carries.
|
|
134
|
+
*/
|
|
135
|
+
revokeFamily(familyId: string, reason: RevokeFamilyReason): Promise<void>;
|
|
94
136
|
createIdpSession(row: CreateIdpSessionInput): Promise<{
|
|
95
137
|
id: string;
|
|
96
138
|
}>;
|
|
97
139
|
findIdpSession(id: string): Promise<IdpSessionRow | null>;
|
|
98
140
|
updateIdpSession(id: string, patch: UpdateIdpSessionInput): Promise<void>;
|
|
99
141
|
deleteIdpSession(id: string): Promise<void>;
|
|
142
|
+
/**
|
|
143
|
+
* Runs `fn` while holding an exclusive, per-session lock (AK-20, gap 2326) — a
|
|
144
|
+
* Postgres-backed store implements this with an advisory lock scoped to `sessionId`
|
|
145
|
+
* (e.g. `pg_advisory_xact_lock(hashtext(sessionId))`, taken and released within the
|
|
146
|
+
* same transaction `fn` runs in), so the lock is held across concurrent requests on
|
|
147
|
+
* different server instances, not just within one process; an in-memory store (tests,
|
|
148
|
+
* the hello-world example) can implement it with a simple per-key mutex/queue.
|
|
149
|
+
*
|
|
150
|
+
* Fixes a real race in `../plugin.ts`'s `getLiveIdpAccessToken`: flytedesk-id rotates
|
|
151
|
+
* its own refresh token on every use, so two concurrent requests on the same
|
|
152
|
+
* near-expiry session could both read "stale" before either write landed, both call
|
|
153
|
+
* `oidc.refreshIdpTokens` with the SAME (about-to-be-consumed) refresh token, and the
|
|
154
|
+
* loser's request then presents a refresh token flytedesk-id has already rotated away
|
|
155
|
+
* — getting that otherwise perfectly legitimate session signed out. Every caller of
|
|
156
|
+
* `getLiveIdpAccessToken` re-reads the session INSIDE the lock and skips its own
|
|
157
|
+
* refresh if a sibling request already refreshed it while this one was waiting.
|
|
158
|
+
*/
|
|
159
|
+
withIdpSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
|
|
100
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Why `AuthStore.revokeFamily` (above) is revoking a family — AK-20, gap 2327. Lets a
|
|
163
|
+
* consumer's own `AuthStore` implementation write an audit record (e.g.
|
|
164
|
+
* media-planner's reuse-detection log, which records how long after rotation the reuse
|
|
165
|
+
* was observed and the requesting IP) without `../plugin.ts` knowing anything about that
|
|
166
|
+
* consumer's audit schema.
|
|
167
|
+
*/
|
|
168
|
+
export type RevokeFamilyReason = {
|
|
169
|
+
/** A refresh token already rotated away was presented again — either a genuine
|
|
170
|
+
* replay/leak, or a same-cookie-jar tab's request that arrived too late to be
|
|
171
|
+
* treated as a race (see `RefreshTokenRow.rotated`'s doc comment and
|
|
172
|
+
* `FlytedeskAuthOptions.refreshReuseGraceMs`). */
|
|
173
|
+
type: "reuse_detected";
|
|
174
|
+
/** The refresh token row that was replayed, exactly as `findRefreshTokenByHash`
|
|
175
|
+
* read it. */
|
|
176
|
+
replayedToken: RefreshTokenRow;
|
|
177
|
+
/** Milliseconds between `replayedToken`'s rotation and this replay. */
|
|
178
|
+
sinceRotatedMs: number;
|
|
179
|
+
ip: string;
|
|
180
|
+
userAgent: string | null;
|
|
181
|
+
} | {
|
|
182
|
+
/** POST {routePrefix}/logout — the family is revoked unconditionally as part of
|
|
183
|
+
* ending the session, independent of anything else about the request. */
|
|
184
|
+
type: "signed_out";
|
|
185
|
+
} | {
|
|
186
|
+
/** POST {routePrefix}/refresh ran the SAME authorization pipeline
|
|
187
|
+
* `loadAuthedUser` runs (live IdP authorization, `onAuthorizeUser`) and it
|
|
188
|
+
* rejected this session — e.g. a locally-suspended user, a role flytedesk-id
|
|
189
|
+
* since revoked, or the backing IdP session no longer existing at all. */
|
|
190
|
+
type: "authorization_rejected";
|
|
191
|
+
};
|
|
101
192
|
export interface FlytedeskAuthOptions {
|
|
102
193
|
/** This app's OAuth client_id, as registered with flytedesk-id. */
|
|
103
194
|
clientId: string;
|
|
@@ -141,4 +232,64 @@ export interface FlytedeskAuthOptions {
|
|
|
141
232
|
* session is issued — the hook for a consumer's own app-specific bookkeeping (e.g.
|
|
142
233
|
* upserting a local profile row, checking a local suspension flag). */
|
|
143
234
|
onSignIn?: (profile: UserinfoClaims, request: FastifyRequest) => Promise<void> | void;
|
|
235
|
+
/**
|
|
236
|
+
* OAuth scopes requested at `{routePrefix}/login`. Default
|
|
237
|
+
* `["openid", "email", "profile", "offline_access"]` (AK-14, gap 1) — `offline_access`
|
|
238
|
+
* is what makes flytedesk-id's oidc-provider issue a refresh token at all; without it
|
|
239
|
+
* every session dies the moment its ~1h IdP-side access token expires (a
|
|
240
|
+
* previously-shipped production bug — see media-planner's now-superseded
|
|
241
|
+
* `lib/flytedeskId.ts`). Requesting it by default is a deliberate choice, recorded
|
|
242
|
+
* here rather than defaulted into silently: a session that dies after an hour is
|
|
243
|
+
* almost never what a consuming app wants. Override to a narrower list only for a
|
|
244
|
+
* consumer that genuinely wants access-token-lifetime sessions.
|
|
245
|
+
*/
|
|
246
|
+
scopes?: string[];
|
|
247
|
+
/**
|
|
248
|
+
* Bounds EVERY flytedesk-id call this plugin's OIDC client makes — token, userinfo
|
|
249
|
+
* (`/me`), authorization (`/apps/{id}/authorization`), `/me/apps`, discovery and
|
|
250
|
+
* revocation — via `AbortSignal.timeout`, through the one bounded-request helper every
|
|
251
|
+
* one of those calls goes through (AK-20, gap 1; generalized from AK-14 gap 5, which
|
|
252
|
+
* only bounded `/token`). Default `DEFAULT_IDP_REQUEST_TIMEOUT_MS` (5s, matching
|
|
253
|
+
* media-planner's `IDP_TOKEN_TIMEOUT_MS`). A hung flytedesk-id response fails loudly
|
|
254
|
+
* with the typed `IdpTimeoutError` instead of hanging the caller indefinitely — most
|
|
255
|
+
* importantly for the authorization call, which `loadAuthedUser` (`../plugin.ts`) runs
|
|
256
|
+
* on every authenticated request, so a hang there previously hung every signed-in
|
|
257
|
+
* request across every consumer of this package. No alias or deprecated name is kept
|
|
258
|
+
* for the old, narrower `tokenRequestTimeoutMs`.
|
|
259
|
+
*/
|
|
260
|
+
idpRequestTimeoutMs?: number;
|
|
261
|
+
/**
|
|
262
|
+
* How long after a refresh token was rotated a second presentation of it is still
|
|
263
|
+
* treated as a concurrent-tab race rather than reuse (AK-14, gap 3; MP-127; RFC 9700
|
|
264
|
+
* §4.14.2). Two browser tabs share one cookie jar: the loser of a rotation sends the
|
|
265
|
+
* now-stale cookie only if it did so before the winner's `Set-Cookie` reached the
|
|
266
|
+
* browser, so a race's loser can only ever present a token that WAS rotated
|
|
267
|
+
* (`AuthStore.claimRefreshTokenForRotation`'s `rotated: true`), and only within
|
|
268
|
+
* roughly two client request round-trips of that rotation. Default
|
|
269
|
+
* `2 * AUTH_REQUEST_DEADLINE_MS` (`../shared-types.js`) — matching media-planner's
|
|
270
|
+
* `REFRESH_REUSE_GRACE_MS`, which observed a real race's loser arriving 60ms after the
|
|
271
|
+
* winner's rotation (MP-112) — comfortably inside this default. A presentation of a
|
|
272
|
+
* token that was NOT rotated (no successor row references it — e.g. it was revoked
|
|
273
|
+
* directly, by a logout or by reuse detection itself) is ALWAYS treated as reuse,
|
|
274
|
+
* regardless of this window: only a rotation can be a race.
|
|
275
|
+
*/
|
|
276
|
+
refreshReuseGraceMs?: number;
|
|
277
|
+
/**
|
|
278
|
+
* Per-request authorization hook (AK-14, gap 4), run inside `loadAuthedUser` after the
|
|
279
|
+
* user has been resolved from a live IdP session and flytedesk-id's authorization
|
|
280
|
+
* check, but before it's attached to `request.authUser`. Lets a consumer reject a
|
|
281
|
+
* well-formed, still-IdP-authorized session (e.g. media-planner's own
|
|
282
|
+
* `User.suspendedAt` — "a media-planner admin can lock someone out of this app
|
|
283
|
+
* specifically without touching their flytedesk-id account") or enrich/override the
|
|
284
|
+
* resolved user (e.g. substituting a locally-editable `name` for the IdP's) — neither
|
|
285
|
+
* of which is otherwise reachable from outside the plugin, since `onSignIn` only ever
|
|
286
|
+
* fires once, at `/callback` time.
|
|
287
|
+
*
|
|
288
|
+
* Returning `null` rejects the request exactly like an IdP-side authorization failure
|
|
289
|
+
* (`request.authUser` stays unset; `requireAuth` 401s). Returning a user (the same one
|
|
290
|
+
* passed in, or a modified copy) is what gets attached. Throwing, or a rejected
|
|
291
|
+
* promise, is treated as a rejection too (fail loudly, AK-16: logged with context,
|
|
292
|
+
* never a 500) — a hook error must never be indistinguishable from "authenticated".
|
|
293
|
+
*/
|
|
294
|
+
onAuthorizeUser?: (user: AuthedUser, request: FastifyRequest) => Promise<AuthedUser | null> | AuthedUser | null;
|
|
144
295
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flytedesk/app-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Shared platform kit for flytedesk apps: flytedesk-id auth (BFF/OIDC client) and a Postgres-native trace/audit layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"types": "./dist/auth/index.d.ts",
|
|
39
39
|
"import": "./dist/auth/index.js"
|
|
40
40
|
},
|
|
41
|
+
"./auth/plugin": {
|
|
42
|
+
"types": "./dist/auth/plugin.d.ts",
|
|
43
|
+
"import": "./dist/auth/plugin.js"
|
|
44
|
+
},
|
|
41
45
|
"./auth/testing": {
|
|
42
46
|
"types": "./dist/auth/testing/index.d.ts",
|
|
43
47
|
"import": "./dist/auth/testing/index.js"
|