feat(frontend/auth): fix cookie not working due to path being /app

Cookies being set to path=/app meant that the API didn't have those
cookies.
Also fixed the Schema injection for auth'ed routes
This commit is contained in:
Maieul BOYER 2025-11-10 18:43:34 +01:00 committed by Maix0
parent e8b0b7e310
commit aba4c4498c
5 changed files with 151 additions and 131 deletions

View file

@ -9,12 +9,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@types/js-cookie": "^3.0.6",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.1.10", "vite": "^7.1.10",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"js-cookie": "^3.0.5",
"openapi-fetch": "^0.15.0", "openapi-fetch": "^0.15.0",
"tailwindcss": "^4.1.16" "tailwindcss": "^4.1.16"
} }

View file

@ -11,6 +11,9 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)) version: 4.1.16(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2))
js-cookie:
specifier: ^3.0.5
version: 3.0.5
openapi-fetch: openapi-fetch:
specifier: ^0.15.0 specifier: ^0.15.0
version: 0.15.0 version: 0.15.0
@ -18,6 +21,9 @@ importers:
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16 version: 4.1.16
devDependencies: devDependencies:
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
typescript: typescript:
specifier: ~5.9.3 specifier: ~5.9.3
version: 5.9.3 version: 5.9.3
@ -405,6 +411,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -451,6 +460,10 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -867,6 +880,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/js-cookie@3.0.6': {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -920,6 +935,8 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
js-cookie@3.0.5: {}
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
optional: true optional: true

View file

@ -3,148 +3,149 @@ import { showError, showInfo, showSuccess } from "@app/toast";
import authHtml from './login.html?raw'; import authHtml from './login.html?raw';
import client from '@app/api' import client from '@app/api'
import { updateUser } from "@app/auth"; import { updateUser } from "@app/auth";
import Cookie from 'js-cookie';
type Providers = { type Providers = {
name: string, name: string,
display_name: string, display_name: string,
icon_url?: string, icon_url?: string,
color?: { default: string, hover: string }, color?: { default: string, hover: string },
}; };
function handleLogin(_url: string, _args: RouteHandlerParams): RouteHandlerReturn { function handleLogin(_url: string, _args: RouteHandlerParams): RouteHandlerReturn {
setTitle('Login') setTitle('Login')
return { return {
html: authHtml, postInsert: async (app) => { html: authHtml, postInsert: async (app) => {
const fLogin = document.querySelector<HTMLFormElement>('form#login-form'); const fLogin = document.querySelector<HTMLFormElement>('form#login-form');
if (fLogin === null) if (fLogin === null)
return showError('Error while rendering the page: no form found'); return showError('Error while rendering the page: no form found');
showSuccess('got the form !') showSuccess('got the form !')
fLogin.addEventListener('submit', async function(e: SubmitEvent) { fLogin.addEventListener('submit', async function(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
let form = e.target as (HTMLFormElement | null) let form = e.target as (HTMLFormElement | null)
if (form === null) if (form === null)
return showError('Failed to send form...'); return showError('Failed to send form...');
let formData = Object.fromEntries((new FormData(form)).entries()); let formData = Object.fromEntries((new FormData(form)).entries());
if (!('login' in formData) || typeof formData['login'] !== 'string' || (formData['login'] as string).length === 0) if (!('login' in formData) || typeof formData['login'] !== 'string' || (formData['login'] as string).length === 0)
return showError('Please enter a Login'); return showError('Please enter a Login');
if (!('password' in formData) || typeof formData['password'] !== 'string' || (formData['password'] as string).length === 0) if (!('password' in formData) || typeof formData['password'] !== 'string' || (formData['password'] as string).length === 0)
return showError('Please enter a Password'); return showError('Please enter a Password');
try { try {
const res = await client.login({ loginRequest: { name: formData.login, password: formData.password } }); const res = await client.login({ loginRequest: { name: formData.login, password: formData.password } });
switch (res.kind) { switch (res.kind) {
case 'success': { case 'success': {
document.cookie = `token=${res.payload.token}`; Cookie.set('token', res.payload.token, { path: '/', sameSite: 'lax' });
let user = await updateUser(); let user = await updateUser();
if (user === null) if (user === null)
return showError('Failed to get user: no user ?'); return showError('Failed to get user: no user ?');
setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`); setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`);
break; break;
} }
case 'otpRequired': { case 'otpRequired': {
showInfo('Got ask OTP, not yet implemented'); showInfo('Got ask OTP, not yet implemented');
break; break;
} }
case 'failed': { case 'failed': {
showError(`Failed to login: ${res.msg}`); showError(`Failed to login: ${res.msg}`);
} }
} }
} catch (e) { } catch (e) {
console.error("Login error:", e); console.error("Login error:", e);
showError('Failed to login: Unknown error'); showError('Failed to login: Unknown error');
} }
}); });
const bLoginAsGuest = document.querySelector<HTMLButtonElement>('#bGuestLogin'); const bLoginAsGuest = document.querySelector<HTMLButtonElement>('#bGuestLogin');
bLoginAsGuest?.addEventListener('click', async () => { bLoginAsGuest?.addEventListener('click', async () => {
try { try {
const res = await client.guestLogin(); const res = await client.guestLogin();
switch (res.kind) { switch (res.kind) {
case 'success': { case 'success': {
document.cookie = `token=${res.payload.token}`; Cookie.set('token', res.payload.token, { path: '/', sameSite: 'lax' });
let user = await updateUser(); let user = await updateUser();
if (user === null) if (user === null)
return showError('Failed to get user: no user ?'); return showError('Failed to get user: no user ?');
setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`); setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`);
break; break;
} }
case 'failed': { case 'failed': {
showError(`Failed to login: ${res.msg}`); showError(`Failed to login: ${res.msg}`);
} }
} }
} catch (e) { } catch (e) {
console.error("Login error:", e); console.error("Login error:", e);
showError('Failed to login: Unknown error'); showError('Failed to login: Unknown error');
} }
}); });
const dOtherLoginArea = document.querySelector<HTMLDivElement>('#otherLogin'); const dOtherLoginArea = document.querySelector<HTMLDivElement>('#otherLogin');
if (dOtherLoginArea) { if (dOtherLoginArea) {
let styleSheetElement = document.createElement('style'); let styleSheetElement = document.createElement('style');
styleSheetElement.innerText = ""; styleSheetElement.innerText = "";
// TODO: fetch all the providers from an API ? // TODO: fetch all the providers from an API ?
const providers: Providers[] = [ const providers: Providers[] = [
{ name: 'discord', display_name: 'Discord', color: { default: 'bg-[#5865F2]', hover: '#FF65F2' } }, { name: 'discord', display_name: 'Discord', color: { default: 'bg-[#5865F2]', hover: '#FF65F2' } },
{ name: 'kanidm', display_name: 'Kanidm', color: { default: 'bg-red-500', hover: 'bg-red-700' } }, { name: 'kanidm', display_name: 'Kanidm', color: { default: 'bg-red-500', hover: 'bg-red-700' } },
{ name: 'google', display_name: 'Google' }, { name: 'google', display_name: 'Google' },
] ]
let first = true; let first = true;
for (const p of providers) { for (const p of providers) {
let b = document.createElement('button'); let b = document.createElement('button');
if (first) b.classList.add('last:col-span-2'); if (first) b.classList.add('last:col-span-2');
first = false; first = false;
b.classList.add(...( b.classList.add(...(
'w-full text-white font-medium py-2 rounded-xl transition' 'w-full text-white font-medium py-2 rounded-xl transition'
.split(' ') .split(' ')
)); ));
b.classList.add(`providerButton-${p.name}`) b.classList.add(`providerButton-${p.name}`)
const col = { default: p.color?.default ?? "bg-gray-600", hover: p.color?.hover ?? "bg-gray-700" }; const col = { default: p.color?.default ?? "bg-gray-600", hover: p.color?.hover ?? "bg-gray-700" };
for (const k of Object.keys(col)) { for (const k of Object.keys(col)) {
let c = (col as { [k: string]: string })[k].trim(); let c = (col as { [k: string]: string })[k].trim();
if (c.startsWith('bg-')) { if (c.startsWith('bg-')) {
c = c.replace(/^bg-/, ''); c = c.replace(/^bg-/, '');
const customProp = c.match(/^\((.+)\)$/); const customProp = c.match(/^\((.+)\)$/);
const customVal = c.match(/^\[(.+)\]$/); const customVal = c.match(/^\[(.+)\]$/);
if (customProp) if (customProp)
c = `var(${customProp[1]})` c = `var(${customProp[1]})`
else if (customVal) else if (customVal)
c = customVal[1]; c = customVal[1];
else if (c === 'inherit') else if (c === 'inherit')
c = 'inherit'; c = 'inherit';
else if (c === 'current') else if (c === 'current')
c = 'currentColor'; c = 'currentColor';
else if (c === 'transparent') else if (c === 'transparent')
c = 'transparent'; c = 'transparent';
else else
c = `var(--color-${c})` c = `var(--color-${c})`
} }
(col as { [k: string]: string })[k] = c; (col as { [k: string]: string })[k] = c;
} }
styleSheetElement.innerText += `.providerButton-${p.name} { background-color: ${col.default}; }\n`; styleSheetElement.innerText += `.providerButton-${p.name} { background-color: ${col.default}; }\n`;
styleSheetElement.innerText += `.providerButton-${p.name}:hover { background-color: ${col.hover}; }\n`; styleSheetElement.innerText += `.providerButton-${p.name}:hover { background-color: ${col.hover}; }\n`;
b.dataset.display_name = p.display_name; b.dataset.display_name = p.display_name;
b.dataset.name = p.name; b.dataset.name = p.name;
if (p.icon_url) b.dataset.icon = p.icon_url; if (p.icon_url) b.dataset.icon = p.icon_url;
b.innerHTML = ` b.innerHTML = `
${p.icon_url ? `<img src="${p.icon_url}" alt="${p.display_name} Logo" />` : ''} <span class="">${p.display_name}</span> ${p.icon_url ? `<img src="${p.icon_url}" alt="${p.display_name} Logo" />` : ''} <span class="">${p.display_name}</span>
` `
b.addEventListener('click', () => { b.addEventListener('click', () => {
location.href = `/api/auth/oauth2/${p.name}/login`; location.href = `/api/auth/oauth2/${p.name}/login`;
}) })
dOtherLoginArea.insertAdjacentElement('afterbegin', b); dOtherLoginArea.insertAdjacentElement('afterbegin', b);
} }
app?.appendChild(styleSheetElement); app?.appendChild(styleSheetElement);
} }
} }
}; };
} }

View file

@ -41,7 +41,7 @@ http {
} }
location / { location / {
proxy_ssl_verify off; proxy_ssl_verify off;
return 301 'https://$http_host/app/$request_uri'; proxy_pass http://localhost:5173/;
} }
} }
} }

View file

@ -90,10 +90,10 @@ export const authPlugin = fp<{ onlySchema?: boolean }>(async (fastify, { onlySch
let schema: TSchema = authSchema; let schema: TSchema = authSchema;
if ('401' in (routeOpts.schema.response as { [k: string]: TSchema })) { if ('401' in (routeOpts.schema.response as { [k: string]: TSchema })) {
const schema_orig = (routeOpts.schema.response as { [k: string]: TSchema })['401']; const schema_orig = (routeOpts.schema.response as { [k: string]: TSchema })['401'];
if (schema_orig[Typebox.Kind] === 'Union') { if (Type.IsUnion(schema_orig)) {
schema = Typebox.Union([...((schema_orig as Typebox.TUnion).anyOf), authSchema]); schema = Typebox.Union([...((schema_orig as Typebox.TUnion).anyOf), authSchema]);
} }
else if (schema_orig[Typebox.Kind] === 'Object') { else if (Type.IsObject(schema_orig)) {
schema = Typebox.Union([schema_orig, authSchema]); schema = Typebox.Union([schema_orig, authSchema]);
} }
} }
@ -103,26 +103,26 @@ export const authPlugin = fp<{ onlySchema?: boolean }>(async (fastify, { onlySch
try { try {
if (isNullish(req.cookies.token)) { if (isNullish(req.cookies.token)) {
return res return res
.clearCookie('token') .clearCookie('token', { path: '/' })
.makeResponse(401, 'notLoggedIn', 'auth.noCookie'); .makeResponse(401, 'notLoggedIn', 'auth.noCookie');
} }
const tok = this.jwt.verify<JwtType>(req.cookies.token); const tok = this.jwt.verify<JwtType>(req.cookies.token);
if (tok.kind != 'auth') { if (tok.kind != 'auth') {
return res return res
.clearCookie('token') .clearCookie('token', { path: '/' })
.makeResponse(401, 'notLoggedIn', 'auth.invalidKind'); .makeResponse(401, 'notLoggedIn', 'auth.invalidKind');
} }
const user = this.db.getUser(tok.who); const user = this.db.getUser(tok.who);
if (isNullish(user)) { if (isNullish(user)) {
return res return res
.clearCookie('token') .clearCookie('token', { path: '/' })
.makeResponse(401, 'notLoggedIn', 'auth.noUser'); .makeResponse(401, 'notLoggedIn', 'auth.noUser');
} }
req.authUser = { id: user.id, name: tok.who }; req.authUser = { id: user.id, name: tok.who };
} }
catch { catch {
return res return res
.clearCookie('token') .clearCookie('token', { path: '/' })
.makeResponse(401, 'notLoggedIn', 'auth.invalid'); .makeResponse(401, 'notLoggedIn', 'auth.invalid');
} }
}; };