@sourceregistry/sveltekit-oidc 1.6.13 → 1.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 CHANGED
@@ -147,9 +147,19 @@ export async function load(event) {
147
147
 
148
148
  - local expiry redirects
149
149
  - `check_session_iframe` polling when the provider advertises it
150
- - periodic `invalidateAll()` revalidation so revoked server sessions are detected
150
+ - targeted session revalidation when a token reaches its renewal window
151
+ - optional periodic session revalidation when `revalidateIntervalMs` is explicitly configured
151
152
  - a client context for nested auth-aware components through `useOIDC()` / `getOIDCContext()`
152
153
 
154
+ `oidc.getPublicSession(event)` automatically registers the `oidc:session` dependency used by
155
+ `OIDCContext`, so the standard setup above does not re-run unrelated page loads. If you destructure
156
+ the load event, pass both `cookies` and `depends`: `oidc.getPublicSession({cookies, depends})`.
157
+
158
+ Periodic revalidation is disabled by default to avoid unnecessary page updates. Set
159
+ `revalidateIntervalMs` to a positive interval only when you need polling in addition to token-expiry
160
+ and provider session monitoring. Existing integrations that pass only `cookies` remain compatible;
161
+ their expiry-driven revalidation falls back to `invalidateAll()`.
162
+
153
163
  Set `monitorSession={false}` when you want to keep the client context but leave remote session
154
164
  revocation checks to your server-side guard or another mechanism. This disables
155
165
  `check_session_iframe` polling without changing the provider metadata exposed through the context.
@@ -6,7 +6,7 @@
6
6
  </script>
7
7
 
8
8
  <script lang="ts" generics="TClaims extends OIDCUserClaims = OIDCUserClaims">
9
- import {invalidateAll, beforeNavigate} from '$app/navigation';
9
+ import {beforeNavigate, invalidate, invalidateAll} from '$app/navigation';
10
10
  import {tick} from 'svelte';
11
11
  import type {Snippet} from 'svelte';
12
12
 
@@ -14,18 +14,24 @@
14
14
 
15
15
  type RedirectMode = 'login' | 'logout' | 'reload' | 'none';
16
16
 
17
+ type OIDCClientDebugEvent = {
18
+ type: string;
19
+ details?: Record<string, unknown>;
20
+ };
21
+
17
22
  let {
18
23
  session = null,
19
24
  config,
20
25
  loginPath,
21
- logoutPath = '/auth/logout',
26
+ logoutPath,
22
27
  checkSessionIntervalMs = 5000,
23
28
  monitorSession = true,
24
- revalidateIntervalMs = 30000,
29
+ revalidateIntervalMs = 0,
25
30
  renewalLeadTimeMs = 5000,
26
31
  redirectOnExpired = 'login',
27
32
  redirectOnRevoked = 'login',
28
33
  redirectIfUnauthenticated = false,
34
+ onDebug,
29
35
  children
30
36
  }: {
31
37
  session?: OIDCPublicSession<TClaims> | null;
@@ -39,6 +45,8 @@
39
45
  redirectOnExpired?: RedirectMode;
40
46
  redirectOnRevoked?: RedirectMode;
41
47
  redirectIfUnauthenticated?: boolean;
48
+ /** Receives token-safe lifecycle events for diagnosing session changes. */
49
+ onDebug?: (event: OIDCClientDebugEvent) => void;
42
50
  children?: Snippet;
43
51
  } = $props();
44
52
 
@@ -48,8 +56,13 @@
48
56
  let handledUnauthenticated = $state(false);
49
57
  let sessionExpiredDuringRevalidation = false;
50
58
 
59
+ function debug(type: string, details?: Record<string, unknown>) {
60
+ onDebug?.({type, details});
61
+ }
62
+
51
63
  const metadata = $derived(config.metadata);
52
64
  const resolvedLoginPath = $derived(loginPath ?? config.loginPath ?? '/auth/login');
65
+ const resolvedLogoutPath = $derived(logoutPath ?? config.logoutPath ?? '/auth/logout');
53
66
  const iframeUrl = $derived(
54
67
  config.metadata.check_session_iframe ?? config.checkSessionIframe ?? undefined
55
68
  );
@@ -73,6 +86,9 @@
73
86
  get groups() {
74
87
  return session?.groups ?? [];
75
88
  },
89
+ get issuer() {
90
+ return config.issuer;
91
+ },
76
92
  get metadata() {
77
93
  return metadata;
78
94
  },
@@ -93,6 +109,7 @@
93
109
  // Only intercept login redirects while a background revalidation is active.
94
110
  beforeNavigate(({to, cancel}) => {
95
111
  if (revalidating && to?.url.pathname.startsWith(resolvedLoginPath)) {
112
+ debug('revalidation_redirected_to_login', {pathname: to.url.pathname});
96
113
  cancel();
97
114
  sessionExpiredDuringRevalidation = true;
98
115
  }
@@ -104,10 +121,11 @@
104
121
 
105
122
  async function logout(clearSessionOnly = false) {
106
123
  if (clearSessionOnly) {
124
+ debug('local_session_logout_requested');
107
125
  // Session-monitor events must clear only this application's session.
108
126
  // Keep the flag in the URL because logoutHandler also supports callers
109
127
  // that do not send a form body.
110
- const url = new URL(logoutPath, window.location.href);
128
+ const url = new URL(resolvedLogoutPath, window.location.href);
111
129
  url.searchParams.set('clearSessionOnly', '1');
112
130
 
113
131
  await fetch(url, {
@@ -122,20 +140,25 @@
122
140
  // session intact and allowing the app to immediately sign in again.
123
141
  const form = document.createElement('form');
124
142
  form.method = 'POST';
125
- form.action = logoutPath;
143
+ form.action = resolvedLogoutPath;
126
144
  form.style.display = 'none';
127
145
  document.body.append(form);
128
146
  form.submit();
129
147
  }
130
148
 
131
149
  function login(returnTo?: string) {
150
+ debug('login_redirect_requested', {returnTo: returnTo ?? `${window.location.pathname}${window.location.search}`});
132
151
  // loginPath is a plain +server.ts redirect endpoint, not a routable
133
152
  // page — use a full browser navigation, not SvelteKit's goto().
134
153
  window.location.href = buildLoginUrl(returnTo);
135
154
  }
136
155
 
137
156
  async function revalidate() {
138
- if (revalidating) return;
157
+ if (revalidating) {
158
+ debug('revalidation_skipped_in_flight');
159
+ return;
160
+ }
161
+ debug('revalidation_started', {expiresAt: session?.expiresAt});
139
162
  revalidating = true;
140
163
 
141
164
  // If the server redirects to the login path during a background
@@ -144,14 +167,25 @@
144
167
  sessionExpiredDuringRevalidation = false;
145
168
 
146
169
  try {
147
- await invalidateAll();
170
+ if (session?.revalidationDependency) {
171
+ debug('revalidation_dependency_invalidated', {
172
+ dependency: session.revalidationDependency
173
+ });
174
+ await invalidate(session.revalidationDependency);
175
+ } else {
176
+ debug('revalidation_fallback_invalidated_all');
177
+ await invalidateAll();
178
+ }
148
179
  } finally {
149
180
  revalidating = false;
150
181
  }
151
182
 
152
183
  if (sessionExpiredDuringRevalidation) {
153
184
  status = 'expired';
185
+ debug('revalidation_session_expired');
154
186
  void handleRedirect(redirectOnExpired);
187
+ } else {
188
+ debug('revalidation_completed', {isAuthenticated: Boolean(session?.isAuthenticated), expiresAt: session?.expiresAt});
155
189
  }
156
190
  }
157
191
 
@@ -199,6 +233,7 @@
199
233
  session.expiresAt * 1000 <= Date.now()
200
234
  ) {
201
235
  status = 'expired';
236
+ debug('session_expired_after_failed_renewal', {expiresAt: session.expiresAt});
202
237
  void handleRedirect(redirectOnExpired);
203
238
  return;
204
239
  }
@@ -208,6 +243,7 @@
208
243
  const leadTimeMs = renewalAttemptedForExpiresAt === expiresAt ? 0 : renewalLeadTimeMs;
209
244
  const timeoutMs = Math.max(0, expiresAt * 1000 - Date.now() - leadTimeMs);
210
245
  const timer = window.setTimeout(async () => {
246
+ debug('token_renewal_due', {expiresAt});
211
247
  // Silent revalidate first — server's maybeRefreshSession will refresh
212
248
  // the token if a valid refresh_token exists. Only redirect if the
213
249
  // session comes back unauthenticated after that.
@@ -216,6 +252,7 @@
216
252
  await tick();
217
253
  if (!session?.isAuthenticated) {
218
254
  status = 'expired';
255
+ debug('token_renewal_left_session_unauthenticated');
219
256
  void handleRedirect(redirectOnExpired);
220
257
  }
221
258
  }, timeoutMs);
@@ -268,6 +305,7 @@
268
305
  return;
269
306
  }
270
307
  if (event.data === 'changed' || event.data === 'error') {
308
+ debug('iframe_session_event', {result: event.data, origin: event.origin});
271
309
  status = 'revoked';
272
310
  void logout(true).then(() => handleRedirect(redirectOnRevoked));
273
311
  }
@@ -13,6 +13,11 @@ declare function $$render<TClaims extends OIDCUserClaims = OIDCUserClaims>(): {
13
13
  redirectOnExpired?: "none" | "login" | "logout" | "reload";
14
14
  redirectOnRevoked?: "none" | "login" | "logout" | "reload";
15
15
  redirectIfUnauthenticated?: boolean;
16
+ /** Receives token-safe lifecycle events for diagnosing session changes. */
17
+ onDebug?: (event: {
18
+ type: string;
19
+ details?: Record<string, unknown>;
20
+ }) => void;
16
21
  children?: Snippet;
17
22
  };
18
23
  exports: {};
@@ -8,6 +8,7 @@ export type OIDCClientContextValue<TClaims extends OIDCUserClaims = OIDCUserClai
8
8
  user: OIDCPublicSession<TClaims>['user'];
9
9
  claims: OIDCPublicSession<TClaims>['claims'];
10
10
  groups: OIDCPublicSession<TClaims>['groups'];
11
+ issuer: string;
11
12
  metadata?: Pick<OIDCDiscoveryDocument, 'issuer' | 'check_session_iframe' | 'end_session_endpoint' | 'backchannel_logout_supported' | 'backchannel_logout_session_supported'>;
12
13
  status: 'authenticated' | 'unauthenticated' | 'expired' | 'revoked';
13
14
  revalidating: boolean;
@@ -7,6 +7,7 @@ import { isSessionExpired, normalizeTokens, shouldRefresh } from './session.js';
7
7
  import { absoluteUrl, base64UrlEncode, buildCookieOptions, collectGroups, createPKCEPair, internalRedirectPath, normalizeIssuer, normalizeScope, parseProviderError, toPublicSession, validateIdTokenClaims, validateUserInfoSubject } from './utils.js';
8
8
  import { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
9
9
  export { createInMemoryBackChannelLogoutStore, createInMemorySessionStore } from './store.js';
10
+ const OIDC_SESSION_REVALIDATION_DEPENDENCY = 'oidc:session';
10
11
  function buildLogger(logger) {
11
12
  const noop = () => {
12
13
  };
@@ -46,6 +47,7 @@ export function createOIDC(options) {
46
47
  const stateCookieName = options.stateCookieName ?? 'oidc_auth_state';
47
48
  const defaultScope = normalizeScope(options.scope);
48
49
  const loginPath = options.loginPath ?? '/auth/login';
50
+ const logoutPath = options.logoutPath ?? '/auth/logout';
49
51
  const redirectPath = options.redirectPath ?? '/auth/callback';
50
52
  const clientAuthMethod = options.clientAuthMethod ?? (options.clientSecret ? 'client_secret_basic' : 'none');
51
53
  const fetchImpl = options.fetch ?? fetch;
@@ -59,10 +61,12 @@ export function createOIDC(options) {
59
61
  }
60
62
  const reference = cookieStore.readSessionReference(cookies);
61
63
  if (!reference?.id) {
64
+ log.debug('No OIDC session reference cookie is present');
62
65
  return null;
63
66
  }
64
67
  const session = await sessionStore.get(reference.id);
65
68
  if (!session) {
69
+ log.debug('OIDC session reference has no matching persisted session');
66
70
  cookieStore.clearSessionReference(cookies);
67
71
  return null;
68
72
  }
@@ -278,10 +282,24 @@ export function createOIDC(options) {
278
282
  return null;
279
283
  }
280
284
  if (isSessionExpired(session, sessionMaxAgeSeconds)) {
285
+ const now = Math.floor(Date.now() / 1000);
286
+ log.debug('OIDC session expired — clearing session', {
287
+ reason: session.createdAt + sessionMaxAgeSeconds <= now
288
+ ? 'session_max_age'
289
+ : 'access_token_expired_without_refresh_token',
290
+ createdAt: session.createdAt,
291
+ expiresAt: session.tokens.expiresAt,
292
+ refreshExpiresAt: session.tokens.refreshExpiresAt,
293
+ hasRefreshToken: Boolean(session.tokens.refreshToken)
294
+ });
281
295
  await clearPersistedSession(cookies, persisted?.id);
282
296
  return null;
283
297
  }
284
298
  if (await isRevoked(session)) {
299
+ log.debug('OIDC session was revoked by back-channel logout — clearing session', {
300
+ hasSid: Boolean(session.sid),
301
+ hasSubject: Boolean(session.sub)
302
+ });
285
303
  await clearPersistedSession(cookies, persisted?.id);
286
304
  return null;
287
305
  }
@@ -289,6 +307,10 @@ export function createOIDC(options) {
289
307
  return session;
290
308
  }
291
309
  try {
310
+ log.debug('Refreshing OIDC session tokens', {
311
+ expiresAt: session.tokens.expiresAt,
312
+ refreshExpiresAt: session.tokens.refreshExpiresAt
313
+ });
292
314
  const tokenResponse = await refreshTokens(session.tokens.refreshToken);
293
315
  const claims = tokenResponse.id_token
294
316
  ? await validateIdToken(tokenResponse.id_token, session.nonce)
@@ -320,6 +342,11 @@ export function createOIDC(options) {
320
342
  })
321
343
  : nextSession;
322
344
  await writePersistedSession(cookies, finalSession, persisted?.id);
345
+ log.debug('OIDC session tokens refreshed', {
346
+ expiresAt: finalSession.tokens.expiresAt,
347
+ refreshExpiresAt: finalSession.tokens.refreshExpiresAt,
348
+ hasRefreshToken: Boolean(finalSession.tokens.refreshToken)
349
+ });
323
350
  return finalSession;
324
351
  }
325
352
  catch (err) {
@@ -329,7 +356,11 @@ export function createOIDC(options) {
329
356
  }
330
357
  }
331
358
  async function getSession(event) {
332
- return maybeRefreshSession(event.cookies, await readPersistedSession(event.cookies));
359
+ const session = await maybeRefreshSession(event.cookies, await readPersistedSession(event.cookies));
360
+ if (!session || !options.enrichSession) {
361
+ return session;
362
+ }
363
+ return options.enrichSession(session, { event: event });
333
364
  }
334
365
  async function signIn(event, loginOptions = {}) {
335
366
  const metadata = await getMetadata();
@@ -440,6 +471,9 @@ export function createOIDC(options) {
440
471
  if (session?.tokens.idToken) {
441
472
  url.searchParams.set('id_token_hint', session.tokens.idToken);
442
473
  }
474
+ // The provider needs client context to validate the post-logout URI
475
+ // even when the local session (and therefore id_token_hint) is gone.
476
+ url.searchParams.set('client_id', options.clientId);
443
477
  url.searchParams.set('post_logout_redirect_uri', absoluteUrl(event, postLogoutRedirectPath));
444
478
  if (logoutOptions.state) {
445
479
  url.searchParams.set('state', logoutOptions.state);
@@ -530,9 +564,6 @@ export function createOIDC(options) {
530
564
  }
531
565
  function logoutHandler(defaults = {}) {
532
566
  return async (event) => {
533
- if (event.request.method !== 'POST') {
534
- throw error(405, { message: 'Logout requires POST' });
535
- }
536
567
  const form = await event.request.formData().catch(() => null);
537
568
  const postLogoutRedirectUri = event.url.searchParams.get('postLogoutRedirectUri') ??
538
569
  form?.get('postLogoutRedirectUri')?.toString() ??
@@ -570,7 +601,9 @@ export function createOIDC(options) {
570
601
  const metadata = await getMetadata();
571
602
  return {
572
603
  clientId: options.clientId,
604
+ issuer: metadata.issuer,
573
605
  loginPath,
606
+ logoutPath,
574
607
  redirectPath,
575
608
  metadata: {
576
609
  issuer: metadata.issuer,
@@ -590,7 +623,13 @@ export function createOIDC(options) {
590
623
  hook,
591
624
  getMetadata,
592
625
  getSession,
593
- getPublicSession: async (event) => toPublicSession(await getSession(event)),
626
+ getPublicSession: async (event) => {
627
+ event.depends?.(OIDC_SESSION_REVALIDATION_DEPENDENCY);
628
+ const session = toPublicSession(await getSession(event));
629
+ return session && event.depends
630
+ ? { ...session, revalidationDependency: OIDC_SESSION_REVALIDATION_DEPENDENCY }
631
+ : session;
632
+ },
594
633
  getSessionManagementConfig,
595
634
  login: signIn,
596
635
  logout: signOut,
@@ -82,6 +82,8 @@ export type OIDCPublicSession<TClaims extends OIDCUserClaims = OIDCUserClaims> =
82
82
  sessionState?: string;
83
83
  sid?: string;
84
84
  sub?: string;
85
+ /** SvelteKit dependency registered by getPublicSession for targeted client revalidation. */
86
+ revalidationDependency?: string;
85
87
  };
86
88
  export type OIDCBackChannelLogoutClaims = Record<string, unknown> & {
87
89
  iss: string;
@@ -126,7 +128,9 @@ export type OIDCSessionStore<TClaims extends OIDCUserClaims = OIDCUserClaims, TS
126
128
  };
127
129
  export type OIDCSessionManagementConfig = {
128
130
  clientId: string;
131
+ issuer: string;
129
132
  loginPath: string;
133
+ logoutPath: string;
130
134
  redirectPath: string;
131
135
  metadata: Pick<OIDCDiscoveryDocument, 'issuer' | 'check_session_iframe' | 'end_session_endpoint' | 'backchannel_logout_supported' | 'backchannel_logout_session_supported'>;
132
136
  checkSessionIframe?: string;
@@ -149,6 +153,7 @@ export type OIDCOptions<TClaims extends OIDCUserClaims = OIDCUserClaims, TSessio
149
153
  clientSecretJwt?: OIDCClientSecretJwtOptions;
150
154
  privateKeyJwt?: OIDCPrivateKeyJwtOptions;
151
155
  loginPath?: string;
156
+ logoutPath?: string;
152
157
  redirectPath?: string;
153
158
  postLogoutRedirectUri?: string;
154
159
  scope?: string | string[];
@@ -177,6 +182,18 @@ export type OIDCOptions<TClaims extends OIDCUserClaims = OIDCUserClaims, TSessio
177
182
  user?: TClaims;
178
183
  isRefresh: boolean;
179
184
  }) => MaybePromise<TSession>;
185
+ /**
186
+ * Runs on every `getSession` read (and therefore every `getPublicSession`,
187
+ * `hook()`-provided `requireAuth`, and the instance-level `requireAuth`) —
188
+ * unlike `transformSession`, which only fires on session creation and token
189
+ * refresh. Use this to keep data your app owns outside the token (e.g. roles
190
+ * or memberships stored in your own database) fresh without waiting for a
191
+ * refresh. Only invoked when a session is present; the result is not persisted
192
+ * back to the session store.
193
+ */
194
+ enrichSession?: (session: TSession, context: {
195
+ event?: MinimalRequestEvent;
196
+ }) => MaybePromise<TSession>;
180
197
  endpoints?: Partial<OIDCDiscoveryDocument>;
181
198
  logger?: OIDCLogger | false;
182
199
  /**
@@ -271,6 +288,7 @@ export type OIDCInstance<TClaims extends OIDCUserClaims = OIDCUserClaims, TSessi
271
288
  }) => Promise<TSession | null>;
272
289
  getPublicSession: (event: {
273
290
  cookies: Cookies;
291
+ depends?: (dependency: string) => void;
274
292
  }) => Promise<OIDCPublicSession<TClaims> | null>;
275
293
  getSessionManagementConfig: () => Promise<OIDCSessionManagementConfig>;
276
294
  login: (event: MinimalRequestEvent, loginOptions?: OIDCLoginOptions) => Promise<never>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sourceregistry/sveltekit-oidc",
3
- "version": "1.6.13",
3
+ "version": "1.7.0",
4
4
  "description": "OIDC authentication helpers for SvelteKit applications",
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {