@sourceregistry/sveltekit-oidc 2.1.0 → 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.
- package/README.md +125 -69
- package/dist/client/OIDCContext.svelte +217 -5
- package/dist/client/OIDCContext.svelte.d.ts +30 -2
- package/dist/client/idle.d.ts +7 -0
- package/dist/client/idle.js +22 -0
- package/dist/server/cookies.d.ts +1 -1
- package/dist/server/cookies.js +28 -7
- package/dist/server/index.js +346 -91
- package/dist/server/jwt.js +13 -3
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +27 -7
- package/dist/server/types.d.ts +41 -2
- package/dist/server/utils.d.ts +32 -1
- package/dist/server/utils.js +97 -4
- package/package.json +2 -2
package/dist/server/store.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { OIDCBackChannelLogoutStore, OIDCSessionStore, OIDCUserClaims } from './types.js';
|
|
2
|
-
export declare function createInMemoryBackChannelLogoutStore<TIdentity extends OIDCUserClaims = OIDCUserClaims>(
|
|
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>;
|
package/dist/server/store.js
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
|
-
export function createInMemoryBackChannelLogoutStore() {
|
|
2
|
-
const
|
|
3
|
-
const
|
|
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
|
|
27
|
+
remember(revokedBySid, `${record.issuer}:${record.clientId}:${record.sid}`, record.iat);
|
|
8
28
|
}
|
|
9
29
|
if (record.sub) {
|
|
10
|
-
revokedBySub
|
|
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
|
|
15
|
-
(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
|
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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,12 +176,38 @@ 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;
|
|
@@ -246,6 +279,12 @@ export type OIDCHandleLocals<TIdentity extends OIDCUserClaims = OIDCUserClaims,
|
|
|
246
279
|
clearSession: () => Promise<void>;
|
|
247
280
|
};
|
|
248
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
|
+
*/
|
|
249
288
|
state: string;
|
|
250
289
|
nonce: string;
|
|
251
290
|
codeVerifier: string;
|
|
@@ -286,9 +325,9 @@ export type OIDCCookies<TIdentity extends OIDCUserClaims = OIDCUserClaims> = {
|
|
|
286
325
|
id: string;
|
|
287
326
|
}): void;
|
|
288
327
|
clearSessionReference(cookies: Cookies): void;
|
|
289
|
-
readState(cookies: Cookies): OIDCStateCookie | null;
|
|
328
|
+
readState(cookies: Cookies, stateToken?: string | null): OIDCStateCookie | null;
|
|
290
329
|
writeState(cookies: Cookies, state: OIDCStateCookie): void;
|
|
291
|
-
clearState(cookies: Cookies): void;
|
|
330
|
+
clearState(cookies: Cookies, stateToken?: string | null): void;
|
|
292
331
|
};
|
|
293
332
|
export type OIDCPersistedSession<TIdentity extends OIDCUserClaims = OIDCUserClaims> = {
|
|
294
333
|
id?: string;
|
package/dist/server/utils.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/utils.js
CHANGED
|
@@ -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
|
-
|
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "OIDC authentication helpers for SvelteKit applications",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"scripts": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"oauth"
|
|
85
85
|
],
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@sourceregistry/node-jwt": "^1.
|
|
87
|
+
"@sourceregistry/node-jwt": "^1.6.0"
|
|
88
88
|
},
|
|
89
89
|
"release": {
|
|
90
90
|
"branches": [
|