Design Prot Merge #1
@@ -1,10 +1,27 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { decodeSession, SESSION_COOKIE } from '$lib/server/auth';
|
import {
|
||||||
|
decodeSession,
|
||||||
|
encodeSession,
|
||||||
|
refreshSession,
|
||||||
|
sessionCookieOptions,
|
||||||
|
SESSION_COOKIE
|
||||||
|
} from '$lib/server/auth';
|
||||||
|
|
||||||
|
const REFRESH_THRESHOLD_SECONDS = 30;
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const session = await decodeSession(event.cookies.get(SESSION_COOKIE));
|
let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
|
||||||
|
|
||||||
if (session && session.expiresAt * 1000 > Date.now()) {
|
if (session && session.expiresAt - REFRESH_THRESHOLD_SECONDS <= Math.floor(Date.now() / 1000)) {
|
||||||
|
session = await refreshSession(session);
|
||||||
|
if (session) {
|
||||||
|
event.cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(event.url));
|
||||||
|
} else {
|
||||||
|
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
event.locals.user = {
|
event.locals.user = {
|
||||||
sub: session.sub,
|
sub: session.sub,
|
||||||
name: session.name,
|
name: session.name,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as client from 'openid-client';
|
import * as client from 'openid-client';
|
||||||
import { SignJWT, jwtVerify } from 'jose';
|
import { EncryptJWT, jwtDecrypt } from 'jose';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
@@ -45,22 +45,61 @@ export const authConfig = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionKey = () => new TextEncoder().encode(env.SESSION_SECRET);
|
let keyPromise: Promise<Uint8Array> | null = null;
|
||||||
|
|
||||||
|
function sessionKey(): Promise<Uint8Array> {
|
||||||
|
keyPromise ??= crypto.subtle
|
||||||
|
.digest('SHA-256', new TextEncoder().encode(env.SESSION_SECRET))
|
||||||
|
.then((digest) => new Uint8Array(digest));
|
||||||
|
return keyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieOptions(url: URL) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: url.protocol === 'https:',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 7
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function encodeSession(session: Session): Promise<string> {
|
export async function encodeSession(session: Session): Promise<string> {
|
||||||
return new SignJWT({ ...session })
|
return new EncryptJWT({ ...session })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime('7d')
|
.setExpirationTime('7d')
|
||||||
.sign(sessionKey());
|
.encrypt(await sessionKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decodeSession(token: string | undefined): Promise<Session | null> {
|
export async function decodeSession(token: string | undefined): Promise<Session | null> {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, sessionKey());
|
const { payload } = await jwtDecrypt(token, await sessionKey());
|
||||||
return payload as unknown as Session;
|
return payload as unknown as Session;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshSession(session: Session): Promise<Session | null> {
|
||||||
|
if (!session.refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const config = await getOidcConfig();
|
||||||
|
const tokens = await client.refreshTokenGrant(config, session.refreshToken);
|
||||||
|
const claims = tokens.claims();
|
||||||
|
return {
|
||||||
|
sub: session.sub,
|
||||||
|
name: (claims?.name as string | undefined) ?? session.name,
|
||||||
|
email: (claims?.email as string | undefined) ?? session.email,
|
||||||
|
picture: (claims?.picture as string | undefined) ?? session.picture,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token ?? session.refreshToken,
|
||||||
|
idToken: tokens.id_token ?? session.idToken,
|
||||||
|
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
||||||
|
};
|
||||||
|
} catch (exception) {
|
||||||
|
console.error('Token refresh failed', exception);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import {
|
import {
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
encodeSession,
|
encodeSession,
|
||||||
|
sessionCookieOptions,
|
||||||
type Session,
|
type Session,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
STATE_COOKIE,
|
STATE_COOKIE,
|
||||||
@@ -53,13 +54,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
|
||||||
};
|
};
|
||||||
|
|
||||||
cookies.set(SESSION_COOKIE, await encodeSession(session), {
|
cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url));
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: url.protocol === 'https:',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 * 7
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) {
|
for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) {
|
||||||
cookies.delete(name, { path: '/' });
|
cookies.delete(name, { path: '/' });
|
||||||
|
|||||||
Reference in New Issue
Block a user