@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.
@@ -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
- /** Reuse-detection path: revoke every row sharing this familyId. */
93
- revokeFamily(familyId: string): Promise<void>;
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.8.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"