Skip to content

Commit fce4d73

Browse files
committed
Auto refresh of user entitlements every 5 - 15 mins so don't need to
relogin
1 parent 4679a2f commit fce4d73

7 files changed

Lines changed: 88 additions & 10 deletions

File tree

apps/api-manager/src/hooks.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,5 +332,7 @@ declare module "svelte-kit-sessions" {
332332
authInfo?: {
333333
authenticated: boolean;
334334
};
335+
/** Epoch ms of the last /users/current fetch (set at login and by the layout's silent refresh) */
336+
userRefreshedAt?: number;
335337
}
336338
}

apps/api-manager/src/lib/utils/roleChecker.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,11 @@ const JIT_EXCLUDED_ROLES = new Set([
470470
* - User holds CanCreateEntitlementAtAnyBank (system-wide), OR
471471
* CanCreateEntitlementAtOneBank for the relevant bank (bank-scoped roles)
472472
* - The missing role is not one of the excluded meta-roles
473+
*
474+
* Only real (database) entitlements count as grant-capable. /users/current also
475+
* returns virtual entitlements (super_admin_user_ids / oidc_operator_user_ids props)
476+
* with an empty entitlement_id; the OBP JIT grant logic only honours real
477+
* entitlements, so counting virtual ones here would promise a grant that never happens.
473478
*/
474479
function canJitGrant(
475480
requirement: RoleRequirement,
@@ -478,8 +483,10 @@ function canJitGrant(
478483
): boolean {
479484
if (JIT_EXCLUDED_ROLES.has(requirement.role)) return false;
480485

486+
const realEntitlements = userEntitlements.filter((e) => e.entitlement_id);
487+
481488
// CanCreateEntitlementAtAnyBank covers everything
482-
const hasAnyBank = userEntitlements.some(
489+
const hasAnyBank = realEntitlements.some(
483490
(e) => e.role_name === "CanCreateEntitlementAtAnyBank",
484491
);
485492
if (hasAnyBank) return true;
@@ -488,7 +495,7 @@ function canJitGrant(
488495
if (requirement.bankScoped || requirement.bankId) {
489496
const bankId = requirement.bankId || currentBankId;
490497
if (!bankId) return false;
491-
return userEntitlements.some(
498+
return realEntitlements.some(
492499
(e) => e.role_name === "CanCreateEntitlementAtOneBank" && e.bank_id === bankId,
493500
);
494501
}
Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { LayoutServerLoad } from "./$types";
22

3-
export const load: LayoutServerLoad = async ({ locals }) => {
4-
const session = locals.session;
5-
const userEntitlements =
6-
(session?.data?.user as any)?.entitlements?.list || [];
7-
const userId = (session?.data?.user as any)?.user_id || "";
8-
return { userEntitlements, userId };
3+
// Derive from the root layout load (rather than reading the session directly) so
4+
// this load reruns when invalidate("app:session-user") refreshes the session user.
5+
export const load: LayoutServerLoad = async ({ parent }) => {
6+
const { userEntitlements, userId } = await parent();
7+
return { userEntitlements: userEntitlements || [], userId: userId || "" };
98
};

apps/api-manager/src/routes/+layout.server.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { createLogger } from "@obp/shared/utils";
22
const logger = createLogger("LayoutServer");
3-
import type { RequestEvent } from "@sveltejs/kit";
3+
import type { ServerLoadEvent } from "@sveltejs/kit";
4+
import type { Session } from "svelte-kit-sessions";
45
import { obpIntegrationService } from "$lib/server/opey/OBPIntegrationService";
6+
import { OBP_API_URL } from "$lib/config";
57
import type { OBPConsentInfo } from "$lib/obp/types";
68
// import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom';
79
// import { storePopup } from '@skeletonlabs/skeleton';
@@ -22,10 +24,61 @@ export interface RootLayoutData {
2224
jitEnabled: boolean;
2325
}
2426

25-
export async function load(event: RequestEvent) {
27+
// Session user (incl. entitlements) is a login-time snapshot. Refresh it from
28+
// /users/current when older than this, so granted roles (manual or JIT) show up
29+
// without logout/login. The client layout re-polls via invalidate() every 5 min;
30+
// keeping the base threshold below the poll interval guarantees each poll refreshes.
31+
// Users with very large entitlement lists (JIT accumulation can reach thousands of
32+
// rows) get a several-hundred-KB /users/current response, so back off for them —
33+
// see security_TODO.md "Prune JIT-accumulated entitlements".
34+
const USER_REFRESH_STALE_MS = 4 * 60 * 1000;
35+
const USER_REFRESH_STALE_LARGE_MS = 15 * 60 * 1000;
36+
const LARGE_ENTITLEMENT_COUNT = 500;
37+
38+
async function refreshSessionUser(session: Session) {
39+
const lastRefresh = session.data.userRefreshedAt ?? 0;
40+
const entitlementCount =
41+
(session.data.user as any)?.entitlements?.list?.length ?? 0;
42+
const staleMs =
43+
entitlementCount > LARGE_ENTITLEMENT_COUNT
44+
? USER_REFRESH_STALE_LARGE_MS
45+
: USER_REFRESH_STALE_MS;
46+
if (Date.now() - lastRefresh < staleMs) return;
47+
48+
try {
49+
const response = await fetch(`${OBP_API_URL}/users/current`, {
50+
headers: { Authorization: `Bearer ${session.data.oauth!.access_token}` },
51+
});
52+
if (!response.ok) {
53+
logger.warn(`Failed to refresh /users/current: ${response.status}`);
54+
return;
55+
}
56+
const freshUser = await response.json();
57+
if (!freshUser.user_id) {
58+
logger.warn("Refresh of /users/current returned no user_id, keeping session user");
59+
return;
60+
}
61+
await session.setData({
62+
...session.data,
63+
user: freshUser,
64+
userRefreshedAt: Date.now(),
65+
});
66+
await session.save();
67+
logger.info(
68+
`Refreshed session user from /users/current (${freshUser.entitlements?.list?.length ?? 0} entitlements)`,
69+
);
70+
} catch (error) {
71+
logger.warn("Failed to refresh /users/current, keeping session user:", error);
72+
}
73+
}
74+
75+
export async function load(event: ServerLoadEvent) {
2676
const startTime = performance.now();
2777
logger.info("🚀 Layout server load started");
2878

79+
// Rerun this load (and dependants) when the client calls invalidate("app:session-user")
80+
event.depends("app:session-user");
81+
2982
const { session } = event.locals;
3083

3184
let data: Partial<RootLayoutData> = {};
@@ -59,6 +112,7 @@ export async function load(event: RequestEvent) {
59112
// User is only considered logged in if they have both user data AND a valid access token
60113
logger.info("👤 Checking user session");
61114
if (session?.data?.user && session?.data?.oauth?.access_token) {
115+
await refreshSessionUser(session);
62116
data.userId = session.data.user.user_id;
63117
data.email = session.data.user.email;
64118
data.username = session.data.user.username;

apps/api-manager/src/routes/+layout.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { currentBank } from "$lib/stores/currentBank.svelte";
2020
import { userPreferences } from "$lib/stores/userPreferences.svelte";
2121
import { onMount } from "svelte";
22+
import { invalidate } from "$app/navigation";
2223
2324
const logger = createLogger("LayoutClient");
2425
const layoutStartTime = performance.now();
@@ -103,6 +104,19 @@
103104
}
104105
});
105106
107+
// Silently re-fetch the session user (incl. entitlements) every 5 minutes so
108+
// newly granted roles show up without logout/login. The server layout load
109+
// throttles the actual /users/current call, so this is cheap when fresh.
110+
const USER_REFRESH_POLL_MS = 5 * 60 * 1000;
111+
onMount(() => {
112+
const interval = setInterval(() => {
113+
if (isAuthenticated) {
114+
invalidate("app:session-user");
115+
}
116+
}, USER_REFRESH_POLL_MS);
117+
return () => clearInterval(interval);
118+
});
119+
106120
// Fetch system dynamic entities for shortcuts
107121
async function fetchSystemDynamicEntities() {
108122
if (!isAuthenticated) return;

apps/api-manager/src/routes/login/[provider]/callback/+server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
231231
const { session } = event.locals;
232232
await session.setData({
233233
user: user,
234+
userRefreshedAt: Date.now(),
234235
oauth: {
235236
access_token: obpAccessToken,
236237
refresh_token: tokens.refreshToken(),

apps/api-manager/src/routes/login/obp/callback/+server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
214214
const { session } = event.locals;
215215
await session.setData({
216216
user: user,
217+
userRefreshedAt: Date.now(),
217218
oauth: {
218219
access_token: obpAccessToken,
219220
refresh_token: tokens.refreshToken(),

0 commit comments

Comments
 (0)