@sourceregistry/sveltekit-oidc 1.0.3
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/LICENSE +201 -0
- package/README.md +184 -0
- package/dist/client/OIDCContext.svelte +208 -0
- package/dist/client/OIDCContext.svelte.d.ts +18 -0
- package/dist/client/context.d.ts +16 -0
- package/dist/client/context.js +15 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jsr.d.ts +2 -0
- package/dist/jsr.js +2 -0
- package/dist/server/cookies.d.ts +2 -0
- package/dist/server/cookies.js +26 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +505 -0
- package/dist/server/jwt.d.ts +5 -0
- package/dist/server/jwt.js +43 -0
- package/dist/server/session.d.ts +3 -0
- package/dist/server/session.js +19 -0
- package/dist/server/store.d.ts +2 -0
- package/dist/server/store.js +18 -0
- package/dist/server/types.d.ts +238 -0
- package/dist/server/types.js +1 -0
- package/dist/server/utils.d.ts +19 -0
- package/dist/server/utils.js +104 -0
- package/package.json +112 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseSignedCookie, serializeSignedCookie } from './utils.js';
|
|
2
|
+
export function createOIDCCookieStore(cookieSecret, sessionCookieName, stateCookieName, cookieOptions) {
|
|
3
|
+
return {
|
|
4
|
+
readSession(cookies) {
|
|
5
|
+
return parseSignedCookie(cookies.get(sessionCookieName), cookieSecret);
|
|
6
|
+
},
|
|
7
|
+
writeSession(cookies, session) {
|
|
8
|
+
cookies.set(sessionCookieName, serializeSignedCookie(session, cookieSecret), cookieOptions);
|
|
9
|
+
},
|
|
10
|
+
clearSession(cookies) {
|
|
11
|
+
cookies.delete(sessionCookieName, cookieOptions);
|
|
12
|
+
},
|
|
13
|
+
readState(cookies) {
|
|
14
|
+
return parseSignedCookie(cookies.get(stateCookieName), cookieSecret);
|
|
15
|
+
},
|
|
16
|
+
writeState(cookies, state) {
|
|
17
|
+
cookies.set(stateCookieName, serializeSignedCookie(state, cookieSecret), {
|
|
18
|
+
...cookieOptions,
|
|
19
|
+
maxAge: 60 * 10
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
clearState(cookies) {
|
|
23
|
+
cookies.delete(stateCookieName, cookieOptions);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OIDCOptions, OIDCInstance } from './types.js';
|
|
2
|
+
export type * from './types.js';
|
|
3
|
+
export { createInMemoryBackChannelLogoutStore } from './store.js';
|
|
4
|
+
export declare function createOIDC(options: OIDCOptions): OIDCInstance;
|
|
5
|
+
export declare const OpenIDConnect: typeof createOIDC;
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { error, redirect } from '@sveltejs/kit';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { decode, fromWeb, verify } from '@sourceregistry/node-jwt/promises';
|
|
4
|
+
import { createOIDCCookieStore } from './cookies.js';
|
|
5
|
+
import { asAuthorizationHeader, createClientSecretJwtAssertion, createPrivateKeyJwtAssertion, fetchJson } from './jwt.js';
|
|
6
|
+
import { normalizeTokens, shouldRefresh } from './session.js';
|
|
7
|
+
import { absoluteUrl, base64UrlEncode, buildCookieOptions, collectGroups, createPKCEPair, normalizeIssuer, normalizeScope, parseProviderError, toPublicSession } from './utils.js';
|
|
8
|
+
export { createInMemoryBackChannelLogoutStore } from './store.js';
|
|
9
|
+
export function createOIDC(options) {
|
|
10
|
+
const cookieOptions = buildCookieOptions(options.cookieOptions);
|
|
11
|
+
const refreshToleranceSeconds = options.refreshToleranceSeconds ?? 30;
|
|
12
|
+
const sessionCookieName = options.sessionCookieName ?? 'oidc_session';
|
|
13
|
+
const stateCookieName = options.stateCookieName ?? 'oidc_auth_state';
|
|
14
|
+
const defaultScope = normalizeScope(options.scope);
|
|
15
|
+
const loginPath = options.loginPath ?? '/auth/login';
|
|
16
|
+
const redirectPath = options.redirectPath ?? '/auth/callback';
|
|
17
|
+
const clientAuthMethod = options.clientAuthMethod ?? (options.clientSecret ? 'client_secret_basic' : 'none');
|
|
18
|
+
const cookieStore = createOIDCCookieStore(options.cookieSecret, sessionCookieName, stateCookieName, cookieOptions);
|
|
19
|
+
let metadataPromise;
|
|
20
|
+
let jwksPromise;
|
|
21
|
+
async function getMetadata() {
|
|
22
|
+
if (!metadataPromise) {
|
|
23
|
+
metadataPromise = (async () => {
|
|
24
|
+
if (options.endpoints?.authorization_endpoint && options.endpoints?.token_endpoint) {
|
|
25
|
+
return {
|
|
26
|
+
issuer: normalizeIssuer(options.issuer ?? options.endpoints.issuer ?? ''),
|
|
27
|
+
jwks_uri: options.endpoints.jwks_uri ?? '',
|
|
28
|
+
...options.endpoints
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const issuer = options.issuer ? normalizeIssuer(options.issuer) : undefined;
|
|
32
|
+
const discoveryUrl = options.discoveryUrl ?? (issuer ? `${issuer}/.well-known/openid-configuration` : undefined);
|
|
33
|
+
if (!discoveryUrl) {
|
|
34
|
+
throw error(500, { message: 'OIDC issuer or discoveryUrl must be configured' });
|
|
35
|
+
}
|
|
36
|
+
const document = await fetchJson(discoveryUrl);
|
|
37
|
+
return {
|
|
38
|
+
...document,
|
|
39
|
+
...options.endpoints,
|
|
40
|
+
issuer: normalizeIssuer(document.issuer)
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
}
|
|
44
|
+
return metadataPromise;
|
|
45
|
+
}
|
|
46
|
+
async function getJwks() {
|
|
47
|
+
if (!jwksPromise) {
|
|
48
|
+
jwksPromise = getMetadata().then((metadata) => {
|
|
49
|
+
if (!metadata.jwks_uri) {
|
|
50
|
+
throw error(500, { message: 'OIDC jwks_uri is required to validate id_token values' });
|
|
51
|
+
}
|
|
52
|
+
return fromWeb(metadata.jwks_uri);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return jwksPromise;
|
|
56
|
+
}
|
|
57
|
+
async function verifyJwtWithJwks(token, verifyOptions) {
|
|
58
|
+
let decoded;
|
|
59
|
+
try {
|
|
60
|
+
decoded = await decode(token);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw error(400, { message: 'Invalid JWT format' });
|
|
64
|
+
}
|
|
65
|
+
const jwks = await getJwks();
|
|
66
|
+
const key = decoded.header.kid ? await jwks.key(decoded.header.kid) : (await jwks.list())[0];
|
|
67
|
+
if (!key) {
|
|
68
|
+
throw error(401, { message: 'Unable to resolve a signing key from JWKS' });
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const result = await verify(token, key.toKeyObject(), verifyOptions);
|
|
72
|
+
return result.payload;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw error(401, {
|
|
76
|
+
message: typeof err === 'object' && err && 'reason' in err
|
|
77
|
+
? String(err.reason)
|
|
78
|
+
: 'JWT verification failed'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function buildTokenRequestAuth(tokenEndpoint) {
|
|
83
|
+
const headers = {
|
|
84
|
+
'content-type': 'application/x-www-form-urlencoded'
|
|
85
|
+
};
|
|
86
|
+
const body = new URLSearchParams();
|
|
87
|
+
switch (clientAuthMethod) {
|
|
88
|
+
case 'none':
|
|
89
|
+
body.set('client_id', options.clientId);
|
|
90
|
+
return { headers, body };
|
|
91
|
+
case 'client_secret_basic':
|
|
92
|
+
if (!options.clientSecret) {
|
|
93
|
+
throw error(500, { message: 'clientSecret is required for client_secret_basic' });
|
|
94
|
+
}
|
|
95
|
+
headers.authorization = asAuthorizationHeader(options.clientId, options.clientSecret);
|
|
96
|
+
return { headers, body };
|
|
97
|
+
case 'client_secret_post':
|
|
98
|
+
if (!options.clientSecret) {
|
|
99
|
+
throw error(500, { message: 'clientSecret is required for client_secret_post' });
|
|
100
|
+
}
|
|
101
|
+
body.set('client_id', options.clientId);
|
|
102
|
+
body.set('client_secret', options.clientSecret);
|
|
103
|
+
return { headers, body };
|
|
104
|
+
case 'client_secret_jwt':
|
|
105
|
+
body.set('client_id', options.clientId);
|
|
106
|
+
body.set('client_assertion', await createClientSecretJwtAssertion({
|
|
107
|
+
tokenEndpoint,
|
|
108
|
+
clientId: options.clientId,
|
|
109
|
+
clientSecret: options.clientSecret,
|
|
110
|
+
...options.clientSecretJwt
|
|
111
|
+
}));
|
|
112
|
+
body.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
113
|
+
return { headers, body };
|
|
114
|
+
case 'private_key_jwt':
|
|
115
|
+
if (!options.privateKeyJwt?.privateKey) {
|
|
116
|
+
throw error(500, { message: 'privateKeyJwt.privateKey is required for private_key_jwt' });
|
|
117
|
+
}
|
|
118
|
+
body.set('client_id', options.clientId);
|
|
119
|
+
body.set('client_assertion', await createPrivateKeyJwtAssertion({
|
|
120
|
+
tokenEndpoint,
|
|
121
|
+
clientId: options.clientId,
|
|
122
|
+
...options.privateKeyJwt
|
|
123
|
+
}));
|
|
124
|
+
body.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
125
|
+
return { headers, body };
|
|
126
|
+
default:
|
|
127
|
+
throw error(500, { message: `Unsupported client authentication method '${clientAuthMethod}'` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function exchangeCode(params) {
|
|
131
|
+
const metadata = await getMetadata();
|
|
132
|
+
const auth = await buildTokenRequestAuth(metadata.token_endpoint);
|
|
133
|
+
auth.body.set('grant_type', 'authorization_code');
|
|
134
|
+
auth.body.set('code', params.code);
|
|
135
|
+
auth.body.set('redirect_uri', params.redirectUri);
|
|
136
|
+
auth.body.set('code_verifier', params.codeVerifier);
|
|
137
|
+
return fetchJson(metadata.token_endpoint, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: auth.headers,
|
|
140
|
+
body: auth.body
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async function refreshTokens(refreshToken) {
|
|
144
|
+
const metadata = await getMetadata();
|
|
145
|
+
const auth = await buildTokenRequestAuth(metadata.token_endpoint);
|
|
146
|
+
auth.body.set('grant_type', 'refresh_token');
|
|
147
|
+
auth.body.set('refresh_token', refreshToken);
|
|
148
|
+
return fetchJson(metadata.token_endpoint, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: auth.headers,
|
|
151
|
+
body: auth.body
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function validateIdToken(idToken, nonce) {
|
|
155
|
+
const metadata = await getMetadata();
|
|
156
|
+
const claims = await verifyJwtWithJwks(idToken, {
|
|
157
|
+
issuer: metadata.issuer,
|
|
158
|
+
audience: options.clientId,
|
|
159
|
+
algorithms: metadata.id_token_signing_alg_values_supported
|
|
160
|
+
});
|
|
161
|
+
const transformedClaims = options.transformClaims ? await options.transformClaims(claims) : claims;
|
|
162
|
+
if (transformedClaims.nonce !== nonce) {
|
|
163
|
+
throw error(401, { message: 'Invalid id_token nonce' });
|
|
164
|
+
}
|
|
165
|
+
return transformedClaims;
|
|
166
|
+
}
|
|
167
|
+
async function validateBackChannelLogoutToken(logoutToken) {
|
|
168
|
+
const metadata = await getMetadata();
|
|
169
|
+
const claims = await verifyJwtWithJwks(logoutToken, {
|
|
170
|
+
issuer: metadata.issuer,
|
|
171
|
+
audience: options.clientId
|
|
172
|
+
});
|
|
173
|
+
if (!claims.events?.['http://schemas.openid.net/event/backchannel-logout']) {
|
|
174
|
+
throw error(400, { message: 'Invalid logout_token events claim' });
|
|
175
|
+
}
|
|
176
|
+
if (!claims.sid && !claims.sub) {
|
|
177
|
+
throw error(400, { message: 'logout_token must contain sid or sub' });
|
|
178
|
+
}
|
|
179
|
+
if ('nonce' in claims && claims.nonce !== undefined) {
|
|
180
|
+
throw error(400, { message: 'logout_token must not contain nonce' });
|
|
181
|
+
}
|
|
182
|
+
return claims;
|
|
183
|
+
}
|
|
184
|
+
async function fetchUserInfo(accessToken) {
|
|
185
|
+
const metadata = await getMetadata();
|
|
186
|
+
if (!metadata.userinfo_endpoint) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return fetchJson(metadata.userinfo_endpoint, {
|
|
190
|
+
headers: {
|
|
191
|
+
authorization: `Bearer ${accessToken}`
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async function isRevoked(session) {
|
|
196
|
+
if (!session || !options.backChannelLogoutStore) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return options.backChannelLogoutStore.isRevoked(session);
|
|
200
|
+
}
|
|
201
|
+
async function maybeRefreshSession(event, session) {
|
|
202
|
+
if (!session) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (await isRevoked(session)) {
|
|
206
|
+
cookieStore.clearSession(event.cookies);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (!shouldRefresh(session, refreshToleranceSeconds)) {
|
|
210
|
+
return session;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const tokenResponse = await refreshTokens(session.tokens.refreshToken);
|
|
214
|
+
const claims = tokenResponse.id_token
|
|
215
|
+
? await validateIdToken(tokenResponse.id_token, session.nonce)
|
|
216
|
+
: session.claims;
|
|
217
|
+
const rawUser = options.fetchUserInfo !== false ? await fetchUserInfo(tokenResponse.access_token) : session.user;
|
|
218
|
+
const user = options.transformUser ? await options.transformUser(rawUser, { claims }) : rawUser;
|
|
219
|
+
const nextSession = {
|
|
220
|
+
...session,
|
|
221
|
+
sub: claims?.sub ?? session.sub,
|
|
222
|
+
sid: claims?.sid ?? session.sid,
|
|
223
|
+
groups: collectGroups(claims, user, session.user, session.claims),
|
|
224
|
+
claims,
|
|
225
|
+
user,
|
|
226
|
+
sessionState: tokenResponse.session_state ?? session.sessionState,
|
|
227
|
+
tokens: normalizeTokens(tokenResponse, defaultScope, session.tokens),
|
|
228
|
+
refreshedAt: Math.floor(Date.now() / 1000)
|
|
229
|
+
};
|
|
230
|
+
const finalSession = options.transformSession
|
|
231
|
+
? await options.transformSession(nextSession, {
|
|
232
|
+
tokenResponse,
|
|
233
|
+
claims,
|
|
234
|
+
user,
|
|
235
|
+
isRefresh: true
|
|
236
|
+
})
|
|
237
|
+
: nextSession;
|
|
238
|
+
cookieStore.writeSession(event.cookies, finalSession);
|
|
239
|
+
return finalSession;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
cookieStore.clearSession(event.cookies);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function getSession(event) {
|
|
247
|
+
return maybeRefreshSession(event, cookieStore.readSession(event.cookies));
|
|
248
|
+
}
|
|
249
|
+
async function signIn(event, loginOptions = {}) {
|
|
250
|
+
const metadata = await getMetadata();
|
|
251
|
+
const pkce = createPKCEPair();
|
|
252
|
+
const state = base64UrlEncode(randomBytes(24));
|
|
253
|
+
const nonce = base64UrlEncode(randomBytes(24));
|
|
254
|
+
const returnTo = loginOptions.returnTo ?? options.defaultLoginRedirect ?? '/';
|
|
255
|
+
cookieStore.writeState(event.cookies, {
|
|
256
|
+
state,
|
|
257
|
+
nonce,
|
|
258
|
+
codeVerifier: pkce.verifier,
|
|
259
|
+
returnTo,
|
|
260
|
+
createdAt: Math.floor(Date.now() / 1000)
|
|
261
|
+
});
|
|
262
|
+
const redirectUri = absoluteUrl(event, redirectPath);
|
|
263
|
+
const authorizationUrl = new URL(metadata.authorization_endpoint);
|
|
264
|
+
authorizationUrl.searchParams.set('client_id', options.clientId);
|
|
265
|
+
authorizationUrl.searchParams.set('response_type', 'code');
|
|
266
|
+
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
|
|
267
|
+
authorizationUrl.searchParams.set('scope', normalizeScope(loginOptions.scope ?? defaultScope).join(' '));
|
|
268
|
+
authorizationUrl.searchParams.set('state', state);
|
|
269
|
+
authorizationUrl.searchParams.set('nonce', nonce);
|
|
270
|
+
authorizationUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
271
|
+
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
272
|
+
if (options.audience) {
|
|
273
|
+
authorizationUrl.searchParams.set('audience', options.audience);
|
|
274
|
+
}
|
|
275
|
+
if (loginOptions.prompt) {
|
|
276
|
+
authorizationUrl.searchParams.set('prompt', loginOptions.prompt);
|
|
277
|
+
}
|
|
278
|
+
for (const [key, value] of Object.entries(loginOptions.extraParams ?? {})) {
|
|
279
|
+
authorizationUrl.searchParams.set(key, value);
|
|
280
|
+
}
|
|
281
|
+
throw redirect(302, authorizationUrl.toString());
|
|
282
|
+
}
|
|
283
|
+
async function handleCallback(event) {
|
|
284
|
+
const providerError = parseProviderError(event);
|
|
285
|
+
if (providerError) {
|
|
286
|
+
throw providerError;
|
|
287
|
+
}
|
|
288
|
+
const stateCookie = cookieStore.readState(event.cookies);
|
|
289
|
+
const state = event.url.searchParams.get('state');
|
|
290
|
+
const code = event.url.searchParams.get('code');
|
|
291
|
+
if (!stateCookie || !state || !code || stateCookie.state !== state) {
|
|
292
|
+
cookieStore.clearState(event.cookies);
|
|
293
|
+
throw error(400, { message: 'Invalid or expired OIDC callback state' });
|
|
294
|
+
}
|
|
295
|
+
cookieStore.clearState(event.cookies);
|
|
296
|
+
const tokenResponse = await exchangeCode({
|
|
297
|
+
code,
|
|
298
|
+
redirectUri: absoluteUrl(event, redirectPath),
|
|
299
|
+
codeVerifier: stateCookie.codeVerifier
|
|
300
|
+
});
|
|
301
|
+
const claims = tokenResponse.id_token
|
|
302
|
+
? await validateIdToken(tokenResponse.id_token, stateCookie.nonce)
|
|
303
|
+
: undefined;
|
|
304
|
+
const rawUser = options.fetchUserInfo === false
|
|
305
|
+
? undefined
|
|
306
|
+
: await fetchUserInfo(tokenResponse.access_token).catch(() => undefined);
|
|
307
|
+
const user = options.transformUser ? await options.transformUser(rawUser, { claims }) : rawUser;
|
|
308
|
+
const metadata = await getMetadata();
|
|
309
|
+
const now = Math.floor(Date.now() / 1000);
|
|
310
|
+
const session = {
|
|
311
|
+
issuer: metadata.issuer,
|
|
312
|
+
clientId: options.clientId,
|
|
313
|
+
nonce: stateCookie.nonce,
|
|
314
|
+
sub: claims?.sub,
|
|
315
|
+
sid: claims?.sid,
|
|
316
|
+
sessionState: tokenResponse.session_state ?? event.url.searchParams.get('session_state') ?? undefined,
|
|
317
|
+
groups: collectGroups(claims, user),
|
|
318
|
+
user,
|
|
319
|
+
claims,
|
|
320
|
+
tokens: normalizeTokens(tokenResponse, defaultScope),
|
|
321
|
+
createdAt: now,
|
|
322
|
+
refreshedAt: now
|
|
323
|
+
};
|
|
324
|
+
const finalSession = options.transformSession
|
|
325
|
+
? await options.transformSession(session, {
|
|
326
|
+
event,
|
|
327
|
+
tokenResponse,
|
|
328
|
+
claims,
|
|
329
|
+
user,
|
|
330
|
+
isRefresh: false
|
|
331
|
+
})
|
|
332
|
+
: session;
|
|
333
|
+
cookieStore.writeSession(event.cookies, finalSession);
|
|
334
|
+
return {
|
|
335
|
+
session: finalSession,
|
|
336
|
+
returnTo: stateCookie.returnTo
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async function signOut(event, logoutOptions = {}) {
|
|
340
|
+
const metadata = await getMetadata();
|
|
341
|
+
const session = cookieStore.readSession(event.cookies);
|
|
342
|
+
cookieStore.clearState(event.cookies);
|
|
343
|
+
cookieStore.clearSession(event.cookies);
|
|
344
|
+
if (logoutOptions.clearSessionOnly || !metadata.end_session_endpoint) {
|
|
345
|
+
throw redirect(302, logoutOptions.postLogoutRedirectUri ??
|
|
346
|
+
options.defaultLogoutRedirect ??
|
|
347
|
+
options.postLogoutRedirectUri ??
|
|
348
|
+
'/');
|
|
349
|
+
}
|
|
350
|
+
const url = new URL(metadata.end_session_endpoint);
|
|
351
|
+
if (session?.tokens.idToken) {
|
|
352
|
+
url.searchParams.set('id_token_hint', session.tokens.idToken);
|
|
353
|
+
}
|
|
354
|
+
url.searchParams.set('post_logout_redirect_uri', absoluteUrl(event, logoutOptions.postLogoutRedirectUri ??
|
|
355
|
+
options.postLogoutRedirectUri ??
|
|
356
|
+
options.defaultLogoutRedirect ??
|
|
357
|
+
'/'));
|
|
358
|
+
if (logoutOptions.state) {
|
|
359
|
+
url.searchParams.set('state', logoutOptions.state);
|
|
360
|
+
}
|
|
361
|
+
throw redirect(302, url.toString());
|
|
362
|
+
}
|
|
363
|
+
async function handleBackChannelLogout(event) {
|
|
364
|
+
const metadata = await getMetadata();
|
|
365
|
+
if (!metadata.backchannel_logout_supported) {
|
|
366
|
+
throw error(400, { message: 'Provider does not advertise back-channel logout support' });
|
|
367
|
+
}
|
|
368
|
+
if (!options.backChannelLogoutStore) {
|
|
369
|
+
throw error(500, {
|
|
370
|
+
message: 'backChannelLogoutStore is required for back-channel logout with cookie sessions'
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const form = await event.request.formData();
|
|
374
|
+
const logoutToken = form.get('logout_token')?.toString();
|
|
375
|
+
if (!logoutToken) {
|
|
376
|
+
throw error(400, { message: 'logout_token is required' });
|
|
377
|
+
}
|
|
378
|
+
const claims = await validateBackChannelLogoutToken(logoutToken);
|
|
379
|
+
await options.backChannelLogoutStore.revoke({
|
|
380
|
+
issuer: claims.iss,
|
|
381
|
+
clientId: options.clientId,
|
|
382
|
+
sid: claims.sid,
|
|
383
|
+
sub: claims.sub,
|
|
384
|
+
jti: claims.jti,
|
|
385
|
+
iat: claims.iat
|
|
386
|
+
});
|
|
387
|
+
return new Response(null, { status: 200 });
|
|
388
|
+
}
|
|
389
|
+
function requireAuth(event, returnTo) {
|
|
390
|
+
const session = cookieStore.readSession(event.cookies);
|
|
391
|
+
if (session) {
|
|
392
|
+
return session;
|
|
393
|
+
}
|
|
394
|
+
throw redirect(302, `${absoluteUrl(event, loginPath)}?returnTo=${encodeURIComponent(returnTo ?? `${event.url.pathname}${event.url.search}`)}`);
|
|
395
|
+
}
|
|
396
|
+
const handle = async ({ event, resolve }) => {
|
|
397
|
+
const session = await getSession(event);
|
|
398
|
+
event.locals.oidc = {
|
|
399
|
+
isAuthenticated: Boolean(session),
|
|
400
|
+
session,
|
|
401
|
+
user: session?.user,
|
|
402
|
+
claims: session?.claims,
|
|
403
|
+
requireAuth: () => {
|
|
404
|
+
if (!session) {
|
|
405
|
+
throw error(401, { message: 'Authentication required' });
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
clearSession: () => cookieStore.clearSession(event.cookies)
|
|
409
|
+
};
|
|
410
|
+
return resolve(event);
|
|
411
|
+
};
|
|
412
|
+
function loginHandler(defaults = {}) {
|
|
413
|
+
return async (event) => {
|
|
414
|
+
const returnTo = event.url.searchParams.get('returnTo') ?? defaults.returnTo;
|
|
415
|
+
return signIn(event, { ...defaults, returnTo });
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function callbackHandler(handlerOptions = {}) {
|
|
419
|
+
return async (event) => {
|
|
420
|
+
try {
|
|
421
|
+
const result = await handleCallback(event);
|
|
422
|
+
const response = await handlerOptions.onsuccess?.(event, result);
|
|
423
|
+
if (response) {
|
|
424
|
+
return response;
|
|
425
|
+
}
|
|
426
|
+
throw redirect(302, handlerOptions.redirectTo ?? result.returnTo);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
const response = await handlerOptions.onfailure?.(event, err);
|
|
430
|
+
if (response) {
|
|
431
|
+
return response;
|
|
432
|
+
}
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function logoutHandler(defaults = {}) {
|
|
438
|
+
return async (event) => {
|
|
439
|
+
const postLogoutRedirectUri = event.url.searchParams.get('postLogoutRedirectUri') ?? defaults.postLogoutRedirectUri;
|
|
440
|
+
const clearSessionOnly = event.url.searchParams.get('clearSessionOnly') === '1' ||
|
|
441
|
+
event.url.searchParams.get('clearSessionOnly') === 'true' ||
|
|
442
|
+
defaults.clearSessionOnly;
|
|
443
|
+
return signOut(event, { ...defaults, postLogoutRedirectUri, clearSessionOnly });
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function backChannelLogoutHandler() {
|
|
447
|
+
return async (event) => handleBackChannelLogout(event);
|
|
448
|
+
}
|
|
449
|
+
function createActions(actionOptions = {}) {
|
|
450
|
+
return {
|
|
451
|
+
login: (async (event) => {
|
|
452
|
+
const form = await event.request.formData();
|
|
453
|
+
const returnTo = (form.get('returnTo')?.toString() || actionOptions.defaultReturnTo || undefined) ?? undefined;
|
|
454
|
+
return signIn(event, { returnTo });
|
|
455
|
+
}),
|
|
456
|
+
logout: (async (event) => {
|
|
457
|
+
const form = await event.request.formData();
|
|
458
|
+
const postLogoutRedirectUri = (form.get('postLogoutRedirectUri')?.toString() ||
|
|
459
|
+
actionOptions.defaultPostLogoutRedirectUri ||
|
|
460
|
+
undefined) ?? undefined;
|
|
461
|
+
const clearSessionOnly = form.get('clearSessionOnly')?.toString() === '1' ||
|
|
462
|
+
form.get('clearSessionOnly')?.toString() === 'true';
|
|
463
|
+
return signOut(event, { postLogoutRedirectUri, clearSessionOnly });
|
|
464
|
+
})
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async function getSessionManagementConfig() {
|
|
468
|
+
const metadata = await getMetadata();
|
|
469
|
+
return {
|
|
470
|
+
clientId: options.clientId,
|
|
471
|
+
loginPath,
|
|
472
|
+
redirectPath,
|
|
473
|
+
metadata: {
|
|
474
|
+
issuer: metadata.issuer,
|
|
475
|
+
check_session_iframe: metadata.check_session_iframe,
|
|
476
|
+
end_session_endpoint: metadata.end_session_endpoint,
|
|
477
|
+
backchannel_logout_supported: metadata.backchannel_logout_supported,
|
|
478
|
+
backchannel_logout_session_supported: metadata.backchannel_logout_session_supported
|
|
479
|
+
},
|
|
480
|
+
checkSessionIframe: metadata.check_session_iframe,
|
|
481
|
+
supportsSessionIframe: Boolean(metadata.check_session_iframe),
|
|
482
|
+
backChannelLogoutSupported: Boolean(metadata.backchannel_logout_supported),
|
|
483
|
+
backChannelLogoutSessionSupported: Boolean(metadata.backchannel_logout_session_supported)
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
handle,
|
|
488
|
+
getMetadata,
|
|
489
|
+
getSession,
|
|
490
|
+
getPublicSession: async (event) => toPublicSession(await getSession(event)),
|
|
491
|
+
getSessionManagementConfig,
|
|
492
|
+
login: signIn,
|
|
493
|
+
logout: signOut,
|
|
494
|
+
handleCallback,
|
|
495
|
+
handleBackChannelLogout,
|
|
496
|
+
loginHandler,
|
|
497
|
+
callbackHandler,
|
|
498
|
+
logoutHandler,
|
|
499
|
+
backChannelLogoutHandler,
|
|
500
|
+
createActions,
|
|
501
|
+
requireAuth,
|
|
502
|
+
clearSession: cookieStore.clearSession
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
export const OpenIDConnect = createOIDC;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OIDCClientAssertionOptions, OIDCClientSecretJwtOptions, OIDCPrivateKeyJwtOptions } from './types.js';
|
|
2
|
+
export declare function fetchJson<T>(url: string, init?: RequestInit): Promise<T>;
|
|
3
|
+
export declare function asAuthorizationHeader(clientId: string, clientSecret: string): string;
|
|
4
|
+
export declare function createClientSecretJwtAssertion(options: OIDCClientAssertionOptions & OIDCClientSecretJwtOptions): Promise<string>;
|
|
5
|
+
export declare function createPrivateKeyJwtAssertion(options: OIDCClientAssertionOptions & OIDCPrivateKeyJwtOptions): Promise<string>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { sign } from '@sourceregistry/node-jwt/promises';
|
|
4
|
+
import { base64UrlEncode } from './utils.js';
|
|
5
|
+
export async function fetchJson(url, init) {
|
|
6
|
+
const response = await fetch(url, init);
|
|
7
|
+
if (!response.ok) {
|
|
8
|
+
throw error(response.status, {
|
|
9
|
+
message: `OIDC request failed for '${url}' with status ${response.status}`
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return (await response.json());
|
|
13
|
+
}
|
|
14
|
+
export function asAuthorizationHeader(clientId, clientSecret) {
|
|
15
|
+
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
|
|
16
|
+
}
|
|
17
|
+
export async function createClientSecretJwtAssertion(options) {
|
|
18
|
+
if (!options.clientSecret) {
|
|
19
|
+
throw error(500, { message: 'clientSecret is required for client_secret_jwt' });
|
|
20
|
+
}
|
|
21
|
+
const algorithm = options.algorithm ?? 'HS256';
|
|
22
|
+
const now = Math.floor(Date.now() / 1000);
|
|
23
|
+
return sign({
|
|
24
|
+
iss: options.clientId,
|
|
25
|
+
sub: options.clientId,
|
|
26
|
+
aud: options.tokenEndpoint,
|
|
27
|
+
jti: base64UrlEncode(randomBytes(24)),
|
|
28
|
+
iat: now,
|
|
29
|
+
exp: now + (options.expiresInSeconds ?? 60)
|
|
30
|
+
}, options.clientSecret, { alg: algorithm, typ: 'JWT' });
|
|
31
|
+
}
|
|
32
|
+
export async function createPrivateKeyJwtAssertion(options) {
|
|
33
|
+
const algorithm = options.algorithm ?? 'RS256';
|
|
34
|
+
const now = Math.floor(Date.now() / 1000);
|
|
35
|
+
return sign({
|
|
36
|
+
iss: options.clientId,
|
|
37
|
+
sub: options.clientId,
|
|
38
|
+
aud: options.tokenEndpoint,
|
|
39
|
+
jti: base64UrlEncode(randomBytes(24)),
|
|
40
|
+
iat: now,
|
|
41
|
+
exp: now + (options.expiresInSeconds ?? 60)
|
|
42
|
+
}, options.privateKey, { alg: algorithm, typ: 'JWT', ...(options.keyId ? { kid: options.keyId } : {}) });
|
|
43
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { OIDCSession, OIDCSessionTokens, OIDCTokenResponse } from './types.js';
|
|
2
|
+
export declare function normalizeTokens(tokenResponse: OIDCTokenResponse, defaultScope: string[], existing?: OIDCSessionTokens, now?: number): OIDCSessionTokens;
|
|
3
|
+
export declare function shouldRefresh(session: OIDCSession, refreshToleranceSeconds: number, now?: number): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function normalizeTokens(tokenResponse, defaultScope, existing, now = Math.floor(Date.now() / 1000)) {
|
|
2
|
+
return {
|
|
3
|
+
accessToken: tokenResponse.access_token,
|
|
4
|
+
tokenType: tokenResponse.token_type,
|
|
5
|
+
idToken: tokenResponse.id_token ?? existing?.idToken,
|
|
6
|
+
refreshToken: tokenResponse.refresh_token ?? existing?.refreshToken,
|
|
7
|
+
scope: tokenResponse.scope ? tokenResponse.scope.split(' ') : (existing?.scope ?? defaultScope),
|
|
8
|
+
expiresAt: tokenResponse.expires_in ? now + tokenResponse.expires_in : existing?.expiresAt,
|
|
9
|
+
refreshExpiresAt: tokenResponse.refresh_expires_in
|
|
10
|
+
? now + tokenResponse.refresh_expires_in
|
|
11
|
+
: existing?.refreshExpiresAt
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function shouldRefresh(session, refreshToleranceSeconds, now = Math.floor(Date.now() / 1000)) {
|
|
15
|
+
if (!session.tokens.expiresAt || !session.tokens.refreshToken) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return session.tokens.expiresAt - refreshToleranceSeconds <= now;
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function createInMemoryBackChannelLogoutStore() {
|
|
2
|
+
const revokedBySid = new Set();
|
|
3
|
+
const revokedBySub = new Set();
|
|
4
|
+
return {
|
|
5
|
+
async revoke(record) {
|
|
6
|
+
if (record.sid) {
|
|
7
|
+
revokedBySid.add(`${record.issuer}:${record.clientId}:${record.sid}`);
|
|
8
|
+
}
|
|
9
|
+
if (record.sub) {
|
|
10
|
+
revokedBySub.add(`${record.issuer}:${record.clientId}:${record.sub}`);
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
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}`)));
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|