Skip to main content
Sign in
Snippets Groups Projects
Commit 39fb247a authored by Emmanuel Raviart's avatar Emmanuel Raviart
Browse files

Complete migration from sk-auth to oauth2-client

parent dedf4814
No related branches found
No related tags found
1 merge request!117Migration from sk-auth to oauth2-client
Pipeline #7133 passed
...@@ -5,27 +5,8 @@ declare namespace App { ...@@ -5,27 +5,8 @@ declare namespace App {
type OpenIdConnectLocals = type OpenIdConnectLocals =
import("$lib/server/openid_connect_handler").OpenIdConnectLocals import("$lib/server/openid_connect_handler").OpenIdConnectLocals
type UserLocals = import("$lib/server/user_handler").UserLocals type UserLocals = import("$lib/server/user_handler").UserLocals
interface Locals interface Locals extends UserLocals, OpenIdConnectLocals {
extends CookiesLocals, id_token?: string
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
}
user?: import("$lib/users").User user?: import("$lib/users").User
} }
// interface PageData {} // interface PageData {}
... ...
......
import type { Auditor } from "@auditors/core"
import { import {
auditArray, auditArray,
auditChain, auditChain,
...@@ -9,6 +8,7 @@ import { ...@@ -9,6 +8,7 @@ import {
auditString, auditString,
auditSwitch, auditSwitch,
auditTest, auditTest,
type Auditor,
} from "@auditors/core" } from "@auditors/core"
export function auditQueryArray(...auditors: Auditor[]) { export function auditQueryArray(...auditors: Auditor[]) {
... ...
......
...@@ -4,20 +4,13 @@ import { laxAudit } from "@auditors/core" ...@@ -4,20 +4,13 @@ import { laxAudit } from "@auditors/core"
export function auditJsonResponse(auditor?: Auditor | null) { export function auditJsonResponse(auditor?: Auditor | null) {
return async function ( return async function (
audit: Audit, audit: Audit,
responseUnknown: unknown, response: Response,
): Promise<[unknown, unknown]> { ): Promise<[unknown, unknown]> {
if (responseUnknown == null) { if (response == null) {
return [responseUnknown, null] return [response, null]
} }
if (typeof responseUnknown !== "object") { if (typeof response !== "object") {
return audit.unexpectedType(responseUnknown, "object") return audit.unexpectedType(response, "object")
}
const response = responseUnknown as {
ok: boolean
json: () => Promise<{ error?: { details?: unknown } }>
status: number
statusText: string
text: () => Promise<string>
} }
if (!response.ok && (response.status < 400 || response.status >= 404)) { if (!response.ok && (response.status < 400 || response.status >= 404)) {
return [ return [
...@@ -51,7 +44,7 @@ export function auditJsonResponse(auditor?: Auditor | null) { ...@@ -51,7 +44,7 @@ export function auditJsonResponse(auditor?: Auditor | null) {
} }
export function validateJsonResponse(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) return auditJsonResponse(auditor)(laxAudit, response)
} }
} }
...@@ -10,9 +10,19 @@ ...@@ -10,9 +10,19 @@
$: authenticationEnabled = $page.data.authenticationEnabled $: 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() { function help() {
$showTutorial = true $showTutorial = true
...@@ -162,7 +172,7 @@ ...@@ -162,7 +172,7 @@
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<a <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" 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"> <div class="flex items-center">
<!-- Material icon: VPN key--> <!-- Material icon: VPN key-->
...@@ -233,8 +243,8 @@ ...@@ -233,8 +243,8 @@
aria-labelledby="user-menu" aria-labelledby="user-menu"
> >
<a <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" 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" type="button"
> >
<div class="flex items-center"> <div class="flex items-center">
...@@ -339,8 +349,8 @@ ...@@ -339,8 +349,8 @@
{#if authenticationEnabled} {#if authenticationEnabled}
{#if user === undefined} {#if user === undefined}
<a <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" class="block border-b px-4 py-3 text-sm font-bold text-gray-700 hover:bg-gray-100"
href={loginUrl}
role="menuitem" role="menuitem"
> >
<!-- Material icon: VPN key--> <!-- Material icon: VPN key-->
...@@ -357,8 +367,8 @@ ...@@ -357,8 +367,8 @@
> >
{:else} {:else}
<a <a
href="/authentication/signout"
class="block border-b-4 px-4 py-3 text-base text-gray-700 hover:bg-gray-100" class="block border-b-4 px-4 py-3 text-base text-gray-700 hover:bg-gray-100"
href={logoutUrl}
role="menuitem" role="menuitem"
> >
<!-- Material icon: Power Settings New--> <!-- Material icon: Power Settings New-->
... ...
......
import type { Handle } from "@sveltejs/kit" import type { Handle } from "@sveltejs/kit"
import { import {
type Client as OpenIdConnectClient,
custom as openIdConnectCustom, custom as openIdConnectCustom,
Issuer as OpenIdConnectIssuer, Issuer as OpenIdConnectIssuer,
type Client as OpenIdConnectClient,
} from "openid-client" } from "openid-client"
import config from "$lib/server/config" import config from "$lib/server/config"
... ...
......
...@@ -10,12 +10,22 @@ export interface UserLocals { ...@@ -10,12 +10,22 @@ export interface UserLocals {
export const userHandler: Handle = async ({ event, resolve }) => { export const userHandler: Handle = async ({ event, resolve }) => {
const { cookies, locals } = event 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") const userJwt = cookies.get("user")
if (userJwt !== undefined) { if (userJwt !== undefined) {
try { try {
locals.user = jwt.verify(userJwt, config.jwtSecret) as User locals.user = jwt.verify(userJwt, config.jwtSecret) as User
} catch (e) { } catch (e) {
console.warn(`Invalid JSON Web Token: ${userJwt}. ${e}`) console.warn(`Invalid JSON Web Token for user: ${userJwt}. ${e}`)
} }
} }
return await resolve(event) return await resolve(event)
... ...
......
export interface SigninPayload {
nonce: string
redirectUrl: string
token: string
}
export interface SignoutPayload {
redirectUrl: string
}
export interface User { export interface User {
email: string // "john@doe.com" email: string // "john@doe.com"
email_verified: boolean email_verified: boolean
...@@ -7,6 +17,6 @@ export interface User { ...@@ -7,6 +17,6 @@ export interface User {
locale: string // "fr" locale: string // "fr"
name: string // "John Doe" name: string // "John Doe"
preferred_username: string // "jdoe", 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" sub: string // "12345678-9abc-def0-1234-56789abcdef0"
} }
import type { RepositoryConfig } from "$lib/repositories" import type { RepositoryConfig } from "$lib/repositories"
import type { User } from "$lib/users" import type { User } from "$lib/users"
import config from "$lib/server/config" import config from "$lib/server/config"
import { oauth2Authenticator } from "$lib/server/oauth2"
import type { LayoutServerLoad } from "./$types" import type { LayoutServerLoad } from "./$types"
const { githubPersonalAccessToken, openfiscaRepository } = config const { githubPersonalAccessToken, openfiscaRepository, openIdConnect } = config
export const load: LayoutServerLoad = async ( export const load: LayoutServerLoad = async (
event, event,
...@@ -36,13 +35,13 @@ export const load: LayoutServerLoad = async ( ...@@ -36,13 +35,13 @@ export const load: LayoutServerLoad = async (
title: string title: string
user?: User user?: User
}> => { }> => {
const oauth2Session = await oauth2Authenticator?.getSession(event) const { locals } = event
const user = oauth2Session?.user const { user } = locals
return { return {
advanced: config.advanced, advanced: config.advanced,
apiBaseUrls: config.apiBaseUrls, apiBaseUrls: config.apiBaseUrls,
apiWebSocketBaseUrls: config.apiWebSocketBaseUrls, apiWebSocketBaseUrls: config.apiWebSocketBaseUrls,
authenticationEnabled: oauth2Authenticator !== undefined, authenticationEnabled: openIdConnect !== undefined,
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
childrenKey: config.childrenKey, childrenKey: config.childrenKey,
familyEntityKey: config.familyEntityKey, familyEntityKey: config.familyEntityKey,
... ...
......
import { error } from "@sveltejs/kit" import { error } from "@sveltejs/kit"
import jwt from "jsonwebtoken"
import { generators } from "openid-client" import { generators } from "openid-client"
import { validateLoginLogoutQuery } from "$lib/server/auditors/queries" import { validateLoginLogoutQuery } from "$lib/server/auditors/queries"
import config from "$lib/server/config"
import type { RequestHandler } from "./$types" 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 [ const [query, queryError] = validateLoginLogoutQuery(url.searchParams) as [
{ redirect: string }, { redirect: string },
unknown, unknown,
...@@ -29,20 +31,25 @@ export const GET: RequestHandler = async ({ locals, url }) => { ...@@ -29,20 +31,25 @@ export const GET: RequestHandler = async ({ locals, url }) => {
} }
const { redirect: redirectUrl } = query const { redirect: redirectUrl } = query
const session = locals.session
if (locals.openIdConnectClient !== undefined) { if (locals.openIdConnectClient !== undefined) {
const nonce = generators.nonce() const nonce = generators.nonce()
session.nonce = nonce
session.redirectUrl = redirectUrl
const token = crypto.randomUUID() const token = crypto.randomUUID()
session.token = token
const authorizationUrl = locals.openIdConnectClient.authorizationUrl({ const authorizationUrl = locals.openIdConnectClient.authorizationUrl({
scope: "openid email profile", scope: "openid email profile",
response_mode: "form_post", response_mode: "form_post",
nonce, nonce,
state: token, 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, { return new Response(undefined, {
status: 302, status: 302,
headers: { location: authorizationUrl }, headers: { location: authorizationUrl },
... ...
......
import type { ZammadUser } from "@progedo/lib" import { error, type RequestHandler } from "@sveltejs/kit"
import type { RequestHandler } from "@sveltejs/kit" import jwt from "jsonwebtoken"
import dedent from "dedent-js" import type { Client as OpenIdConnectClient } from "openid-client"
import { db } from "$lib/server/database" import config from "$lib/server/config"
import { hashJsonObject, type SessionRecord } from "$lib/server/session_handler" import type { SigninPayload } from "$lib/users"
import { fetchZammadApi, fetchZammadApiList } from "$lib/server/zammad"
export const POST: RequestHandler = async ({ locals, request, url }) => { export const POST: RequestHandler = async ({
const client = locals.openIdConnectClient cookies,
locals,
request,
url,
}) => {
const openIdConnectClient = locals.openIdConnectClient as OpenIdConnectClient
// Since method callbackParams doesn't accept a SvelteKit request as a parameter, // 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. // create a dummy URL for a GET request and use it as a parameter of callbackParams.
const queryString = await request.text() const queryString = await request.text()
const fakeUrlString = new URL("?" + queryString, url).toString() const fakeUrlString = new URL("?" + queryString, url).toString()
const params = client.callbackParams(fakeUrlString) const params = openIdConnectClient.callbackParams(fakeUrlString)
// Since session cookie is not sent after a redirection from authentication server, const signinJwt = cookies.get("signin")
// use params.state as session token and delete newly created empty session. cookies.delete("signin", { path: "/" })
await db.none( if (signinJwt === undefined) {
dedent` console.warn(`Authentication failed: Missing signin cookie`)
DELETE FROM sessions return new Response(`Authentication failed: Missing signin cookie.`, {
WHERE id = $<id> status: 302,
`, headers: { location: "/" },
{ id: locals.sessionId }, })
) }
const token = params.state let signinPayload: SigninPayload
const sessionRecord: SessionRecord | null = await db.oneOrNone( try {
dedent` signinPayload = jwt.verify(signinJwt, config.jwtSecret) as SigninPayload
SELECT * } catch (e) {
FROM sessions console.error(`Invalid JSON Web Token: ${signinJwt}. ${e}`)
WHERE token = $<token> throw error(401, `Invalid JSON Web Token`)
`, }
{ token }, if (params.state !== signinPayload.token) {
) console.warn("Authentication failed: Token mismatch")
if (sessionRecord === null) { return new Response(`Authentication failed: Token mismatch.`, {
console.error( status: 302,
"OpenIDConnect authentication failed: Unable to retrieve current session from state token.", headers: { location: "/" },
) })
return new Response(`Authentication failed: Unable to retrieve current session.`, { 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. // State must be removed from params before calling callback method.
delete params.state delete params.state
const tokenSet = await client.callback(url.toString(), params, { const tokenSet = await openIdConnectClient.callback(url.toString(), params, {
nonce: session.nonce as string, nonce: signinPayload.nonce,
}) })
delete session.nonce
// console.log("Received tokenSet:", tokenSet) // console.log("Received tokenSet:", tokenSet)
const authentication = tokenSet.claims() const user = tokenSet.claims()
// console.log("Received authentication claims:", authentication) // console.log("Received authentication claims:", user)
let user: (ZammadUser & { error: unknown }) | undefined = undefined if (tokenSet.id_token !== undefined) {
const users = (await fetchZammadApiList( cookies.set("id_token", jwt.sign(tokenSet.id_token, config.jwtSecret), {
`users/search?expand=true&query=${encodeURIComponent( path: "/",
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: "/" } })
}
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 cookies.set("user", jwt.sign(user, config.jwtSecret), {
session.userId = user.id path: "/",
// Keep id_token, to use it for single logout. })
session.userIdToken = tokenSet.id_token
const redirectUrl = session.redirectUrl return new Response(undefined, {
delete session.redirectUrl status: 302,
return new Response(undefined, { status: 302, headers: { location: redirectUrl ?? "/" } }) headers: { location: signinPayload.redirectUrl },
})
} }
import { error } from "@sveltejs/kit" import { error } from "@sveltejs/kit"
import jwt from "jsonwebtoken"
import { validateLoginLogoutQuery } from "$lib/server/auditors/queries" import { validateLoginLogoutQuery } from "$lib/server/auditors/queries"
import config from "$lib/server/config"
import type { RequestHandler } from "./$types" 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 [ const [query, queryError] = validateLoginLogoutQuery(url.searchParams) as [
{ redirect: string }, { redirect: string },
unknown, unknown,
...@@ -28,24 +30,41 @@ export const GET: RequestHandler = ({ locals, url }) => { ...@@ -28,24 +30,41 @@ export const GET: RequestHandler = ({ locals, url }) => {
} }
const { redirect: redirectUrl } = query const { redirect: redirectUrl } = query
const { session } = locals cookies.delete("id_token", { path: "/" })
if (session.userIdToken === undefined) { cookies.delete("user", { path: "/" })
const idToken = locals.id_token
delete locals.id_token
delete locals.user delete locals.user
delete session.userId
if (idToken === undefined) {
return new Response(undefined, { return new Response(undefined, {
status: 302, status: 302,
headers: { location: redirectUrl }, headers: { location: redirectUrl },
}) })
} }
session.redirectUrl = redirectUrl 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({ const endSessionUrl = locals.openIdConnectClient.endSessionUrl({
id_token_hint: session.userIdToken, id_token_hint: idToken,
}) })
delete locals.user
delete session.userId
delete session.userIdToken
return new Response(undefined, { return new Response(undefined, {
status: 302, status: 302,
headers: { location: endSessionUrl }, headers: { location: endSessionUrl },
}) })
} }
console.error(`No authentication method defined`)
return new Response("No authentication method defined", {
status: 302,
headers: { location: "/" },
})
}
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 }) => { import config from "$lib/server/config"
const { session } = locals import type { SignoutPayload } from "$lib/users"
const { redirectUrl } = session
delete session.redirectUrl export const GET: RequestHandler = async ({ cookies, locals }) => {
return new Response(undefined, { status: 302, headers: { location: redirectUrl ?? "/" } }) 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 },
})
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment