@flytedesk/app-kit 6.0.0 → 7.1.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 +192 -41
- package/dist/auth/client/authSession.d.ts +168 -0
- package/dist/auth/client/authSession.js +581 -0
- package/dist/auth/client/authSession.js.map +1 -0
- package/dist/auth/client/browserEnvironment.d.ts +22 -0
- package/dist/auth/client/browserEnvironment.js +87 -0
- package/dist/auth/client/browserEnvironment.js.map +1 -0
- package/dist/auth/client/httpClient.d.ts +32 -28
- package/dist/auth/client/httpClient.js +91 -84
- package/dist/auth/client/httpClient.js.map +1 -1
- package/dist/auth/client/index.d.ts +15 -8
- package/dist/auth/client/index.js +13 -7
- package/dist/auth/client/index.js.map +1 -1
- package/dist/auth/client/testing.d.ts +6 -0
- package/dist/auth/client/testing.js +20 -0
- package/dist/auth/client/testing.js.map +1 -0
- package/dist/auth/client/useAuthSession.d.ts +54 -91
- package/dist/auth/client/useAuthSession.js +65 -252
- package/dist/auth/client/useAuthSession.js.map +1 -1
- package/dist/auth/index.d.ts +3 -4
- package/dist/auth/index.js +1 -2
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oidc-client.d.ts +18 -4
- package/dist/auth/oidc-client.js +18 -1
- package/dist/auth/oidc-client.js.map +1 -1
- package/dist/auth/plugin.js +244 -225
- package/dist/auth/plugin.js.map +1 -1
- package/dist/auth/returnTarget.d.ts +30 -0
- package/dist/auth/returnTarget.js +48 -0
- package/dist/auth/returnTarget.js.map +1 -0
- package/dist/auth/shared-types.d.ts +18 -41
- package/dist/auth/shared-types.js +18 -45
- package/dist/auth/shared-types.js.map +1 -1
- package/dist/auth/testing/fake-idp.d.ts +29 -1
- package/dist/auth/testing/fake-idp.js +142 -31
- package/dist/auth/testing/fake-idp.js.map +1 -1
- package/dist/auth/types.d.ts +26 -29
- package/dist/bigquery/index.d.ts +1 -1
- package/dist/bigquery/params.d.ts +6 -0
- package/dist/bigquery/params.js +79 -0
- package/dist/bigquery/params.js.map +1 -0
- package/dist/bigquery/query.js +3 -0
- package/dist/bigquery/query.js.map +1 -1
- package/dist/bigquery/types.d.ts +37 -4
- package/package.json +9 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@flytedesk/app-kit/auth/client/testing` — seams for an app's OWN unit tests (AK-35),
|
|
3
|
+
* never for app code. The session's access token and lifecycle are private to
|
|
4
|
+
* `@flytedesk/app-kit/auth/client`; a test that exercises `apiRequest` against a mocked
|
|
5
|
+
* `fetch`, or renders a component under `useAuthSession`, sets them up through here.
|
|
6
|
+
*/
|
|
7
|
+
import { setAccessToken } from "./httpClient.js";
|
|
8
|
+
import { resetAuthSession } from "./browserEnvironment.js";
|
|
9
|
+
/** Makes every following `apiRequest`/`apiUpload`/`apiDownload` send `token` as its
|
|
10
|
+
* Bearer — as if the session had just refreshed. `null` clears it. */
|
|
11
|
+
export function setTestAccessToken(token) {
|
|
12
|
+
setAccessToken(token);
|
|
13
|
+
}
|
|
14
|
+
/** Stops the tab's session lifecycle, forgets its state and clears the access token, so
|
|
15
|
+
* the next test starts from a fresh boot. */
|
|
16
|
+
export function resetAuthSessionForTests() {
|
|
17
|
+
resetAuthSession();
|
|
18
|
+
setAccessToken(null);
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../../../src/auth/client/testing.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAE3D;uEACuE;AACvE,MAAM,UAAU,kBAAkB,CAAC,KAAoB;IACrD,cAAc,CAAC,KAAK,CAAC,CAAC;AACxB,CAAC;AAED;8CAC8C;AAC9C,MAAM,UAAU,wBAAwB;IACtC,gBAAgB,EAAE,CAAC;IACnB,cAAc,CAAC,IAAI,CAAC,CAAC;AACvB,CAAC"}
|
|
@@ -1,100 +1,63 @@
|
|
|
1
|
-
import { AuthUnavailableError } from "./httpClient.js";
|
|
2
|
-
import { type AppSwitcherEntry } from "../shared-types.js";
|
|
3
|
-
export type AuthStatus = "loading" | "authenticated" | "unauthenticated" | "unavailable" | "error";
|
|
4
|
-
/** First delay before re-verifying a session the server could not verify (AK-23). */
|
|
5
|
-
export declare const UNAVAILABLE_RETRY_BASE_MS = 1000;
|
|
6
|
-
/** The backoff's ceiling: an outage is retried every this-many ms at most. */
|
|
7
|
-
export declare const UNAVAILABLE_RETRY_MAX_MS = 30000;
|
|
8
1
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
2
|
+
* The React face of the flytedesk ID session lifecycle (AK-35) — a view of the ONE
|
|
3
|
+
* per-tab `authSession` (`./browserEnvironment.ts`), never a lifecycle of its own: any
|
|
4
|
+
* number of components can call `useAuthSession()` and there is still exactly one
|
|
5
|
+
* bootstrap, one check timer, one sign-in. Everything it shows, and when, is decided in
|
|
6
|
+
* `./authSession.ts`; an app only renders.
|
|
7
|
+
*
|
|
8
|
+
* The app's one piece of wiring is `SessionBoundary`: its shell for a usable session,
|
|
9
|
+
* the design system's `SessionScreen` for everything else —
|
|
10
|
+
*
|
|
11
|
+
* <SessionBoundary<AuthedUserDto>
|
|
12
|
+
* screen={(state) => <SessionScreen {...state} productName="SMS" />}
|
|
13
|
+
* >
|
|
14
|
+
* {(session) => <Shell session={session} />}
|
|
15
|
+
* </SessionBoundary>
|
|
16
|
+
*
|
|
17
|
+
* `@flytedesk/app-kit` never imports the design system, and the design system never
|
|
18
|
+
* imports this package: `SessionScreenState` is a plain shape `SessionScreen` accepts,
|
|
19
|
+
* checked structurally at the app's call site.
|
|
15
20
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
* `logout_store_failed` branch below). A caller showing a pending state on this
|
|
22
|
-
* promise (as a design system's AccountMenu typically does) gets an honest failure
|
|
23
|
-
* instead of silence. */
|
|
21
|
+
import * as React from "react";
|
|
22
|
+
import type { AuthenticatedState, AuthSessionState, UnavailableState } from "./authSession.js";
|
|
23
|
+
export interface AuthSessionActions {
|
|
24
|
+
/** Explicit sign-out — see `AuthSessionController.signOut`. Navigates away on
|
|
25
|
+
* success; rejects when the server did not end the session. */
|
|
24
26
|
signOut: () => Promise<void>;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* The "All flytedesk apps" switcher feed is decoration, not the session (AK-23, N2): it
|
|
32
|
-
* never decides the status. When `GET {routePrefix}/apps` fails, `apps` is `[]` and
|
|
33
|
-
* `appsError` carries the failure (for an optional "couldn't load apps" hint); the session
|
|
34
|
-
* still becomes "authenticated". The next successful load clears it.
|
|
35
|
-
*/
|
|
36
|
-
interface AppsFeed {
|
|
37
|
-
apps: AppSwitcherEntry[];
|
|
38
|
-
appsError: Error | null;
|
|
27
|
+
/** The failure states' "Try again" — see `AuthSessionController.retry`. */
|
|
28
|
+
retry: () => void;
|
|
29
|
+
/** Re-reads the signed-in user after the app changed it server-side. */
|
|
30
|
+
refreshUser: () => Promise<void>;
|
|
39
31
|
}
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
error: null;
|
|
45
|
-
}
|
|
46
|
-
/** "authenticated" always carries a user — there is no way to construct this variant
|
|
47
|
-
* without one, unlike the pre-AK-18 `{ status: AuthStatus; user: TUser | null }` shape
|
|
48
|
-
* that forced every consumer to handle an impossible `authenticated && !user` state by
|
|
49
|
-
* hand (sms-app's App.tsx, before this card). */
|
|
50
|
-
export interface AuthenticatedSessionState<TUser> extends AuthSessionActions, AppsFeed {
|
|
51
|
-
status: "authenticated";
|
|
32
|
+
export type AuthSession<TUser> = AuthSessionState<TUser> & AuthSessionActions;
|
|
33
|
+
/** A session an app can render its shell for: signed in, or an outage that still has a
|
|
34
|
+
* last-known user to show (display-only — see `UnavailableState`). */
|
|
35
|
+
export type UsableSession<TUser> = (AuthenticatedState<TUser> | (UnavailableState<TUser> & {
|
|
52
36
|
user: TUser;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
error: null;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* AK-23: the server could not verify the session right now — flytedesk-id or the app's
|
|
63
|
-
* AuthStore is down (an `AuthUnavailableError`, a 503). NOT signed out: the session is
|
|
64
|
-
* kept (`user` is the last verified user, or null if none was verified yet) and the hook
|
|
65
|
-
* re-verifies it by itself, with exponential backoff (`retryInMs` until the next try,
|
|
66
|
-
* capped at UNAVAILABLE_RETRY_MAX_MS), until the server answers — then it moves to
|
|
67
|
-
* "authenticated", or to "unauthenticated" only if the server actually says so. Entered
|
|
68
|
-
* from the bootstrap, or from "authenticated" when any request made through this
|
|
69
|
-
* client hits the outage — including the automatic refresh reaching no decision at all
|
|
70
|
-
* (no answer, no answer in time, or an infrastructure 502/503/504: `AUTH_UNREACHABLE_CODE`).
|
|
71
|
-
* Render it as "reconnecting", never as a sign-in screen.
|
|
72
|
-
*
|
|
73
|
-
* `user` is DISPLAY-ONLY (AK-23, L-c): it is who was signed in when the outage began, kept
|
|
74
|
-
* so the page can keep showing them while it reconnects. It is not a verified identity
|
|
75
|
-
* right now — no authorization decision (showing an admin-only control, enabling a
|
|
76
|
-
* destructive action) may be based on it; the server re-checks every request anyway.
|
|
77
|
-
*/
|
|
78
|
-
export interface UnavailableSessionState<TUser> extends AuthSessionActions, AppsFeed {
|
|
79
|
-
status: "unavailable";
|
|
80
|
-
user: TUser | null;
|
|
81
|
-
error: AuthUnavailableError;
|
|
82
|
-
retryInMs: number;
|
|
37
|
+
})) & AuthSessionActions;
|
|
38
|
+
export type SessionScreenKind = "signing-in" | "session-expired" | "signing-out" | "reconnecting" | "sign-in-failed" | "load-failed";
|
|
39
|
+
/** What the design system's `SessionScreen` renders for every non-usable state. `error`
|
|
40
|
+
* and `onRetry` are set exactly for the two failure kinds. */
|
|
41
|
+
export interface SessionScreenState {
|
|
42
|
+
kind: SessionScreenKind;
|
|
43
|
+
error: string | null;
|
|
44
|
+
onRetry: (() => void) | null;
|
|
83
45
|
}
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
export
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
46
|
+
/** The tab's session, re-rendering on every change. `TUser` is the shape `GET
|
|
47
|
+
* {routePrefix}/me` answers for this app (e.g. `AuthedUserDto`). */
|
|
48
|
+
export declare function useAuthSession<TUser>(): AuthSession<TUser>;
|
|
49
|
+
/** The screen for a state the app does not render itself, or `null` when the session is
|
|
50
|
+
* usable. The one mapping from lifecycle state to what the user sees. */
|
|
51
|
+
export declare function sessionScreenFor<TUser>(state: AuthSessionState<TUser>): SessionScreenState | null;
|
|
52
|
+
export interface SessionBoundaryProps<TUser> {
|
|
53
|
+
/** The app, for a usable session. */
|
|
54
|
+
children: (session: UsableSession<TUser>) => React.ReactNode;
|
|
55
|
+
/** Everything else — pass the design system's `SessionScreen`. */
|
|
56
|
+
screen: (state: SessionScreenState) => React.ReactNode;
|
|
91
57
|
}
|
|
92
|
-
export type AuthSessionState<TUser> = LoadingSessionState | AuthenticatedSessionState<TUser> | UnauthenticatedSessionState | UnavailableSessionState<TUser> | ErrorSessionState;
|
|
93
58
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* it explicitly (`useAuthSession<MyUser>(...)`) rather than this module guessing.
|
|
59
|
+
* Renders the app only while the session is usable. In every other state the app is
|
|
60
|
+
* UNMOUNTED — no data panel can fail on its own with an auth error, and nothing it runs
|
|
61
|
+
* (a poll, a timer) keeps calling the api for a session that is over.
|
|
98
62
|
*/
|
|
99
|
-
export declare function
|
|
100
|
-
export {};
|
|
63
|
+
export declare function SessionBoundary<TUser>({ children, screen, }: SessionBoundaryProps<TUser>): React.ReactNode;
|
|
@@ -1,261 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* either side. Every consuming app was on track to re-implement this by hand (sms-app's
|
|
8
|
-
* apps/web/src/session/useAuthSession.ts, before this card) — extracting it here (AK-16)
|
|
9
|
-
* means every one gets the StrictMode-safe single-flight bootstrap and the fail-loudly
|
|
10
|
-
* sign-out for free, instead of each app getting a slightly different (and possibly
|
|
11
|
-
* wrong) version of the same ~80 lines.
|
|
2
|
+
* The React face of the flytedesk ID session lifecycle (AK-35) — a view of the ONE
|
|
3
|
+
* per-tab `authSession` (`./browserEnvironment.ts`), never a lifecycle of its own: any
|
|
4
|
+
* number of components can call `useAuthSession()` and there is still exactly one
|
|
5
|
+
* bootstrap, one check timer, one sign-in. Everything it shows, and when, is decided in
|
|
6
|
+
* `./authSession.ts`; an app only renders.
|
|
12
7
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
8
|
+
* The app's one piece of wiring is `SessionBoundary`: its shell for a usable session,
|
|
9
|
+
* the design system's `SessionScreen` for everything else —
|
|
10
|
+
*
|
|
11
|
+
* <SessionBoundary<AuthedUserDto>
|
|
12
|
+
* screen={(state) => <SessionScreen {...state} productName="SMS" />}
|
|
13
|
+
* >
|
|
14
|
+
* {(session) => <Shell session={session} />}
|
|
15
|
+
* </SessionBoundary>
|
|
16
|
+
*
|
|
17
|
+
* `@flytedesk/app-kit` never imports the design system, and the design system never
|
|
18
|
+
* imports this package: `SessionScreenState` is a plain shape `SessionScreen` accepts,
|
|
19
|
+
* checked structurally at the app's call site.
|
|
18
20
|
*/
|
|
19
21
|
import * as React from "react";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*/
|
|
34
|
-
export function
|
|
35
|
-
|
|
36
|
-
|
|
22
|
+
import { authSession } from "./browserEnvironment.js";
|
|
23
|
+
const actions = {
|
|
24
|
+
signOut: () => authSession.signOut(),
|
|
25
|
+
retry: () => authSession.retry(),
|
|
26
|
+
refreshUser: () => authSession.refreshUser(),
|
|
27
|
+
};
|
|
28
|
+
/** The tab's session, re-rendering on every change. `TUser` is the shape `GET
|
|
29
|
+
* {routePrefix}/me` answers for this app (e.g. `AuthedUserDto`). */
|
|
30
|
+
export function useAuthSession() {
|
|
31
|
+
const state = React.useSyncExternalStore(authSession.subscribe, () => authSession.getSnapshot(), () => authSession.getSnapshot());
|
|
32
|
+
return React.useMemo(() => ({ ...state, ...actions }), [state]);
|
|
33
|
+
}
|
|
34
|
+
/** The screen for a state the app does not render itself, or `null` when the session is
|
|
35
|
+
* usable. The one mapping from lifecycle state to what the user sees. */
|
|
36
|
+
export function sessionScreenFor(state) {
|
|
37
|
+
switch (state.status) {
|
|
38
|
+
case "authenticated":
|
|
39
|
+
return null;
|
|
40
|
+
case "unavailable":
|
|
41
|
+
return state.user === null
|
|
42
|
+
? { kind: "reconnecting", error: null, onRetry: null }
|
|
43
|
+
: null;
|
|
44
|
+
case "checking":
|
|
45
|
+
return { kind: "signing-in", error: null, onRetry: null };
|
|
46
|
+
case "signing-in":
|
|
47
|
+
return {
|
|
48
|
+
kind: state.reason === "expired" ? "session-expired" : "signing-in",
|
|
49
|
+
error: null,
|
|
50
|
+
onRetry: null,
|
|
51
|
+
};
|
|
52
|
+
case "signing-out":
|
|
53
|
+
return { kind: "signing-out", error: null, onRetry: null };
|
|
54
|
+
case "error":
|
|
55
|
+
return {
|
|
56
|
+
kind: state.kind,
|
|
57
|
+
error: state.error.message,
|
|
58
|
+
onRetry: actions.retry,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
37
61
|
}
|
|
38
|
-
const ME_PATH = authRoutePath(AUTH_ROUTE_PREFIX, "me");
|
|
39
|
-
const APPS_PATH = authRoutePath(AUTH_ROUTE_PREFIX, "apps");
|
|
40
|
-
const LOGOUT_PATH = authRoutePath(AUTH_ROUTE_PREFIX, "logout");
|
|
41
62
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* it explicitly (`useAuthSession<MyUser>(...)`) rather than this module guessing.
|
|
63
|
+
* Renders the app only while the session is usable. In every other state the app is
|
|
64
|
+
* UNMOUNTED — no data panel can fail on its own with an auth error, and nothing it runs
|
|
65
|
+
* (a poll, a timer) keeps calling the api for a session that is over.
|
|
46
66
|
*/
|
|
47
|
-
export function
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const [unavailable, setUnavailable] = React.useState(null);
|
|
54
|
-
const [generation, setGeneration] = React.useState(0);
|
|
55
|
-
// POST {routePrefix}/refresh ROTATES the refresh cookie on every call (reuse detection
|
|
56
|
-
// — see @flytedesk/app-kit/auth's plugin.ts) — a second call presenting the same,
|
|
57
|
-
// now-already-consumed cookie is treated as token replay and revokes the whole
|
|
58
|
-
// session. React 18+'s <StrictMode> intentionally double-invokes this effect in dev
|
|
59
|
-
// (mount -> cleanup -> mount, on the SAME fiber, so this ref survives it) with the
|
|
60
|
-
// SAME `generation`, which without a guard fires two concurrent refresh calls
|
|
61
|
-
// against that one cookie: the loser gets "token_reuse_detected" and this hook
|
|
62
|
-
// bounces to "unauthenticated" for no signed-out reason at all. Tracking which
|
|
63
|
-
// generation has already started a load — rather than an unconditional `cancelled`
|
|
64
|
-
// flag — is what makes the second StrictMode invocation a no-op while still letting
|
|
65
|
-
// `refetch()` (which bumps `generation`) start a genuinely new load.
|
|
66
|
-
const startedGenerationRef = React.useRef(null);
|
|
67
|
-
// The load/retry machinery, created once per mounted hook (lazy useState initializer:
|
|
68
|
-
// guaranteed stable, unlike useMemo). It only touches state setters, which React keeps
|
|
69
|
-
// stable, and its own mutable fields.
|
|
70
|
-
const [session] = React.useState(() => {
|
|
71
|
-
const engine = {
|
|
72
|
-
/** Mirrors `status`, readable from outside a render (the outage listener). */
|
|
73
|
-
status: "loading",
|
|
74
|
-
/** Whether the hook is mounted — a retry never fires into an unmounted one. */
|
|
75
|
-
active: false,
|
|
76
|
-
retryTimer: null,
|
|
77
|
-
retryAttempt: 0,
|
|
78
|
-
/**
|
|
79
|
-
* AK-23 (L-a): which load may still change state. `signOut` and every fresh load
|
|
80
|
-
* (the bootstrap, `refetch`) bump it; a load that finds the epoch moved on since it
|
|
81
|
-
* started is stale and drops its answer — so a retry still in flight can neither
|
|
82
|
-
* put a signed-out session back into "unavailable" nor overwrite a newer load.
|
|
83
|
-
*/
|
|
84
|
-
epoch: 0,
|
|
85
|
-
setStatus(next) {
|
|
86
|
-
engine.status = next;
|
|
87
|
-
setStatus(next);
|
|
88
|
-
},
|
|
89
|
-
clearRetry() {
|
|
90
|
-
if (engine.retryTimer !== null)
|
|
91
|
-
clearTimeout(engine.retryTimer);
|
|
92
|
-
engine.retryTimer = null;
|
|
93
|
-
},
|
|
94
|
-
/** Ends every load in flight and any pending retry — their answers are ignored —
|
|
95
|
-
* and returns the new epoch. */
|
|
96
|
-
supersede() {
|
|
97
|
-
engine.clearRetry();
|
|
98
|
-
engine.epoch += 1;
|
|
99
|
-
return engine.epoch;
|
|
100
|
-
},
|
|
101
|
-
/**
|
|
102
|
-
* Refresh -> me -> apps. `fresh` (a new bootstrap) supersedes any load in flight and
|
|
103
|
-
* shows "loading"; a retry keeps whatever the user is looking at until it has an
|
|
104
|
-
* answer.
|
|
105
|
-
*
|
|
106
|
-
* The refresh is httpClient's shared, single-flighted `refreshAccessToken`, so a
|
|
107
|
-
* load started while another one (or any 401-triggered refresh) is waiting on
|
|
108
|
-
* `POST /refresh` JOINS that request instead of presenting the same rotating cookie
|
|
109
|
-
* a second time. `/me` and `/apps` skip the 401 -> refresh -> recovery dance: right
|
|
110
|
-
* after a refresh, a 401 is the answer itself, and a load must never start the
|
|
111
|
-
* app's auth recovery (which could sign a user back in after `signOut`).
|
|
112
|
-
*/
|
|
113
|
-
async load(fresh) {
|
|
114
|
-
const epoch = fresh ? engine.supersede() : engine.epoch;
|
|
115
|
-
const current = () => epoch === engine.epoch;
|
|
116
|
-
if (fresh) {
|
|
117
|
-
engine.retryAttempt = 0;
|
|
118
|
-
setError(null);
|
|
119
|
-
setUnavailable(null);
|
|
120
|
-
engine.setStatus("loading");
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
const accessToken = await refreshAccessToken();
|
|
124
|
-
if (!current())
|
|
125
|
-
return;
|
|
126
|
-
if (accessToken === null) {
|
|
127
|
-
engine.settleSignedOut();
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
const me = await apiRequest(ME_PATH, { skipAuthRetry: true });
|
|
131
|
-
if (!current())
|
|
132
|
-
return;
|
|
133
|
-
const feed = await engine.loadApps();
|
|
134
|
-
if (!current())
|
|
135
|
-
return;
|
|
136
|
-
setUser(me.user);
|
|
137
|
-
setApps(feed.apps);
|
|
138
|
-
setAppsError(feed.error);
|
|
139
|
-
engine.retryAttempt = 0;
|
|
140
|
-
setUnavailable(null);
|
|
141
|
-
engine.setStatus("authenticated");
|
|
142
|
-
}
|
|
143
|
-
catch (err) {
|
|
144
|
-
if (!current())
|
|
145
|
-
return;
|
|
146
|
-
if (err instanceof AuthUnavailableError) {
|
|
147
|
-
engine.scheduleRetry(err);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
if (err instanceof ApiRequestError && err.status === 401) {
|
|
151
|
-
engine.settleSignedOut();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
engine.retryAttempt = 0;
|
|
155
|
-
setUnavailable(null);
|
|
156
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
157
|
-
engine.setStatus("error");
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
/** The switcher feed, non-blocking (AK-23, N2): a failure is `apps: []` plus the
|
|
161
|
-
* error, never a failed load. */
|
|
162
|
-
async loadApps() {
|
|
163
|
-
try {
|
|
164
|
-
const res = await apiRequest(APPS_PATH, { skipAuthRetry: true });
|
|
165
|
-
return { apps: res.apps, error: null };
|
|
166
|
-
}
|
|
167
|
-
catch (err) {
|
|
168
|
-
return { apps: [], error: err instanceof Error ? err : new Error(String(err)) };
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
/** The server said this browser has no session: local state follows. */
|
|
172
|
-
settleSignedOut() {
|
|
173
|
-
engine.clearRetry();
|
|
174
|
-
engine.retryAttempt = 0;
|
|
175
|
-
setAccessToken(null);
|
|
176
|
-
setUser(null);
|
|
177
|
-
setApps([]);
|
|
178
|
-
setAppsError(null);
|
|
179
|
-
setUnavailable(null);
|
|
180
|
-
engine.setStatus("unauthenticated");
|
|
181
|
-
},
|
|
182
|
-
/** Enters (or stays in) "unavailable" and re-verifies after the next backoff delay.
|
|
183
|
-
* One pending retry at a time: a burst of failing requests schedules one. */
|
|
184
|
-
scheduleRetry(err) {
|
|
185
|
-
if (!engine.active || engine.retryTimer !== null)
|
|
186
|
-
return;
|
|
187
|
-
const retryInMs = unavailableRetryDelayMs(engine.retryAttempt);
|
|
188
|
-
engine.retryAttempt += 1;
|
|
189
|
-
setUnavailable({ error: err, retryInMs });
|
|
190
|
-
engine.setStatus("unavailable");
|
|
191
|
-
engine.retryTimer = setTimeout(() => {
|
|
192
|
-
engine.retryTimer = null;
|
|
193
|
-
void engine.load(false);
|
|
194
|
-
}, retryInMs);
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
return engine;
|
|
198
|
-
});
|
|
199
|
-
React.useEffect(() => {
|
|
200
|
-
session.active = true;
|
|
201
|
-
// An ordinary request (anywhere in the app) hitting an outage while signed in: keep
|
|
202
|
-
// the user, show "unavailable", and re-verify — never treat it as a sign-out.
|
|
203
|
-
const unsubscribe = onAuthUnavailable((err) => {
|
|
204
|
-
if (session.status === "authenticated")
|
|
205
|
-
session.scheduleRetry(err);
|
|
206
|
-
});
|
|
207
|
-
return () => {
|
|
208
|
-
session.active = false;
|
|
209
|
-
session.clearRetry();
|
|
210
|
-
unsubscribe();
|
|
211
|
-
};
|
|
212
|
-
}, [session]);
|
|
213
|
-
React.useEffect(() => {
|
|
214
|
-
if (startedGenerationRef.current === generation)
|
|
215
|
-
return;
|
|
216
|
-
startedGenerationRef.current = generation;
|
|
217
|
-
void session.load(true);
|
|
218
|
-
}, [generation, session]);
|
|
219
|
-
const signOut = React.useCallback(async () => {
|
|
220
|
-
// Whatever load or retry is in flight belongs to the session being ended: its answer
|
|
221
|
-
// must never land after this one (AK-23, L-a).
|
|
222
|
-
session.supersede();
|
|
223
|
-
try {
|
|
224
|
-
await apiRequest(LOGOUT_PATH, { method: "POST" });
|
|
225
|
-
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
if (err instanceof ApiRequestError && err.code === "logout_store_failed") {
|
|
228
|
-
// The plugin clears the refresh cookie on EVERY logout outcome (AK-23, in a
|
|
229
|
-
// `finally`) and attempts each AuthStore step independently, so this browser
|
|
230
|
-
// no longer holds a usable session no matter which step failed here (plugin.ts's
|
|
231
|
-
// POST {routePrefix}/logout). Leaving local state at
|
|
232
|
-
// "authenticated" here would be exactly the bug the SMS-5 gate found (the design
|
|
233
|
-
// system's AccountMenu swallowing this rejection and showing nothing): a UI
|
|
234
|
-
// claiming a live session the server has already revoked. Clearing local state
|
|
235
|
-
// mirrors the server's actual state; re-throwing still fails loudly so a caller
|
|
236
|
-
// can surface "sign-out did not fully complete" rather than reporting a clean one.
|
|
237
|
-
session.settleSignedOut();
|
|
238
|
-
}
|
|
239
|
-
throw err;
|
|
240
|
-
}
|
|
241
|
-
session.settleSignedOut();
|
|
242
|
-
}, [session]);
|
|
243
|
-
const refetch = React.useCallback(() => setGeneration((g) => g + 1), []);
|
|
244
|
-
const actions = { signOut, refetch };
|
|
245
|
-
if (status === "authenticated") {
|
|
246
|
-
// Invariant: the only place status flips to "authenticated" is the line right after
|
|
247
|
-
// setUser(me.user) above, in the same synchronous pass — so by the time a render
|
|
248
|
-
// observes status === "authenticated", `user` is never null.
|
|
249
|
-
return { ...actions, status, user: user, apps, appsError, error: null };
|
|
250
|
-
}
|
|
251
|
-
if (status === "unavailable" && unavailable) {
|
|
252
|
-
return { ...actions, status, user, apps, appsError, error: unavailable.error, retryInMs: unavailable.retryInMs };
|
|
253
|
-
}
|
|
254
|
-
if (status === "error") {
|
|
255
|
-
return { ...actions, status, user: null, apps, appsError: null, error: error ?? new Error("unknown error") };
|
|
256
|
-
}
|
|
257
|
-
if (status === "loading")
|
|
258
|
-
return { ...actions, status, user: null, apps, appsError: null, error: null };
|
|
259
|
-
return { ...actions, status: "unauthenticated", user: null, apps, appsError: null, error: null };
|
|
67
|
+
export function SessionBoundary({ children, screen, }) {
|
|
68
|
+
const session = useAuthSession();
|
|
69
|
+
const shown = sessionScreenFor(session);
|
|
70
|
+
if (shown !== null)
|
|
71
|
+
return screen(shown);
|
|
72
|
+
return children(session);
|
|
260
73
|
}
|
|
261
74
|
//# sourceMappingURL=useAuthSession.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAuthSession.js","sourceRoot":"","sources":["../../../src/auth/client/useAuthSession.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"useAuthSession.js","sourceRoot":"","sources":["../../../src/auth/client/useAuthSession.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAM/B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAqCtD,MAAM,OAAO,GAAuB;IAClC,OAAO,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,OAAO,EAAE;IACpC,KAAK,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE;IAChC,WAAW,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,WAAW,EAAE;CAC7C,CAAC;AAEF;qEACqE;AACrE,MAAM,UAAU,cAAc;IAC5B,MAAM,KAAK,GAAG,KAAK,CAAC,oBAAoB,CACtC,WAAW,CAAC,SAAS,EACrB,GAAG,EAAE,CAAC,WAAW,CAAC,WAAW,EAAS,EACtC,GAAG,EAAE,CAAC,WAAW,CAAC,WAAW,EAAS,CACvC,CAAC;IACF,OAAO,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,GAAG,OAAO,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;0EAC0E;AAC1E,MAAM,UAAU,gBAAgB,CAC9B,KAA8B;IAE9B,QAAQ,KAAK,CAAC,MAAM,EAAE,CAAC;QACrB,KAAK,eAAe;YAClB,OAAO,IAAI,CAAC;QACd,KAAK,aAAa;YAChB,OAAO,KAAK,CAAC,IAAI,KAAK,IAAI;gBACxB,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE;gBACtD,CAAC,CAAC,IAAI,CAAC;QACX,KAAK,UAAU;YACb,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC5D,KAAK,YAAY;YACf,OAAO;gBACL,IAAI,EAAE,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,YAAY;gBACnE,KAAK,EAAE,IAAI;gBACX,OAAO,EAAE,IAAI;aACd,CAAC;QACJ,KAAK,aAAa;YAChB,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC7D,KAAK,OAAO;YACV,OAAO;gBACL,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,OAAO;gBAC1B,OAAO,EAAE,OAAO,CAAC,KAAK;aACvB,CAAC;IACN,CAAC;AACH,CAAC;AASD;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAQ,EACrC,QAAQ,EACR,MAAM,GACsB;IAC5B,MAAM,OAAO,GAAG,cAAc,EAAS,CAAC;IACxC,MAAM,KAAK,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;IACxC,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;IACzC,OAAO,QAAQ,CAAC,OAA+B,CAAC,CAAC;AACnD,CAAC"}
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
* redirectUri: "https://my-app.flytedesk.com/api/v1/auth/callback",
|
|
35
35
|
* appId: "my-app",
|
|
36
36
|
* postLoginRedirect: "https://my-app.flytedesk.com/",
|
|
37
|
-
* signInUrl: "https://my-app.flytedesk.com/sign-in",
|
|
38
37
|
* cookieSecret: process.env.COOKIE_SECRET!,
|
|
39
38
|
* secureCookies: process.env.NODE_ENV === "production",
|
|
40
39
|
* accessTokenSecret: process.env.JWT_SECRET!,
|
|
@@ -44,11 +43,11 @@
|
|
|
44
43
|
* app.get("/me", { preHandler: requireAuth }, async (request) => request.authUser);
|
|
45
44
|
*/
|
|
46
45
|
export { requireAuth, requirePermission } from "./guards.js";
|
|
47
|
-
export type { AuthedUser, AuthStore, CreateIdpSessionInput, CreateRefreshTokenInput, FlytedeskAuthOptions, IdpRole, IdpSessionLockedStore, IdpSessionRow, RefreshTokenRevocation, RefreshTokenRevocationReason, RefreshTokenRow, RevokeFamilyReason,
|
|
46
|
+
export type { AuthedUser, AuthStore, CreateIdpSessionInput, CreateRefreshTokenInput, FlytedeskAuthOptions, IdpRole, IdpSessionLockedStore, IdpSessionRow, RefreshTokenRevocation, RefreshTokenRevocationReason, RefreshTokenRow, RevokeFamilyReason, UpdateIdpSessionInput, UserinfoClaims, } from "./types.js";
|
|
48
47
|
export type { AppSwitcherEntry, IdpAuthorization, IdpTokenResponse, IdTokenClaims, OidcClient, OidcClientOptions, } from "./oidc-client.js";
|
|
49
48
|
export { createOidcClient, DEFAULT_OIDC_SCOPES, DEFAULT_IDP_REQUEST_TIMEOUT_MS } from "./oidc-client.js";
|
|
50
|
-
export type { ApiErrorBody, AuthUnavailableCode,
|
|
51
|
-
export { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, AUTH_ROUTE_SEGMENTS, AUTH_UNAVAILABLE_CODES, authRoutePath,
|
|
49
|
+
export type { ApiErrorBody, AuthUnavailableCode, SignInErrorCode, } from "./shared-types.js";
|
|
50
|
+
export { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, AUTH_ROUTE_SEGMENTS, AUTH_UNAVAILABLE_CODES, authRoutePath, SIGNIN_ERROR_QUERY_PARAM, SIGNIN_ERROR_CODES, type AuthRouteSegment, } from "./shared-types.js";
|
|
52
51
|
export { IdpError, IdpUnavailableError, IdpTimeoutError, IdpAccessRevokedError, AuthStoreError, OidcHttpError, OidcTokenError, classifyIdpError, } from "./errors.js";
|
|
53
52
|
export { generatePkce, randomState, type PkcePair } from "./pkce.js";
|
|
54
53
|
export { generateRefreshToken, hashRefreshToken, signAccessToken, verifyAccessToken, type AccessTokenClaims, } from "./tokens.js";
|
package/dist/auth/index.js
CHANGED
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
* redirectUri: "https://my-app.flytedesk.com/api/v1/auth/callback",
|
|
35
35
|
* appId: "my-app",
|
|
36
36
|
* postLoginRedirect: "https://my-app.flytedesk.com/",
|
|
37
|
-
* signInUrl: "https://my-app.flytedesk.com/sign-in",
|
|
38
37
|
* cookieSecret: process.env.COOKIE_SECRET!,
|
|
39
38
|
* secureCookies: process.env.NODE_ENV === "production",
|
|
40
39
|
* accessTokenSecret: process.env.JWT_SECRET!,
|
|
@@ -45,7 +44,7 @@
|
|
|
45
44
|
*/
|
|
46
45
|
export { requireAuth, requirePermission } from "./guards.js";
|
|
47
46
|
export { createOidcClient, DEFAULT_OIDC_SCOPES, DEFAULT_IDP_REQUEST_TIMEOUT_MS } from "./oidc-client.js";
|
|
48
|
-
export { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, AUTH_ROUTE_SEGMENTS, AUTH_UNAVAILABLE_CODES, authRoutePath,
|
|
47
|
+
export { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, AUTH_ROUTE_SEGMENTS, AUTH_UNAVAILABLE_CODES, authRoutePath, SIGNIN_ERROR_QUERY_PARAM, SIGNIN_ERROR_CODES, } from "./shared-types.js";
|
|
49
48
|
export { IdpError, IdpUnavailableError, IdpTimeoutError, IdpAccessRevokedError, AuthStoreError, OidcHttpError, OidcTokenError, classifyIdpError, } from "./errors.js";
|
|
50
49
|
export { generatePkce, randomState } from "./pkce.js";
|
|
51
50
|
export { generateRefreshToken, hashRefreshToken, signAccessToken, verifyAccessToken, } from "./tokens.js";
|
package/dist/auth/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA2B7D,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,8BAA8B,EAAE,MAAM,kBAAkB,CAAC;AAOzG,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,mBAAmB,EACnB,sBAAsB,EACtB,aAAa,EACb,wBAAwB,EACxB,kBAAkB,GAEnB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,eAAe,EACf,qBAAqB,EACrB,cAAc,EACd,aAAa,EACb,cAAc,EACd,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,YAAY,EAAE,WAAW,EAAiB,MAAM,WAAW,CAAC;AAErE,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EACf,iBAAiB,GAElB,MAAM,aAAa,CAAC"}
|