Actually mostly-compliant introspection endpoint, csrf and documentation

This commit is contained in:
Evert Prants 2024-06-06 20:02:18 +03:00
parent 874b4804b9
commit 17821bfda3
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
15 changed files with 140 additions and 46 deletions

View File

@ -1,14 +1,27 @@
# Front-end public URL, without leading slash
PUBLIC_URL=http://localhost:5173 PUBLIC_URL=http://localhost:5173
# Site name, displayed on the UI and in emails
PUBLIC_SITE_NAME=Amanita SSO PUBLIC_SITE_NAME=Amanita SSO
# Database connection (mysql)
DATABASE_HOST=localhost DATABASE_HOST=localhost
DATABASE_DB=icyauth DATABASE_DB=icyauth
DATABASE_USER=icyauth DATABASE_USER=icyauth
DATABASE_PASS=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 SESSION_SECRET=32 char key
CHALLENGE_SECRET=64 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_ALGORITHM=RS256
JWT_EXPIRATION=7d JWT_EXPIRATION=7d
JWT_ISSUER=http://localhost:5173 JWT_ISSUER=http://localhost:5173
# SMTP settings
EMAIL_ENABLED=true EMAIL_ENABLED=true
EMAIL_FROM=no-reply@icynet.eu EMAIL_FROM=no-reply@icynet.eu
EMAIL_SMTP_HOST=mail.icynet.eu EMAIL_SMTP_HOST=mail.icynet.eu
@ -16,7 +29,13 @@ EMAIL_SMTP_PORT=587
EMAIL_SMTP_SECURE=false EMAIL_SMTP_SECURE=false
EMAIL_SMTP_USER= EMAIL_SMTP_USER=
EMAIL_SMTP_PASS= EMAIL_SMTP_PASS=
# Enable new account registrations
REGISTRATIONS=true REGISTRATIONS=true
# Trust the first proxy to give us the user's real IP
ADDRESS_HEADER=X-Forwarded-For ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1 XFF_DEPTH=1
# Run database migrations automatically on startup
AUTO_MIGRATE=true AUTO_MIGRATE=true

View File

@ -1,7 +1,9 @@
import { AUTO_MIGRATE, SESSION_SECRET } from '$env/static/private'; import { AUTO_MIGRATE, SESSION_SECRET } from '$env/static/private';
import { csrf } from '$lib/server/csrf';
import { DB } from '$lib/server/drizzle'; import { DB } from '$lib/server/drizzle';
import { runSeeds } from '$lib/server/drizzle/seeds'; import { runSeeds } from '$lib/server/drizzle/seeds';
import { JWT } from '$lib/server/jwt'; import { JWT } from '$lib/server/jwt';
import { sequence } from '@sveltejs/kit/hooks';
import { migrate } from 'drizzle-orm/mysql2/migrator'; import { migrate } from 'drizzle-orm/mysql2/migrator';
import { handleSession } from 'svelte-kit-cookie-session'; import { handleSession } from 'svelte-kit-cookie-session';
@ -14,6 +16,9 @@ if (AUTO_MIGRATE === 'true') {
await runSeeds(); await runSeeds();
export const handle = handleSession({ export const handle = sequence(
secret: SESSION_SECRET csrf(['/oauth2/token', '/oauth2/introspect']),
}); handleSession({
secret: SESSION_SECRET
})
);

View File

@ -13,6 +13,5 @@
</script> </script>
<form {action} method="POST" use:enhancer={enhanceFn}> <form {action} method="POST" use:enhancer={enhanceFn}>
<slot name="form" />
<Button {variant} type="submit"><slot /></Button> <Button {variant} type="submit"><slot /></Button>
</form> </form>

View File

@ -1,11 +1,4 @@
export class ApiUtils { 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 { static redirect(url: string, status = 302): Response {
return new Response(null, { return new Response(null, {
status, 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 {};
}
} }

33
src/lib/server/csrf.ts Normal file
View File

@ -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);
};

View File

@ -9,10 +9,11 @@ import {
type JWK, type JWK,
type KeyLike type KeyLike
} from 'jose'; } from 'jose';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid'; 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 * 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 * 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 jwksKid: string;
static async init() { static async init() {
const privateKeyFile = await readFile('private/jwt.private.pem', { encoding: 'utf-8' }); const privateKeyFile = await readFile(join('private', 'jwt.private.pem'), {
const publicKeyFile = await readFile('private/jwt.public.pem', { encoding: 'utf-8' }); encoding: 'utf-8'
});
const publicKeyFile = await readFile(join('private', 'jwt.public.pem'), { encoding: 'utf-8' });
JWT.privateKey = await importPKCS8(privateKeyFile, JWT_ALGORITHM); JWT.privateKey = await importPKCS8(privateKeyFile, JWT_ALGORITHM);
JWT.publicKey = await importSPKI(publicKeyFile, JWT_ALGORITHM); JWT.publicKey = await importSPKI(publicKeyFile, JWT_ALGORITHM);
JWT.jwks = await exportJWK(JWT.publicKey); JWT.jwks = await exportJWK(JWT.publicKey);

View File

@ -1,10 +1,11 @@
import { InvalidRequest } from '../error'; import { ApiUtils } from '$lib/server/api-utils';
import { OAuth2AccessTokens } from '../model'; import { InvalidClient, InvalidRequest, UnauthorizedClient } from '../error';
import { OAuth2AccessTokens, OAuth2Clients } from '../model';
import { OAuth2Response } from '../response'; import { OAuth2Response } from '../response';
export class OAuth2IntrospectionController { export class OAuth2IntrospectionController {
static postHandler = async ({ request, url }: { request: Request; url: URL }) => { 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 clientId: string | null = null;
let clientSecret: string | null = null; let clientSecret: string | null = null;
@ -12,7 +13,7 @@ export class OAuth2IntrospectionController {
if (body.client_id && body.client_secret) { if (body.client_id && body.client_secret) {
clientId = body.client_id as string; clientId = body.client_id as string;
clientSecret = body.client_secret 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 { } else {
if (!request.headers?.has('authorization')) { if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed'); throw new InvalidRequest('No authorization header passed');
@ -34,23 +35,34 @@ export class OAuth2IntrospectionController {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; 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) { if (!body.token) {
throw new InvalidRequest('Token not provided in request body'); throw new InvalidRequest('Token not provided in request body');
} }
const token = await OAuth2AccessTokens.fetchByToken(body.token); const client = await OAuth2Clients.fetchById(clientId);
if (!token) {
throw new InvalidRequest('Token does not exist'); 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 = { const resObj = {
token_type: 'bearer', active: true,
token: token.token, scope: token.scope || '',
expires_in: Math.floor(ttl / 1000) client_id: clientId,
exp: Math.floor(token.expires_at.getTime() / 1000)
}; };
return OAuth2Response.response(url, resObj); return OAuth2Response.response(url, resObj);

View File

@ -1,3 +1,4 @@
import { ApiUtils } from '$lib/server/api-utils';
import { import {
InvalidRequest, InvalidRequest,
InvalidClient, InvalidClient,
@ -16,12 +17,12 @@ export class OAuth2TokenController {
let clientSecret: string | null = null; let clientSecret: string | null = null;
let grantType: 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) { if (body.client_id && body.client_secret) {
clientId = body.client_id as string; clientId = body.client_id as string;
clientSecret = body.client_secret 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 { } else {
if (!request.headers?.has('authorization')) { if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed'); throw new InvalidRequest('No authorization header passed');
@ -43,7 +44,7 @@ export class OAuth2TokenController {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; 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) { if (!body.grant_type) {
@ -51,7 +52,7 @@ export class OAuth2TokenController {
} }
grantType = body.grant_type as string; 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); const client = await OAuth2Clients.fetchById(clientId);
@ -67,7 +68,7 @@ export class OAuth2TokenController {
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') { if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
throw new UnauthorizedClient('Invalid grant type for the client'); throw new UnauthorizedClient('Invalid grant type for the client');
} else { } else {
console.debug('Grant type check passed'); // console.debug('Grant type check passed');
} }
let tokenResponse: OAuth2TokenResponse = {}; let tokenResponse: OAuth2TokenResponse = {};

View File

@ -54,7 +54,7 @@ export async function authorizationCode(
throw new InvalidGrant('Code not found'); throw new InvalidGrant('Code not found');
} }
console.debug('Code fetched', code); // console.debug('Code fetched', code);
const scope = code.scope || ''; const scope = code.scope || '';
const cleanScope = OAuth2Clients.transformScope(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')) { 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 { } else {
try { try {
respObj.refresh_token = await OAuth2RefreshTokens.create(userId, clientId, scope); respObj.refresh_token = await OAuth2RefreshTokens.create(userId, clientId, scope);
@ -111,7 +111,7 @@ export async function authorizationCode(
} }
respObj.expires_in = OAuth2Tokens.tokenTtl; respObj.expires_in = OAuth2Tokens.tokenTtl;
console.debug('Access token saved:', respObj.access_token); // console.debug('Access token saved:', respObj.access_token);
try { try {
await OAuth2Codes.removeByCode(providedCode); await OAuth2Codes.removeByCode(providedCode);

View File

@ -15,6 +15,10 @@ export interface OAuth2TokenResponse {
expires_in?: number; expires_in?: number;
token_type?: string; token_type?: string;
state?: string; state?: string;
active?: boolean;
scope?: string;
client_id?: string;
exp?: number;
} }
export class OAuth2Response { export class OAuth2Response {
@ -116,7 +120,8 @@ export class OAuth2Response {
fragment: boolean = false fragment: boolean = false
) { ) {
if (redirectUri) { if (redirectUri) {
redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&'; const searchJoinChar = redirectUri.includes('?') ? '&' : '?';
redirectUri += fragment ? '#' : searchJoinChar;
if (url.searchParams.has('state')) { if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string; obj.state = url.searchParams.get('state') as string;

View File

@ -1,8 +1,8 @@
import { JWT_ALGORITHM } from '$env/static/private'; import { JWT_ALGORITHM } from '$env/static/private';
import { ApiUtils } from '$lib/server/api-utils';
import { JWT } from '$lib/server/jwt'; import { JWT } from '$lib/server/jwt';
import { json } from '@sveltejs/kit';
export const GET = async () => export const GET = async () =>
ApiUtils.json({ json({
keys: [{ alg: JWT_ALGORITHM, kid: JWT.jwksKid, ...JWT.jwks, use: 'sig' }] keys: [{ alg: JWT_ALGORITHM, kid: JWT.jwksKid, ...JWT.jwks, use: 'sig' }]
}); });

View File

@ -1,9 +1,9 @@
import { JWT_ALGORITHM, JWT_ISSUER } from '$env/static/private'; import { JWT_ALGORITHM, JWT_ISSUER } from '$env/static/private';
import { PUBLIC_URL } from '$env/static/public'; import { PUBLIC_URL } from '$env/static/public';
import { ApiUtils } from '$lib/server/api-utils'; import { json } from '@sveltejs/kit';
export const GET = async () => export const GET = async () =>
ApiUtils.json({ json({
issuer: JWT_ISSUER, issuer: JWT_ISSUER,
authorization_endpoint: `${PUBLIC_URL}/oauth2/authorize`, authorization_endpoint: `${PUBLIC_URL}/oauth2/authorize`,
token_endpoint: `${PUBLIC_URL}/oauth2/token`, token_endpoint: `${PUBLIC_URL}/oauth2/token`,

View File

@ -18,6 +18,7 @@
import { PUBLIC_SITE_NAME } from '$env/static/public'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormErrors from '$lib/components/form/FormErrors.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte'; import TitleRow from '$lib/components/container/TitleRow.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -180,9 +181,7 @@
> >
</div> </div>
{#if data.hasAvatar} {#if data.hasAvatar}
<form action="?/removeAvatar" method="POST" use:enhance> <ActionButton action="?/removeAvatar">{$t('account.avatar.remove')}</ActionButton>
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
</form>
{/if} {/if}
</ViewColumn> </ViewColumn>
</AvatarCard> </AvatarCard>

View File

@ -1,11 +1,11 @@
import { PUBLIC_URL } from '$env/static/public'; 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 type { User } from '$lib/server/drizzle/schema.js';
import { OAuth2BearerController } from '$lib/server/oauth2/controller/bearer.js'; import { OAuth2BearerController } from '$lib/server/oauth2/controller/bearer.js';
import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js'; import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2Clients } from '$lib/server/oauth2/model/client.js'; import { OAuth2Clients } from '$lib/server/oauth2/model/client.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js'; import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
import { json } from '@sveltejs/kit';
export const GET = async ({ request, url, locals }) => { export const GET = async ({ request, url, locals }) => {
let user: User | undefined = undefined; let user: User | undefined = undefined;
@ -64,5 +64,5 @@ export const GET = async ({ request, url, locals }) => {
userData.privileges = await Users.getUserPrivileges(user, clientId); userData.privileges = await Users.getUserPrivileges(user, clientId);
} }
return ApiUtils.json(userData); return json(userData);
}; };

View File

@ -11,7 +11,12 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // 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. // 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. // 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
}
} }
}; };