diff --git a/.env.example b/.env.example index 8ea2d70..c9f5441 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,27 @@ +# Front-end public URL, without leading slash PUBLIC_URL=http://localhost:5173 + +# Site name, displayed on the UI and in emails PUBLIC_SITE_NAME=Amanita SSO + +# Database connection (mysql) DATABASE_HOST=localhost DATABASE_DB=icyauth DATABASE_USER=icyauth DATABASE_PASS=icyauth + +# Secret keys for sessions and challenges +# These keys should be rotated as part of regular maintenance SESSION_SECRET=32 char key CHALLENGE_SECRET=64 char key + +# OpenID Connect JWT (ID token) settings +# Private keys for JWTs are stored as files in the private directory JWT_ALGORITHM=RS256 JWT_EXPIRATION=7d JWT_ISSUER=http://localhost:5173 + +# SMTP settings EMAIL_ENABLED=true EMAIL_FROM=no-reply@icynet.eu EMAIL_SMTP_HOST=mail.icynet.eu @@ -16,7 +29,13 @@ EMAIL_SMTP_PORT=587 EMAIL_SMTP_SECURE=false EMAIL_SMTP_USER= EMAIL_SMTP_PASS= + +# Enable new account registrations REGISTRATIONS=true + +# Trust the first proxy to give us the user's real IP ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 + +# Run database migrations automatically on startup AUTO_MIGRATE=true diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9b1082f..ede6176 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,9 @@ import { AUTO_MIGRATE, SESSION_SECRET } from '$env/static/private'; +import { csrf } from '$lib/server/csrf'; import { DB } from '$lib/server/drizzle'; import { runSeeds } from '$lib/server/drizzle/seeds'; import { JWT } from '$lib/server/jwt'; +import { sequence } from '@sveltejs/kit/hooks'; import { migrate } from 'drizzle-orm/mysql2/migrator'; import { handleSession } from 'svelte-kit-cookie-session'; @@ -14,6 +16,9 @@ if (AUTO_MIGRATE === 'true') { await runSeeds(); -export const handle = handleSession({ - secret: SESSION_SECRET -}); +export const handle = sequence( + csrf(['/oauth2/token', '/oauth2/introspect']), + handleSession({ + secret: SESSION_SECRET + }) +); diff --git a/src/lib/components/ActionButton.svelte b/src/lib/components/ActionButton.svelte index 00d8a73..40a19d0 100644 --- a/src/lib/components/ActionButton.svelte +++ b/src/lib/components/ActionButton.svelte @@ -13,6 +13,5 @@
- diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index 5a702e3..82f836d 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -1,11 +1,4 @@ export class ApiUtils { - static json(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { 'Content-Type': 'application/json' } - }); - } - static redirect(url: string, status = 302): Response { return new Response(null, { status, @@ -14,4 +7,24 @@ export class ApiUtils { } }); } + + static async getJsonOrFormBody(request: Request) { + if (request.headers.get('content-type')?.startsWith('application/json')) { + try { + const jsonBody = await request.json(); + return jsonBody; + } catch { + // Try next... + } + } + + try { + const formBody = await request.formData(); + return Object.fromEntries(formBody); + } catch (err) { + // Skip... + } + + return {}; + } } diff --git a/src/lib/server/csrf.ts b/src/lib/server/csrf.ts new file mode 100644 index 0000000..680380e --- /dev/null +++ b/src/lib/server/csrf.ts @@ -0,0 +1,33 @@ +import { json, text, type Handle } from '@sveltejs/kit'; + +const isContentType = (request: Request, ...types: string[]) => { + const type = request.headers.get('content-type')?.split(';', 1)[0].trim() ?? ''; + return types.includes(type); +}; + +const isFormContentType = (request: Request) => + isContentType(request, 'application/x-www-form-urlencoded', 'multipart/form-data'); + +/** + * CSRF protection copied from sveltekit but with the ability to turn it off for specific routes. + */ +export const csrf = + (allowedPaths: string[]): Handle => + async ({ event, resolve }) => { + const forbidden = + event.request.method === 'POST' && + event.request.headers.get('origin') !== event.url.origin && + isFormContentType(event.request) && + !allowedPaths.includes(event.url.pathname); + + if (forbidden) { + const csrfError = `Cross-site ${event.request.method} form submissions are forbidden`; + if (event.request.headers.get('accept') === 'application/json') { + return json({ error: 'forbidden', error_description: csrfError }, { status: 403 }); + } + + return text(csrfError, { status: 403 }); + } + + return resolve(event); + }; diff --git a/src/lib/server/jwt.ts b/src/lib/server/jwt.ts index 2154c4c..9b209fb 100644 --- a/src/lib/server/jwt.ts +++ b/src/lib/server/jwt.ts @@ -9,10 +9,11 @@ import { type JWK, type KeyLike } from 'jose'; +import { join } from 'path'; import { v4 as uuidv4 } from 'uuid'; /** - * Generate JWTs using the following commands: + * Generate JWT keys using the following commands: * Private: openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048 * Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem */ @@ -23,8 +24,10 @@ export class JWT { static jwksKid: string; static async init() { - const privateKeyFile = await readFile('private/jwt.private.pem', { encoding: 'utf-8' }); - const publicKeyFile = await readFile('private/jwt.public.pem', { encoding: 'utf-8' }); + const privateKeyFile = await readFile(join('private', 'jwt.private.pem'), { + encoding: 'utf-8' + }); + const publicKeyFile = await readFile(join('private', 'jwt.public.pem'), { encoding: 'utf-8' }); JWT.privateKey = await importPKCS8(privateKeyFile, JWT_ALGORITHM); JWT.publicKey = await importSPKI(publicKeyFile, JWT_ALGORITHM); JWT.jwks = await exportJWK(JWT.publicKey); diff --git a/src/lib/server/oauth2/controller/introspection.ts b/src/lib/server/oauth2/controller/introspection.ts index 097ab06..0145aba 100644 --- a/src/lib/server/oauth2/controller/introspection.ts +++ b/src/lib/server/oauth2/controller/introspection.ts @@ -1,10 +1,11 @@ -import { InvalidRequest } from '../error'; -import { OAuth2AccessTokens } from '../model'; +import { ApiUtils } from '$lib/server/api-utils'; +import { InvalidClient, InvalidRequest, UnauthorizedClient } from '../error'; +import { OAuth2AccessTokens, OAuth2Clients } from '../model'; import { OAuth2Response } from '../response'; export class OAuth2IntrospectionController { static postHandler = async ({ request, url }: { request: Request; url: URL }) => { - const body = await request.json().catch(() => ({})); + const body = await ApiUtils.getJsonOrFormBody(request); let clientId: string | null = null; let clientSecret: string | null = null; @@ -12,7 +13,7 @@ export class OAuth2IntrospectionController { if (body.client_id && body.client_secret) { clientId = body.client_id as string; clientSecret = body.client_secret as string; - console.debug('Client credentials parsed from body parameters ', clientId, clientSecret); + // console.debug('Client credentials parsed from body parameters ', clientId, clientSecret); } else { if (!request.headers?.has('authorization')) { throw new InvalidRequest('No authorization header passed'); @@ -34,23 +35,34 @@ export class OAuth2IntrospectionController { clientId = pieces[0]; clientSecret = pieces[1]; - console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret); + // console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret); } if (!body.token) { throw new InvalidRequest('Token not provided in request body'); } - const token = await OAuth2AccessTokens.fetchByToken(body.token); - if (!token) { - throw new InvalidRequest('Token does not exist'); + const client = await OAuth2Clients.fetchById(clientId); + + if (!client) { + throw new InvalidClient('Client not found'); + } + + const valid = OAuth2Clients.checkSecret(client, clientSecret); + if (!valid) { + throw new UnauthorizedClient('The client authentication was invalid'); + } + + const token = await OAuth2AccessTokens.fetchByToken(body.token); + if (!token || token.clientIdPub !== clientId || token.expires_at.getTime() < Date.now()) { + return OAuth2Response.response(url, { active: false }); } - const ttl = OAuth2AccessTokens.getTTL(token); const resObj = { - token_type: 'bearer', - token: token.token, - expires_in: Math.floor(ttl / 1000) + active: true, + scope: token.scope || '', + client_id: clientId, + exp: Math.floor(token.expires_at.getTime() / 1000) }; return OAuth2Response.response(url, resObj); diff --git a/src/lib/server/oauth2/controller/token.ts b/src/lib/server/oauth2/controller/token.ts index 68c5919..92c369a 100644 --- a/src/lib/server/oauth2/controller/token.ts +++ b/src/lib/server/oauth2/controller/token.ts @@ -1,3 +1,4 @@ +import { ApiUtils } from '$lib/server/api-utils'; import { InvalidRequest, InvalidClient, @@ -16,12 +17,12 @@ export class OAuth2TokenController { let clientSecret: string | null = null; let grantType: string | null = null; - const body = await request.json().catch(() => ({})); + const body = await ApiUtils.getJsonOrFormBody(request); if (body.client_id && body.client_secret) { clientId = body.client_id as string; clientSecret = body.client_secret as string; - console.debug('Client credentials parsed from body parameters', clientId, clientSecret); + // console.debug('Client credentials parsed from body parameters', clientId, clientSecret); } else { if (!request.headers?.has('authorization')) { throw new InvalidRequest('No authorization header passed'); @@ -43,7 +44,7 @@ export class OAuth2TokenController { clientId = pieces[0]; clientSecret = pieces[1]; - console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); + // console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); } if (!body.grant_type) { @@ -51,7 +52,7 @@ export class OAuth2TokenController { } grantType = body.grant_type as string; - console.debug('Parameter grant_type is', grantType); + // console.debug('Parameter grant_type is', grantType); const client = await OAuth2Clients.fetchById(clientId); @@ -67,7 +68,7 @@ export class OAuth2TokenController { if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') { throw new UnauthorizedClient('Invalid grant type for the client'); } else { - console.debug('Grant type check passed'); + // console.debug('Grant type check passed'); } let tokenResponse: OAuth2TokenResponse = {}; diff --git a/src/lib/server/oauth2/controller/tokens/authorizationCode.ts b/src/lib/server/oauth2/controller/tokens/authorizationCode.ts index 703d5f5..596a9c8 100644 --- a/src/lib/server/oauth2/controller/tokens/authorizationCode.ts +++ b/src/lib/server/oauth2/controller/tokens/authorizationCode.ts @@ -54,7 +54,7 @@ export async function authorizationCode( throw new InvalidGrant('Code not found'); } - console.debug('Code fetched', code); + // console.debug('Code fetched', code); const scope = code.scope || ''; const cleanScope = OAuth2Clients.transformScope(scope); @@ -84,11 +84,11 @@ export async function authorizationCode( } } - console.debug('Code passed PCKE check'); + // console.debug('Code passed PCKE check'); } if (!OAuth2Clients.checkGrantType(client, 'refresh_token')) { - console.debug('Client does not allow grant type refresh_token, skip creation'); + // console.debug('Client does not allow grant type refresh_token, skip creation'); } else { try { respObj.refresh_token = await OAuth2RefreshTokens.create(userId, clientId, scope); @@ -111,7 +111,7 @@ export async function authorizationCode( } respObj.expires_in = OAuth2Tokens.tokenTtl; - console.debug('Access token saved:', respObj.access_token); + // console.debug('Access token saved:', respObj.access_token); try { await OAuth2Codes.removeByCode(providedCode); diff --git a/src/lib/server/oauth2/response.ts b/src/lib/server/oauth2/response.ts index 2d2de29..2756cca 100644 --- a/src/lib/server/oauth2/response.ts +++ b/src/lib/server/oauth2/response.ts @@ -15,6 +15,10 @@ export interface OAuth2TokenResponse { expires_in?: number; token_type?: string; state?: string; + active?: boolean; + scope?: string; + client_id?: string; + exp?: number; } export class OAuth2Response { @@ -116,7 +120,8 @@ export class OAuth2Response { fragment: boolean = false ) { if (redirectUri) { - redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&'; + const searchJoinChar = redirectUri.includes('?') ? '&' : '?'; + redirectUri += fragment ? '#' : searchJoinChar; if (url.searchParams.has('state')) { obj.state = url.searchParams.get('state') as string; diff --git a/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts b/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts index 34ddd1f..0a7d15c 100644 --- a/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/jwks.json/+server.ts @@ -1,8 +1,8 @@ import { JWT_ALGORITHM } from '$env/static/private'; -import { ApiUtils } from '$lib/server/api-utils'; import { JWT } from '$lib/server/jwt'; +import { json } from '@sveltejs/kit'; export const GET = async () => - ApiUtils.json({ + json({ keys: [{ alg: JWT_ALGORITHM, kid: JWT.jwksKid, ...JWT.jwks, use: 'sig' }] }); diff --git a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts index 727e824..11511a3 100644 --- a/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts +++ b/src/routes/[...wellKnown=wellKnown]/openid-configuration/+server.ts @@ -1,9 +1,9 @@ import { JWT_ALGORITHM, JWT_ISSUER } from '$env/static/private'; import { PUBLIC_URL } from '$env/static/public'; -import { ApiUtils } from '$lib/server/api-utils'; +import { json } from '@sveltejs/kit'; export const GET = async () => - ApiUtils.json({ + json({ issuer: JWT_ISSUER, authorization_endpoint: `${PUBLIC_URL}/oauth2/authorize`, token_endpoint: `${PUBLIC_URL}/oauth2/token`, diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 7991445..23dcb0b 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -18,6 +18,7 @@ import { PUBLIC_SITE_NAME } from '$env/static/public'; import FormErrors from '$lib/components/form/FormErrors.svelte'; import TitleRow from '$lib/components/container/TitleRow.svelte'; + import ActionButton from '$lib/components/ActionButton.svelte'; export let data: PageData; export let form: ActionData; @@ -180,9 +181,7 @@ > {#if data.hasAvatar} -
- -
+ {$t('account.avatar.remove')} {/if} diff --git a/src/routes/api/user/+server.ts b/src/routes/api/user/+server.ts index 95f7940..0c1c4fa 100644 --- a/src/routes/api/user/+server.ts +++ b/src/routes/api/user/+server.ts @@ -1,11 +1,11 @@ import { PUBLIC_URL } from '$env/static/public'; -import { ApiUtils } from '$lib/server/api-utils.js'; import type { User } from '$lib/server/drizzle/schema.js'; import { OAuth2BearerController } from '$lib/server/oauth2/controller/bearer.js'; import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js'; import { OAuth2Clients } from '$lib/server/oauth2/model/client.js'; import { OAuth2Response } from '$lib/server/oauth2/response.js'; import { Users } from '$lib/server/users/index.js'; +import { json } from '@sveltejs/kit'; export const GET = async ({ request, url, locals }) => { let user: User | undefined = undefined; @@ -64,5 +64,5 @@ export const GET = async ({ request, url, locals }) => { userData.privileges = await Users.getUserPrivileges(user, clientId); } - return ApiUtils.json(userData); + return json(userData); }; diff --git a/svelte.config.js b/svelte.config.js index fffb849..08420be 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -11,7 +11,12 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + + // This is reimplemented in hooks.server.ts to allow certain endpoints + csrf: { + checkOrigin: false + } } };