@sourceregistry/sveltekit-oidc 1.6.13 → 1.6.14

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) {
@@ -440,6 +467,9 @@ export function createOIDC(options) {
440
467
  if (session?.tokens.idToken) {
441
468
  url.searchParams.set('id_token_hint', session.tokens.idToken);
442
469
  }
470
+ // The provider needs client context to validate the post-logout URI
471
+ // even when the local session (and therefore id_token_hint) is gone.
472
+ url.searchParams.set('client_id', options.clientId);
443
473
  url.searchParams.set('post_logout_redirect_uri', absoluteUrl(event, postLogoutRedirectPath));
444
474
  if (logoutOptions.state) {
445
475
  url.searchParams.set('state', logoutOptions.state);
@@ -530,9 +560,6 @@ export function createOIDC(options) {
530
560
  }
531
561
  function logoutHandler(defaults = {}) {
532
562
  return async (event) => {
533
- if (event.request.method !== 'POST') {
534
- throw error(405, { message: 'Logout requires POST' });
535
- }
536
563
  const form = await event.request.formData().catch(() => null);
537
564
  const postLogoutRedirectUri = event.url.searchParams.get('postLogoutRedirectUri') ??
538
565
  form?.get('postLogoutRedirectUri')?.toString() ??
@@ -570,7 +597,9 @@ export function createOIDC(options) {
570
597
  const metadata = await getMetadata();
571
598
  return {
572
599
  clientId: options.clientId,
600
+ issuer: metadata.issuer,
573
601
  loginPath,
602
+ logoutPath,
574
603
  redirectPath,
575
604
  metadata: {
576
605
  issuer: metadata.issuer,
@@ -590,7 +619,13 @@ export function createOIDC(options) {
590
619
  hook,
591
620
  getMetadata,
592
621
  getSession,
593
- getPublicSession: async (event) => toPublicSession(await getSession(event)),
622
+ getPublicSession: async (event) => {
623
+ event.depends?.(OIDC_SESSION_REVALIDATION_DEPENDENCY);
624
+ const session = toPublicSession(await getSession(event));
625
+ return session && event.depends
626
+ ? { ...session, revalidationDependency: OIDC_SESSION_REVALIDATION_DEPENDENCY }
627
+ : session;
628
+ },
594
629
  getSessionManagementConfig,
595
630
  login: signIn,
596
631
  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[];
@@ -271,6 +276,7 @@ export type OIDCInstance<TClaims extends OIDCUserClaims = OIDCUserClaims, TSessi
271
276
  }) => Promise<TSession | null>;
272
277
  getPublicSession: (event: {
273
278
  cookies: Cookies;
279
+ depends?: (dependency: string) => void;
274
280
  }) => Promise<OIDCPublicSession<TClaims> | null>;
275
281
  getSessionManagementConfig: () => Promise<OIDCSessionManagementConfig>;
276
282
  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.6.14",
4
4
  "description": "OIDC authentication helpers for SvelteKit applications",
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {