diff --git a/src/app.d.ts b/src/app.d.ts index cde121735f2267b226266f67efa776c580c41892..155c2a39ef69b5804bf5ddc4e5c99e7c7f4063ec 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -5,27 +5,8 @@ declare namespace App { type OpenIdConnectLocals = import("$lib/server/openid_connect_handler").OpenIdConnectLocals type UserLocals = import("$lib/server/user_handler").UserLocals - interface Locals - extends CookiesLocals, - SessionLocals, - UserLocals, - OpenIdConnectLocals { - /// The server session stored in database - session: { - nonce?: string - /// URL to redirect to after authentication (or logout) - redirectUrl?: string - /// Temporary token used to retrieve session, when its cookie was not given in HTTP - /// request headers - /// For example, a HTTP request may omit the session cookie header after a redirection - /// from an external site. - token?: string - // /// Unique ID used to retrieve user - // userId?: number - // /// Token used by OpenIdConnect to uniquely identify user - // userIdToken?: string - } - + interface Locals extends UserLocals, OpenIdConnectLocals { + id_token?: string user?: import("$lib/users").User } // interface PageData {} diff --git a/src/lib/auditors/queries.ts b/src/lib/auditors/queries.ts index c527ac448b82023ce8fe0b829f2155c316ab5f49..13960fa67c9d061748787a63cb5c2157b491e6c4 100644 --- a/src/lib/auditors/queries.ts +++ b/src/lib/auditors/queries.ts @@ -1,4 +1,3 @@ -import type { Auditor } from "@auditors/core" import { auditArray, auditChain, @@ -9,6 +8,7 @@ import { auditString, auditSwitch, auditTest, + type Auditor, } from "@auditors/core" export function auditQueryArray(...auditors: Auditor[]) { diff --git a/src/lib/auditors/responses.ts b/src/lib/auditors/responses.ts index 7e23a8ba16c4d605c0ee33cc3e7a591ce84b19e1..e7bb16d33851d779236c5c7bffd5b6592fa194ea 100644 --- a/src/lib/auditors/responses.ts +++ b/src/lib/auditors/responses.ts @@ -4,20 +4,13 @@ import { laxAudit } from "@auditors/core" export function auditJsonResponse(auditor?: Auditor | null) { return async function ( audit: Audit, - responseUnknown: unknown, + response: Response, ): Promise<[unknown, unknown]> { - if (responseUnknown == null) { - return [responseUnknown, null] + if (response == null) { + return [response, null] } - if (typeof responseUnknown !== "object") { - return audit.unexpectedType(responseUnknown, "object") - } - const response = responseUnknown as { - ok: boolean - json: () => Promise<{ error?: { details?: unknown } }> - status: number - statusText: string - text: () => Promise<string> + if (typeof response !== "object") { + return audit.unexpectedType(response, "object") } if (!response.ok && (response.status < 400 || response.status >= 404)) { return [ @@ -51,7 +44,7 @@ export function auditJsonResponse(auditor?: Auditor | null) { } export function validateJsonResponse(auditor?: Auditor | null) { - return async function (response: unknown): Promise<[unknown, unknown]> { + return async function (response: Response): Promise<[unknown, unknown]> { return auditJsonResponse(auditor)(laxAudit, response) } } diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 6da2785069e3016036258694e3660cf5067ba7af..b8e0a95f327a10c7197fdf3899fff2a24e531969 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -10,9 +10,19 @@ $: authenticationEnabled = $page.data.authenticationEnabled - $: pathname = $page.url.pathname + $: ({ data } = $page) - $: user = $page.data.user + $: ({ pathname } = $page.url) + + $: ({ baseUrl, user } = data) + + $: loginUrl = `/auth/login?redirect=${encodeURIComponent( + new URL(pathname, baseUrl).toString(), + )}` + + $: logoutUrl = `/auth/logout?redirect=${encodeURIComponent( + new URL(pathname, baseUrl).toString(), + )}` function help() { $showTutorial = true @@ -162,7 +172,7 @@ <div class="flex items-center justify-between gap-2"> <a class="flex rounded-sm p-1 text-sm uppercase text-white hover:bg-gray-400 hover:bg-opacity-20 hover:text-white focus:outline-none" - href="/authentication/signin/leximpact?redirect={pathname}" + href={loginUrl} > <div class="flex items-center"> <!-- Material icon: VPN key--> @@ -233,8 +243,8 @@ aria-labelledby="user-menu" > <a - href="/authentication/signout" class="block rounded-sm border-b px-4 py-3 text-sm uppercase text-black hover:bg-gray-300 hover:bg-opacity-20 focus:outline-none" + href={logoutUrl} type="button" > <div class="flex items-center"> @@ -339,8 +349,8 @@ {#if authenticationEnabled} {#if user === undefined} <a - href="/authentication/signin/leximpact?redirect={pathname}" class="block border-b px-4 py-3 text-sm font-bold text-gray-700 hover:bg-gray-100" + href={loginUrl} role="menuitem" > <!-- Material icon: VPN key--> @@ -357,8 +367,8 @@ > {:else} <a - href="/authentication/signout" class="block border-b-4 px-4 py-3 text-base text-gray-700 hover:bg-gray-100" + href={logoutUrl} role="menuitem" > <!-- Material icon: Power Settings New--> diff --git a/src/lib/server/openid_connect_handler.ts b/src/lib/server/openid_connect_handler.ts index ca17634f1ad0f1b40d5c3954a06aabff705b054a..8435c220716fa700b668920c8b8b522de85ecb11 100644 --- a/src/lib/server/openid_connect_handler.ts +++ b/src/lib/server/openid_connect_handler.ts @@ -1,8 +1,8 @@ import type { Handle } from "@sveltejs/kit" import { - type Client as OpenIdConnectClient, custom as openIdConnectCustom, Issuer as OpenIdConnectIssuer, + type Client as OpenIdConnectClient, } from "openid-client" import config from "$lib/server/config" diff --git a/src/lib/server/user_handler.ts b/src/lib/server/user_handler.ts index aadefb085f41b8767acdbf8d1c5e9de484a1cd76..edb6ff76956a5a4b4031332f09f55d2bb044eb4f 100644 --- a/src/lib/server/user_handler.ts +++ b/src/lib/server/user_handler.ts @@ -10,12 +10,22 @@ export interface UserLocals { export const userHandler: Handle = async ({ event, resolve }) => { const { cookies, locals } = event + + const idTokenJwt = cookies.get("id_token") + if (idTokenJwt !== undefined) { + try { + locals.id_token = jwt.verify(idTokenJwt, config.jwtSecret) as string + } catch (e) { + console.warn(`Invalid JSON Web Token for id_token: ${idTokenJwt}. ${e}`) + } + } + const userJwt = cookies.get("user") if (userJwt !== undefined) { try { locals.user = jwt.verify(userJwt, config.jwtSecret) as User } catch (e) { - console.warn(`Invalid JSON Web Token: ${userJwt}. ${e}`) + console.warn(`Invalid JSON Web Token for user: ${userJwt}. ${e}`) } } return await resolve(event) diff --git a/src/lib/users.ts b/src/lib/users.ts index 19b723dc090dc704a879a7164dbce2c061d9638f..8b42c69a8c21d686170f279e1674622929bf29fd 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -1,3 +1,13 @@ +export interface SigninPayload { + nonce: string + redirectUrl: string + token: string +} + +export interface SignoutPayload { + redirectUrl: string +} + export interface User { email: string // "john@doe.com" email_verified: boolean @@ -7,6 +17,6 @@ export interface User { locale: string // "fr" name: string // "John Doe" preferred_username: string // "jdoe", - roles?: string[] // [ 'offline_access', 'default-roles-leximpact', 'uma_authorization' ], + roles?: string[] // [ "offline_access", "default-roles-leximpact", "uma_authorization" ], sub: string // "12345678-9abc-def0-1234-56789abcdef0" } diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 3a7d4d6a4a513921c6a0c8bbd624b77ac12418c3..d44103283ae2f3d6c7c7e78ff90d0e15ead625aa 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,11 +1,10 @@ import type { RepositoryConfig } from "$lib/repositories" import type { User } from "$lib/users" import config from "$lib/server/config" -import { oauth2Authenticator } from "$lib/server/oauth2" import type { LayoutServerLoad } from "./$types" -const { githubPersonalAccessToken, openfiscaRepository } = config +const { githubPersonalAccessToken, openfiscaRepository, openIdConnect } = config export const load: LayoutServerLoad = async ( event, @@ -36,13 +35,13 @@ export const load: LayoutServerLoad = async ( title: string user?: User }> => { - const oauth2Session = await oauth2Authenticator?.getSession(event) - const user = oauth2Session?.user + const { locals } = event + const { user } = locals return { advanced: config.advanced, apiBaseUrls: config.apiBaseUrls, apiWebSocketBaseUrls: config.apiWebSocketBaseUrls, - authenticationEnabled: oauth2Authenticator !== undefined, + authenticationEnabled: openIdConnect !== undefined, baseUrl: config.baseUrl, childrenKey: config.childrenKey, familyEntityKey: config.familyEntityKey, diff --git a/src/routes/auth/login/+server.ts b/src/routes/auth/login/+server.ts index 7e8017621cc688c0e102ac4dd8e66679f5bdd7ed..b4b1be9e741bc71d6dd0cc88a1ee971cea53acfb 100644 --- a/src/routes/auth/login/+server.ts +++ b/src/routes/auth/login/+server.ts @@ -1,11 +1,13 @@ import { error } from "@sveltejs/kit" +import jwt from "jsonwebtoken" import { generators } from "openid-client" import { validateLoginLogoutQuery } from "$lib/server/auditors/queries" +import config from "$lib/server/config" import type { RequestHandler } from "./$types" -export const GET: RequestHandler = async ({ locals, url }) => { +export const GET: RequestHandler = async ({ cookies, locals, url }) => { const [query, queryError] = validateLoginLogoutQuery(url.searchParams) as [ { redirect: string }, unknown, @@ -29,20 +31,25 @@ export const GET: RequestHandler = async ({ locals, url }) => { } const { redirect: redirectUrl } = query - const session = locals.session - if (locals.openIdConnectClient !== undefined) { const nonce = generators.nonce() - session.nonce = nonce - session.redirectUrl = redirectUrl const token = crypto.randomUUID() - session.token = token const authorizationUrl = locals.openIdConnectClient.authorizationUrl({ scope: "openid email profile", response_mode: "form_post", nonce, state: token, }) + cookies.set( + "signin", + jwt.sign({ nonce, redirectUrl, token }, config.jwtSecret, { + expiresIn: "1h", + }), + { + path: "/", + sameSite: "none", // Same-Site must be set to none, otherwise cookie will not be available. + }, + ) return new Response(undefined, { status: 302, headers: { location: authorizationUrl }, diff --git a/src/routes/auth/login_callback/+server.ts b/src/routes/auth/login_callback/+server.ts index 2c194f2d2342d18b5f5eb707f5969c8fb03fe291..29a482a592507213c34440d4d8459c907ccf8ced 100644 --- a/src/routes/auth/login_callback/+server.ts +++ b/src/routes/auth/login_callback/+server.ts @@ -1,103 +1,67 @@ -import type { ZammadUser } from "@progedo/lib" -import type { RequestHandler } from "@sveltejs/kit" -import dedent from "dedent-js" +import { error, type RequestHandler } from "@sveltejs/kit" +import jwt from "jsonwebtoken" +import type { Client as OpenIdConnectClient } from "openid-client" -import { db } from "$lib/server/database" -import { hashJsonObject, type SessionRecord } from "$lib/server/session_handler" -import { fetchZammadApi, fetchZammadApiList } from "$lib/server/zammad" +import config from "$lib/server/config" +import type { SigninPayload } from "$lib/users" -export const POST: RequestHandler = async ({ locals, request, url }) => { - const client = locals.openIdConnectClient +export const POST: RequestHandler = async ({ + cookies, + locals, + request, + url, +}) => { + const openIdConnectClient = locals.openIdConnectClient as OpenIdConnectClient // Since method callbackParams doesn't accept a SvelteKit request as a parameter, // create a dummy URL for a GET request and use it as a parameter of callbackParams. const queryString = await request.text() const fakeUrlString = new URL("?" + queryString, url).toString() - const params = client.callbackParams(fakeUrlString) - // Since session cookie is not sent after a redirection from authentication server, - // use params.state as session token and delete newly created empty session. - await db.none( - dedent` - DELETE FROM sessions - WHERE id = $<id> - `, - { id: locals.sessionId }, - ) - const token = params.state - const sessionRecord: SessionRecord | null = await db.oneOrNone( - dedent` - SELECT * - FROM sessions - WHERE token = $<token> - `, - { token }, - ) - if (sessionRecord === null) { - console.error( - "OpenIDConnect authentication failed: Unable to retrieve current session from state token.", - ) - return new Response(`Authentication failed: Unable to retrieve current session.`, { status: 302, headers: { location: "/" } }) + const params = openIdConnectClient.callbackParams(fakeUrlString) + const signinJwt = cookies.get("signin") + cookies.delete("signin", { path: "/" }) + if (signinJwt === undefined) { + console.warn(`Authentication failed: Missing signin cookie`) + return new Response(`Authentication failed: Missing signin cookie.`, { + status: 302, + headers: { location: "/" }, + }) + } + let signinPayload: SigninPayload + try { + signinPayload = jwt.verify(signinJwt, config.jwtSecret) as SigninPayload + } catch (e) { + console.error(`Invalid JSON Web Token: ${signinJwt}. ${e}`) + throw error(401, `Invalid JSON Web Token`) + } + if (params.state !== signinPayload.token) { + console.warn("Authentication failed: Token mismatch") + return new Response(`Authentication failed: Token mismatch.`, { + status: 302, + headers: { location: "/" }, + }) } - const session = sessionRecord.data as App.Locals["session"] - session.token = sessionRecord.token - locals.session = session - locals.sessionHash = hashJsonObject(session) - locals.sessionId = sessionRecord.id - - // Remove token from session after sessionHash has been computed, - // to ensure that session will be updated by session handler. - delete session.token // State must be removed from params before calling callback method. delete params.state - const tokenSet = await client.callback(url.toString(), params, { - nonce: session.nonce as string, + const tokenSet = await openIdConnectClient.callback(url.toString(), params, { + nonce: signinPayload.nonce, }) - delete session.nonce // console.log("Received tokenSet:", tokenSet) - const authentication = tokenSet.claims() - // console.log("Received authentication claims:", authentication) + const user = tokenSet.claims() + // console.log("Received authentication claims:", user) - let user: (ZammadUser & { error: unknown }) | undefined = undefined - const users = (await fetchZammadApiList( - `users/search?expand=true&query=${encodeURIComponent( - authentication.email, - )}`, - )) as ZammadUser[] & { error: unknown } - if (users.error !== undefined) { - console.error( - `OpenIDConnect authentication failed: An error occurred when retrieving user by its email ${authentication.email}: ${users.error}`, - ) - return new Response(`OpenIDConnect authentication failed: An error occurred when retrieving user by its email ${authentication.email}: ${users.error}`, { status: 302, headers: { location: "/" } }) + if (tokenSet.id_token !== undefined) { + cookies.set("id_token", jwt.sign(tokenSet.id_token, config.jwtSecret), { + path: "/", + }) } - user = users.find(({ email }) => email === authentication.email) as - | (ZammadUser & { error: unknown }) - | undefined - if (user === undefined) { - user = (await fetchZammadApi("users?expand=true", { - body: JSON.stringify({ - email: authentication.email, - firstname: authentication.given_name, - lastname: authentication.family_name, - }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", - })) as ZammadUser & { error: unknown } - if (user.error !== undefined) { - console.error( - `OpenIDConnect authentication failed: An error occurred while creating Zammad user ${authentication.email}: ${user.error}`, - ) - return new Response(`OpenIDConnect authentication failed: An error occurred while creating Zammad user ${authentication.email}: ${user.error}`, { status: 302, headers: { location: "/" } }) - } - } - locals.user = user - session.userId = user.id - // Keep id_token, to use it for single logout. - session.userIdToken = tokenSet.id_token + cookies.set("user", jwt.sign(user, config.jwtSecret), { + path: "/", + }) - const redirectUrl = session.redirectUrl - delete session.redirectUrl - return new Response(undefined, { status: 302, headers: { location: redirectUrl ?? "/" } }) + return new Response(undefined, { + status: 302, + headers: { location: signinPayload.redirectUrl }, + }) } diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index d93a3135dc39bc2e4fad06d804430385bde7b5cb..111e48230947cf6faa6999bff1286cc0479326f5 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -1,10 +1,12 @@ import { error } from "@sveltejs/kit" +import jwt from "jsonwebtoken" import { validateLoginLogoutQuery } from "$lib/server/auditors/queries" +import config from "$lib/server/config" import type { RequestHandler } from "./$types" -export const GET: RequestHandler = ({ locals, url }) => { +export const GET: RequestHandler = ({ cookies, locals, url }) => { const [query, queryError] = validateLoginLogoutQuery(url.searchParams) as [ { redirect: string }, unknown, @@ -28,24 +30,41 @@ export const GET: RequestHandler = ({ locals, url }) => { } const { redirect: redirectUrl } = query - const { session } = locals - if (session.userIdToken === undefined) { - delete locals.user - delete session.userId + cookies.delete("id_token", { path: "/" }) + cookies.delete("user", { path: "/" }) + const idToken = locals.id_token + delete locals.id_token + delete locals.user + + if (idToken === undefined) { return new Response(undefined, { status: 302, headers: { location: redirectUrl }, }) } - session.redirectUrl = redirectUrl - const endSessionUrl = locals.openIdConnectClient.endSessionUrl({ - id_token_hint: session.userIdToken, - }) - delete locals.user - delete session.userId - delete session.userIdToken - return new Response(undefined, { + if (locals.openIdConnectClient !== undefined) { + cookies.set( + "signout", + jwt.sign({ redirectUrl }, config.jwtSecret, { + expiresIn: "1h", + }), + { + path: "/", + sameSite: "none", // Same-Site must be set to none, otherwise cookie will not be available. + }, + ) + const endSessionUrl = locals.openIdConnectClient.endSessionUrl({ + id_token_hint: idToken, + }) + return new Response(undefined, { + status: 302, + headers: { location: endSessionUrl }, + }) + } + + console.error(`No authentication method defined`) + return new Response("No authentication method defined", { status: 302, - headers: { location: endSessionUrl }, + headers: { location: "/" }, }) } diff --git a/src/routes/auth/logout_callback/+server.ts b/src/routes/auth/logout_callback/+server.ts index 5e8932dd4816bd1df01ed241bf43ea2efb740b1d..8956b0c6c2dcee979f6e0a0fec0e74aaad7c61e3 100644 --- a/src/routes/auth/logout_callback/+server.ts +++ b/src/routes/auth/logout_callback/+server.ts @@ -1,8 +1,34 @@ -import type { RequestHandler } from "@sveltejs/kit" +import { error, type RequestHandler } from "@sveltejs/kit" +import jwt from "jsonwebtoken" +import type { Client as OpenIdConnectClient } from "openid-client" -export const GET: RequestHandler = async ({ locals }) => { - const { session } = locals - const { redirectUrl } = session - delete session.redirectUrl - return new Response(undefined, { status: 302, headers: { location: redirectUrl ?? "/" } }) +import config from "$lib/server/config" +import type { SignoutPayload } from "$lib/users" + +export const GET: RequestHandler = async ({ cookies, locals }) => { + const openIdConnectClient = locals.openIdConnectClient as OpenIdConnectClient + // Since method callbackParams doesn't accept a SvelteKit request as a parameter, + // create a dummy URL for a GET request and use it as a parameter of callbackParams. + const signoutJwt = cookies.get("signout") + cookies.delete("signout", { path: "/" }) + + if (signoutJwt === undefined) { + console.warn(`Logout failed: Missing signout cookie`) + return new Response(`Logout failed: Missing signout cookie.`, { + status: 302, + headers: { location: "/" }, + }) + } + let signoutPayload: SignoutPayload + try { + signoutPayload = jwt.verify(signoutJwt, config.jwtSecret) as SignoutPayload + } catch (e) { + console.error(`Invalid JSON Web Token: ${signoutJwt}. ${e}`) + throw error(401, `Invalid JSON Web Token`) + } + + return new Response(undefined, { + status: 302, + headers: { location: signoutPayload.redirectUrl }, + }) }