@sourceregistry/sveltekit-oidc 2.0.2 → 3.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.
@@ -13,11 +13,17 @@ export async function fetchJson(url, init, fetchImpl = fetch) {
13
13
  return (await response.json());
14
14
  }
15
15
  export function asAuthorizationHeader(clientId, clientSecret) {
16
- return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
16
+ const encode = (value) => {
17
+ const encoded = new URLSearchParams({ value }).toString();
18
+ return encoded.slice('value='.length);
19
+ };
20
+ return `Basic ${Buffer.from(`${encode(clientId)}:${encode(clientSecret)}`).toString('base64')}`;
17
21
  }
18
22
  export async function createClientSecretJwtAssertion(options) {
19
23
  if (!options.clientSecret) {
20
- throw error(500, { message: 'clientSecret is required for client_secret_jwt' });
24
+ throw error(500, {
25
+ message: 'clientSecret is required for client_secret_jwt'
26
+ });
21
27
  }
22
28
  const algorithm = options.algorithm ?? 'HS256';
23
29
  const now = Math.floor(Date.now() / 1000);
@@ -40,5 +46,9 @@ export async function createPrivateKeyJwtAssertion(options) {
40
46
  jti: base64UrlEncode(randomBytes(24)),
41
47
  iat: now,
42
48
  exp: now + (options.expiresInSeconds ?? 60)
43
- }, options.privateKey, { alg: algorithm, typ: 'JWT', ...(options.keyId ? { kid: options.keyId } : {}) });
49
+ }, options.privateKey, {
50
+ alg: algorithm,
51
+ typ: 'JWT',
52
+ ...(options.keyId ? { kid: options.keyId } : {})
53
+ });
44
54
  }
@@ -1,3 +1,5 @@
1
1
  import type { OIDCBackChannelLogoutStore, OIDCSessionStore, OIDCUserClaims } from './types.js';
2
- export declare function createInMemoryBackChannelLogoutStore<TIdentity extends OIDCUserClaims = OIDCUserClaims>(): OIDCBackChannelLogoutStore<TIdentity>;
2
+ export declare function createInMemoryBackChannelLogoutStore<TIdentity extends OIDCUserClaims = OIDCUserClaims>(options?: {
3
+ retentionSeconds?: number;
4
+ }): OIDCBackChannelLogoutStore<TIdentity>;
3
5
  export declare function createInMemorySessionStore<TIdentity extends OIDCUserClaims = OIDCUserClaims>(): OIDCSessionStore<TIdentity>;
@@ -1,18 +1,38 @@
1
- export function createInMemoryBackChannelLogoutStore() {
2
- const revokedBySid = new Set();
3
- const revokedBySub = new Set();
1
+ export function createInMemoryBackChannelLogoutStore(options = {}) {
2
+ const retentionSeconds = options.retentionSeconds ?? 60 * 60 * 8;
3
+ const revokedBySid = new Map();
4
+ const revokedBySub = new Map();
5
+ function remember(map, key, issuedAt) {
6
+ map.set(key, {
7
+ issuedAt,
8
+ removeAt: Math.floor(Date.now() / 1000) + retentionSeconds
9
+ });
10
+ }
11
+ function matches(map, key, sessionCreatedAt) {
12
+ if (!key)
13
+ return false;
14
+ const record = map.get(key);
15
+ if (!record)
16
+ return false;
17
+ if (record.removeAt <= Math.floor(Date.now() / 1000)) {
18
+ map.delete(key);
19
+ return false;
20
+ }
21
+ // A later login is a new session and must not inherit a subject-wide revocation.
22
+ return sessionCreatedAt <= record.issuedAt;
23
+ }
4
24
  return {
5
25
  async revoke(record) {
6
26
  if (record.sid) {
7
- revokedBySid.add(`${record.issuer}:${record.clientId}:${record.sid}`);
27
+ remember(revokedBySid, `${record.issuer}:${record.clientId}:${record.sid}`, record.iat);
8
28
  }
9
29
  if (record.sub) {
10
- revokedBySub.add(`${record.issuer}:${record.clientId}:${record.sub}`);
30
+ remember(revokedBySub, `${record.issuer}:${record.clientId}:${record.sub}`, record.iat);
11
31
  }
12
32
  },
13
33
  async isRevoked(session) {
14
- return Boolean((session.sid && revokedBySid.has(`${session.issuer}:${session.clientId}:${session.sid}`)) ||
15
- (session.sub && revokedBySub.has(`${session.issuer}:${session.clientId}:${session.sub}`)));
34
+ return Boolean(matches(revokedBySid, session.sid ? `${session.issuer}:${session.clientId}:${session.sid}` : undefined, session.createdAt) ||
35
+ matches(revokedBySub, session.sub ? `${session.issuer}:${session.clientId}:${session.sub}` : undefined, session.createdAt));
16
36
  }
17
37
  };
18
38
  }
@@ -56,6 +56,8 @@ export type OIDCUserClaims = Record<string, unknown> & {
56
56
  iat?: number;
57
57
  nbf?: number;
58
58
  nonce?: string;
59
+ azp?: string;
60
+ auth_time?: number;
59
61
  };
60
62
  export type OIDCSessionReason = 'login' | 'refresh';
61
63
  export type OIDCSession<TIdentity extends OIDCUserClaims = OIDCUserClaims> = {
@@ -93,6 +95,7 @@ export type OIDCBackChannelLogoutClaims = Record<string, unknown> & {
93
95
  iss: string;
94
96
  aud: string | string[];
95
97
  iat: number;
98
+ exp: number;
96
99
  jti: string;
97
100
  sub?: string;
98
101
  sid?: string;
@@ -125,6 +128,10 @@ export type OIDCBackChannelLogoutStore<TIdentity extends OIDCUserClaims = OIDCUs
125
128
  revoke(record: OIDCBackChannelLogoutRecord): MaybePromise<void>;
126
129
  isRevoked(session: OIDCSession<TIdentity>): MaybePromise<boolean>;
127
130
  };
131
+ /** Optional distributed lock used to serialize refresh-token rotation across application instances. */
132
+ export type OIDCRefreshLock = {
133
+ runExclusive<T>(key: string, task: () => Promise<T>): Promise<T>;
134
+ };
128
135
  export type OIDCSessionStore<TIdentity extends OIDCUserClaims = OIDCUserClaims> = {
129
136
  get(sessionId: string): MaybePromise<OIDCSession<TIdentity> | null>;
130
137
  set(sessionId: string, session: OIDCSession<TIdentity>): MaybePromise<void>;
@@ -169,25 +176,57 @@ export type OIDCOptions<TIdentity extends OIDCUserClaims = OIDCUserClaims, TRequ
169
176
  cookieOptions?: Partial<CookieOptions>;
170
177
  clockSkewSeconds?: number;
171
178
  refreshToleranceSeconds?: number;
179
+ /** Accepted ID-token signing algorithms. Defaults to the provider metadata, or RS256 when absent. */
180
+ idTokenSigningAlgorithms?: SupportedAlgorithm[];
181
+ /** Additional audiences trusted in a multi-audience ID token. */
182
+ trustedIdTokenAudiences?: string[];
183
+ /** Permit non-HTTPS issuer and protocol endpoints for local development only. */
184
+ allowInsecureHttp?: boolean;
185
+ /** Maximum serialized cookie size before failing with guidance to use sessionStore. Defaults to 3800 bytes. */
186
+ maxCookieSizeBytes?: number;
172
187
  /** Maximum lifetime of a local browser session. Defaults to 8 hours. */
173
188
  sessionMaxAgeSeconds?: number;
189
+ /**
190
+ * Maximum lifetime of the short-lived cookie that carries CSRF state,
191
+ * the PKCE verifier, and the OIDC nonce across the redirect to the
192
+ * provider and back. Defaults to 10 minutes.
193
+ *
194
+ * If this expires (or the cookie is otherwise missing/mismatched, e.g.
195
+ * a second login started in another tab) before the browser returns
196
+ * from the provider, `handleCallback` restarts the login rather than
197
+ * failing outright - and the caller's original `returnTo` is *not*
198
+ * lost when that happens, since it also travels signed inside the
199
+ * `state` query parameter itself (see encodeOAuthState/decodeOAuthState
200
+ * in utils.ts), independent of this cookie's survival. Raising this
201
+ * value only reduces how often that restart happens; it is not
202
+ * required to fix a lost `returnTo`.
203
+ */
204
+ stateMaxAgeSeconds?: number;
174
205
  defaultLoginRedirect?: string;
175
206
  defaultLogoutRedirect?: string;
176
207
  sessionStore?: OIDCSessionStore<TIdentity> | 'memory';
177
208
  backChannelLogoutStore?: OIDCBackChannelLogoutStore<TIdentity> | 'memory';
209
+ /** Use a shared implementation in multi-instance deployments with rotating refresh tokens. */
210
+ refreshLock?: OIDCRefreshLock;
178
211
  /** Resolves application identity after provider data validation on login and refresh. */
179
212
  resolveIdentity?: (context: {
180
213
  idTokenClaims: OIDCUserClaims;
181
214
  userInfo?: OIDCUserClaims;
182
215
  reason: OIDCSessionReason;
183
216
  }) => MaybePromise<TIdentity>;
184
- /** Runs immediately before a login or refreshed session is persisted. */
217
+ /**
218
+ * Runs immediately before a login or refreshed session is persisted.
219
+ * Returning a session replaces the one that gets persisted and handed back to the
220
+ * caller (`handleCallback`'s result, `getSession`'s result); returning `void` keeps it
221
+ * unchanged. Use this to enrich or provision application data — e.g. upsert a user row —
222
+ * before the session is written, rather than after via a route's own callback hook.
223
+ */
185
224
  beforeSessionPersist?: (context: {
186
225
  session: OIDCSession<TIdentity>;
187
226
  reason: OIDCSessionReason;
188
227
  event?: MinimalRequestEvent;
189
228
  tokenResponse: OIDCTokenResponse;
190
- }) => MaybePromise<void>;
229
+ }) => MaybePromise<OIDCSession<TIdentity> | void>;
191
230
  /**
192
231
  * Loads application-owned data once for each authenticated request handled by
193
232
  * `handle`. The result is exposed as `event.locals.oidc.data` and is never
@@ -240,6 +279,12 @@ export type OIDCHandleLocals<TIdentity extends OIDCUserClaims = OIDCUserClaims,
240
279
  clearSession: () => Promise<void>;
241
280
  };
242
281
  export type OIDCStateCookie = {
282
+ /**
283
+ * The bare anti-CSRF token, compared against the decoded `state` query
284
+ * parameter on callback - not the full value sent to the provider,
285
+ * which additionally carries an encrypted `returnTo` (see
286
+ * encodeOAuthState/decodeOAuthState in utils.ts).
287
+ */
243
288
  state: string;
244
289
  nonce: string;
245
290
  codeVerifier: string;
@@ -280,9 +325,9 @@ export type OIDCCookies<TIdentity extends OIDCUserClaims = OIDCUserClaims> = {
280
325
  id: string;
281
326
  }): void;
282
327
  clearSessionReference(cookies: Cookies): void;
283
- readState(cookies: Cookies): OIDCStateCookie | null;
328
+ readState(cookies: Cookies, stateToken?: string | null): OIDCStateCookie | null;
284
329
  writeState(cookies: Cookies, state: OIDCStateCookie): void;
285
- clearState(cookies: Cookies): void;
330
+ clearState(cookies: Cookies, stateToken?: string | null): void;
286
331
  };
287
332
  export type OIDCPersistedSession<TIdentity extends OIDCUserClaims = OIDCUserClaims> = {
288
333
  id?: string;
@@ -10,6 +10,36 @@ export declare function normalizeStringArray(value: unknown): string[];
10
10
  export declare function collectGroups<TClaims extends OIDCUserClaims = OIDCUserClaims>(...sources: Array<TClaims | undefined>): string[];
11
11
  export declare function createSignedValue(value: string, secret: string): string;
12
12
  export declare function verifySignedValue(value: string, secret: string): string | null;
13
+ /**
14
+ * Embeds an app-supplied `returnTo` destination directly in the OAuth
15
+ * `state` parameter sent to the authorization endpoint, encrypted and
16
+ * authenticated with the same cookie secret used everywhere else here.
17
+ *
18
+ * The encrypted payload keeps the destination confidential while allowing
19
+ * it to survive the authorization-server round trip. The random token is
20
+ * still compared with the matching transaction cookie by the callback.
21
+ */
22
+ export declare function encodeOAuthState(token: string, returnTo: string | undefined, secret: string): string;
23
+ /**
24
+ * Inverse of encodeOAuthState. Always returns whatever `token` it can parse
25
+ * out - including from a bare, pre-upgrade-format state with no embedded
26
+ * `returnTo`, so an in-flight login started before a version bump that adds
27
+ * this encoding keeps working - and a `returnTo` only when an encrypted
28
+ * payload authenticates successfully. Forged or corrupted current-format
29
+ * state values are rejected. The legacy signed suffix remains readable so
30
+ * an authorization request begun before an upgrade can finish.
31
+ *
32
+ * This does NOT authenticate the caller by itself - `token` still has to be
33
+ * compared against the value in the state cookie by callers, exactly as
34
+ * before. The `returnTo` recovered here is only ever used to decide where
35
+ * to send the browser next (see handleCallback's restart-on-mismatch path
36
+ * in index.ts); nothing security-sensitive is decided from it, so a
37
+ * same-origin-only destination recovered without the cookie is fine.
38
+ */
39
+ export declare function decodeOAuthState(raw: string | null | undefined, secret: string): {
40
+ token: string | null;
41
+ returnTo?: string;
42
+ };
13
43
  export declare function serializeSignedCookie(payload: unknown, secret: string): string;
14
44
  export declare function parseSignedCookie<T>(value: string | undefined, secret: string): T | null;
15
45
  export declare function buildCookieOptions(options?: Partial<CookieOptions>): CookieOptions;
@@ -22,6 +52,7 @@ export declare function absoluteUrl(event: {
22
52
  export declare function internalRedirectPath(event: {
23
53
  url: URL;
24
54
  }, pathOrUrl: string | undefined, fallback?: string): string;
25
- export declare function validateIdTokenClaims(claims: OIDCUserClaims, nonce: string): void;
55
+ export declare function validateIdTokenClaims(claims: OIDCUserClaims, nonce: string | undefined, clientId?: string, trustedAudiences?: string[], requireNonce?: boolean): void;
56
+ export declare function validateRefreshedIdTokenClaims(previous: OIDCUserClaims, claims: OIDCUserClaims): void;
26
57
  export declare function validateUserInfoSubject(claims: OIDCUserClaims, user: OIDCUserClaims | undefined): void;
27
58
  export declare function toPublicSession<TIdentity extends OIDCUserClaims = OIDCUserClaims>(session: OIDCSession<TIdentity> | null): OIDCPublicSession<TIdentity> | null;
@@ -15,8 +15,11 @@ export function normalizeIssuer(issuer) {
15
15
  export function normalizeScope(scope) {
16
16
  if (!scope)
17
17
  return ['openid', 'profile', 'email'];
18
- const arr = typeof scope === 'string' ? scope.trim().split(/\s+/) : scope;
19
- return arr.length ? [...new Set(arr)] : ['openid', 'profile', 'email'];
18
+ const arr = (typeof scope === 'string' ? scope.trim().split(/\s+/) : scope).filter(Boolean);
19
+ if (!arr.length)
20
+ return ['openid', 'profile', 'email'];
21
+ const unique = [...new Set(arr)];
22
+ return unique.includes('openid') ? unique : ['openid', ...unique];
20
23
  }
21
24
  export function normalizeStringArray(value) {
22
25
  if (!value)
@@ -51,6 +54,66 @@ export function verifySignedValue(value, secret) {
51
54
  }
52
55
  return payload;
53
56
  }
57
+ /**
58
+ * Embeds an app-supplied `returnTo` destination directly in the OAuth
59
+ * `state` parameter sent to the authorization endpoint, encrypted and
60
+ * authenticated with the same cookie secret used everywhere else here.
61
+ *
62
+ * The encrypted payload keeps the destination confidential while allowing
63
+ * it to survive the authorization-server round trip. The random token is
64
+ * still compared with the matching transaction cookie by the callback.
65
+ */
66
+ export function encodeOAuthState(token, returnTo, secret) {
67
+ if (!returnTo)
68
+ return token;
69
+ return `v3.${serializeSignedCookie({ token, returnTo }, secret)}`;
70
+ }
71
+ /**
72
+ * Inverse of encodeOAuthState. Always returns whatever `token` it can parse
73
+ * out - including from a bare, pre-upgrade-format state with no embedded
74
+ * `returnTo`, so an in-flight login started before a version bump that adds
75
+ * this encoding keeps working - and a `returnTo` only when an encrypted
76
+ * payload authenticates successfully. Forged or corrupted current-format
77
+ * state values are rejected. The legacy signed suffix remains readable so
78
+ * an authorization request begun before an upgrade can finish.
79
+ *
80
+ * This does NOT authenticate the caller by itself - `token` still has to be
81
+ * compared against the value in the state cookie by callers, exactly as
82
+ * before. The `returnTo` recovered here is only ever used to decide where
83
+ * to send the browser next (see handleCallback's restart-on-mismatch path
84
+ * in index.ts); nothing security-sensitive is decided from it, so a
85
+ * same-origin-only destination recovered without the cookie is fine.
86
+ */
87
+ export function decodeOAuthState(raw, secret) {
88
+ if (!raw)
89
+ return { token: null };
90
+ if (raw.startsWith('v3.')) {
91
+ const decoded = parseSignedCookie(raw.slice(3), secret);
92
+ if (!decoded || typeof decoded.token !== 'string')
93
+ return { token: null };
94
+ return {
95
+ token: decoded.token,
96
+ ...(typeof decoded.returnTo === 'string' ? { returnTo: decoded.returnTo } : {})
97
+ };
98
+ }
99
+ const separator = raw.indexOf('.');
100
+ if (separator === -1)
101
+ return { token: raw };
102
+ const token = raw.slice(0, separator);
103
+ const signedReturnTo = raw.slice(separator + 1);
104
+ const encodedReturnTo = verifySignedValue(signedReturnTo, secret);
105
+ if (!encodedReturnTo)
106
+ return { token };
107
+ try {
108
+ return {
109
+ token,
110
+ returnTo: Buffer.from(encodedReturnTo, 'base64url').toString('utf8')
111
+ };
112
+ }
113
+ catch {
114
+ return { token };
115
+ }
116
+ }
54
117
  function cookieEncryptionKey(secret) {
55
118
  return createHash('sha256').update(`sveltekit-oidc-cookie:${secret}`).digest();
56
119
  }
@@ -113,19 +176,49 @@ export function internalRedirectPath(event, pathOrUrl, fallback = '/') {
113
176
  return fallback;
114
177
  }
115
178
  }
116
- export function validateIdTokenClaims(claims, nonce) {
179
+ export function validateIdTokenClaims(claims, nonce, clientId, trustedAudiences = [], requireNonce = true) {
117
180
  if (!Number.isFinite(claims.exp)) {
118
181
  throw error(401, { message: 'id_token expiration is required' });
119
182
  }
120
183
  if (!Number.isFinite(claims.iat)) {
121
184
  throw error(401, { message: 'id_token issued-at time is required' });
122
185
  }
123
- if (claims.nonce !== nonce) {
186
+ if (requireNonce && claims.nonce !== nonce) {
124
187
  throw error(401, { message: 'Invalid id_token nonce' });
125
188
  }
126
189
  if (!claims.sub) {
127
190
  throw error(401, { message: 'id_token subject is required' });
128
191
  }
192
+ if (clientId) {
193
+ const audiences = Array.isArray(claims.aud) ? claims.aud : claims.aud ? [claims.aud] : [];
194
+ const trusted = new Set([clientId, ...trustedAudiences]);
195
+ if (!audiences.includes(clientId) || audiences.some((audience) => !trusted.has(audience))) {
196
+ throw error(401, { message: 'id_token contains an untrusted audience' });
197
+ }
198
+ if (claims.azp !== undefined && claims.azp !== clientId) {
199
+ throw error(401, { message: 'Invalid id_token authorized party' });
200
+ }
201
+ }
202
+ }
203
+ export function validateRefreshedIdTokenClaims(previous, claims) {
204
+ const normalizeAudience = (value) => (Array.isArray(value) ? [...value] : value === undefined ? [] : [value]).sort();
205
+ if (claims.sub !== previous.sub)
206
+ throw error(401, { message: 'Refreshed id_token subject changed' });
207
+ if (JSON.stringify(normalizeAudience(claims.aud)) !== JSON.stringify(normalizeAudience(previous.aud))) {
208
+ throw error(401, { message: 'Refreshed id_token audience changed' });
209
+ }
210
+ if (claims.azp !== previous.azp)
211
+ throw error(401, {
212
+ message: 'Refreshed id_token authorized party changed'
213
+ });
214
+ if (claims.auth_time !== undefined && claims.auth_time !== previous.auth_time) {
215
+ throw error(401, {
216
+ message: 'Refreshed id_token authentication time changed'
217
+ });
218
+ }
219
+ if (claims.nonce !== undefined && claims.nonce !== previous.nonce) {
220
+ throw error(401, { message: 'Invalid refreshed id_token nonce' });
221
+ }
129
222
  }
130
223
  export function validateUserInfoSubject(claims, user) {
131
224
  if (user && user.sub !== claims.sub) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sourceregistry/sveltekit-oidc",
3
- "version": "2.0.2",
3
+ "version": "3.0.0",
4
4
  "description": "OIDC authentication helpers for SvelteKit applications",
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -62,18 +62,18 @@
62
62
  "@sveltejs/kit": "^2.63.1",
63
63
  "@sveltejs/package": "^2.5.8",
64
64
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
65
- "@types/node": "^25.9.2",
65
+ "@types/node": "^26.2.0",
66
66
  "@semantic-release/changelog": "^6.0.3",
67
67
  "@semantic-release/git": "^10.0.1",
68
- "publint": "^0.3.21",
68
+ "publint": "^0.3.23",
69
69
  "svelte": "^5.56.3",
70
- "svelte-check": "^4.6.0",
70
+ "svelte-check": "^4.7.5",
71
71
  "typescript": "^6.0.3",
72
- "vite": "^8.0.16",
72
+ "vite": "^8.2.1",
73
73
  "@vitest/coverage-v8": "^4.1.8",
74
- "vitest": "^4.1.8",
74
+ "vitest": "^4.1.10",
75
75
  "@sourceregistry/semantic-release-jsr": "^1.1.1",
76
- "typedoc": "^0.28.19"
76
+ "typedoc": "^0.28.20"
77
77
  },
78
78
  "keywords": [
79
79
  "sveltekit",
@@ -84,7 +84,7 @@
84
84
  "oauth"
85
85
  ],
86
86
  "dependencies": {
87
- "@sourceregistry/node-jwt": "^1.5.11"
87
+ "@sourceregistry/node-jwt": "^1.6.0"
88
88
  },
89
89
  "release": {
90
90
  "branches": [