JWT client authentication, OAuth2 PAR support

This commit is contained in:
Evert Prants 2025-02-18 20:54:09 +02:00
parent 563e0a0350
commit 5faa30d691
Signed by: evert
GPG Key ID: 0960A17F9F40237D
30 changed files with 3112 additions and 107 deletions

View File

@ -0,0 +1,5 @@
ALTER TABLE `o_auth2_token` MODIFY COLUMN `type` enum('code','device_code','access_token','refresh_token','par') NOT NULL;--> statement-breakpoint
ALTER TABLE `o_auth2_client` ADD `enforce_par` tinyint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `o_auth2_token` ADD `state` text;--> statement-breakpoint
ALTER TABLE `o_auth2_token` ADD `grants` text;--> statement-breakpoint
ALTER TABLE `o_auth2_token` ADD `redirect_uri` text;

View File

@ -0,0 +1 @@
ALTER TABLE `o_auth2_client` ADD `jwks` json;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,20 @@
"when": 1733752641589, "when": 1733752641589,
"tag": "0005_happy_meltdown", "tag": "0005_happy_meltdown",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1739894975346,
"tag": "0006_clean_mandrill",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1739896136734,
"tag": "0007_slim_dexter_bennett",
"breakpoints": true
} }
] ]
} }

View File

@ -50,7 +50,7 @@ const handleThemeHook = (async ({ resolve, event }) => {
}) satisfies Handle; }) satisfies Handle;
export const handle = sequence( export const handle = sequence(
csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization']), csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization', '/oauth2/par']),
handleSession({ handleSession({
secret: SESSION_SECRET, secret: SESSION_SECRET,
cookie: { cookie: {

View File

@ -85,12 +85,13 @@
"add": "Add URL" "add": "Add URL"
}, },
"apis": { "apis": {
"title": "OAuth2 APIs", "title": "OAuth 2.0 APIs",
"authorize": "OAuth2 Authorization endpoint", "authorize": "OAuth 2.0 Authorization endpoint",
"token": "OAuth2 Token endpoint", "par": "OAuth 2.0 Pushed Authorization Requests endpoint",
"introspect": "OAuth2 Introspection endpoint", "token": "OAuth 2.0 Token endpoint",
"introspect": "OAuth 2.0 Introspection endpoint",
"userinfo": "User information endpoint (Bearer)", "userinfo": "User information endpoint (Bearer)",
"device": "OAuth2 Device Authorization endpoint", "device": "OAuth 2.0 Device Authorization endpoint",
"openid": "OpenID Connect configuration" "openid": "OpenID Connect configuration"
}, },
"grantTexts": { "grantTexts": {
@ -116,6 +117,11 @@
"add": "Invite a new member", "add": "Invite a new member",
"invite": "Invite" "invite": "Invite"
}, },
"jwks": {
"title": "JSON Web Keys",
"subtitle": "You may use JSON Web Tokens (JWTs) for client authentication (see <a href=\"https://datatracker.ietf.org/doc/html/rfc7523\" target=\"_blank\">RFC7523</a>). Enter your <b>public keys</b> here in JWK (<a href=\"https://datatracker.ietf.org/doc/html/rfc7517\" target=\"_blank\">JSON Web Key, RFC7517</a>) format. Use JSON array syntax to include multiple, if necessary. Please note that not all claims will be preserved - only the ones required for JWT signature validation will be kept.",
"input": "Enter JSON here"
},
"errors": { "errors": {
"noRedirect": "At least one Redirect URI is required for you to be able to use this application!", "noRedirect": "At least one Redirect URI is required for you to be able to use this application!",
"forbidden": "This action is forbidden for this user.", "forbidden": "This action is forbidden for this user.",
@ -131,7 +137,9 @@
"invalidEmail": "Invalid email address.", "invalidEmail": "Invalid email address.",
"emailExists": "This email address is already added.", "emailExists": "This email address is already added.",
"noFile": "Please upload a file first.", "noFile": "Please upload a file first.",
"tooManyTimes": "You are doing that too much, please, slow down!" "tooManyTimes": "You are doing that too much, please, slow down!",
"jwksRequired": "JWKs parameter is required!",
"invalidJwks": "Invalid JSON Web Keys provided, please check your input and try again."
} }
}, },
"audit": { "audit": {

View File

@ -1,4 +1,7 @@
export class Changesets { export class Changesets {
/**
* @deprecated use `Changesets.only`
*/
static take<TRes>( static take<TRes>(
fields: (keyof TRes)[], fields: (keyof TRes)[],
body: FormData | URLSearchParams, body: FormData | URLSearchParams,
@ -11,4 +14,17 @@ export class Changesets {
return accum; return accum;
}, {}); }, {});
} }
static only<KeyList extends string, ResultObject extends Record<KeyList, string | undefined>>(
fields: KeyList[],
body: FormData | URLSearchParams,
challenge?: ResultObject
): ResultObject {
return fields.reduce<ResultObject>((accum, field) => {
accum[field] = challenge
? challenge[field]
: ((body.get(field as string) as string)?.trim() as ResultObject[typeof field]);
return accum;
}, {} as ResultObject);
}
} }

View File

@ -10,8 +10,10 @@ import {
timestamp, timestamp,
mysqlEnum, mysqlEnum,
index, index,
type AnyMySqlColumn type AnyMySqlColumn,
json
} from 'drizzle-orm/mysql-core'; } from 'drizzle-orm/mysql-core';
import type { JWK } from 'jose';
export const jwks = mysqlTable('jwks', { export const jwks = mysqlTable('jwks', {
uuid: varchar('uuid', { length: 36 }).primaryKey(), uuid: varchar('uuid', { length: 36 }).primaryKey(),
@ -70,6 +72,8 @@ export const oauth2Client = mysqlTable(
activated: tinyint('activated').default(0).notNull(), activated: tinyint('activated').default(0).notNull(),
verified: tinyint('verified').default(0).notNull(), verified: tinyint('verified').default(0).notNull(),
confidential: tinyint('confidential').default(1).notNull(), confidential: tinyint('confidential').default(1).notNull(),
enforce_par: tinyint('enforce_par').default(0).notNull(),
jwks: json('jwks').$type<JWK[]>(),
pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }), pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }),
ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }), ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }),
created_at: datetime('created_at', { mode: 'date', fsp: 6 }) created_at: datetime('created_at', { mode: 'date', fsp: 6 })
@ -139,7 +143,13 @@ export type NewOAuth2ClientUrl = typeof oauth2ClientUrl.$inferInsert;
export const oauth2Token = mysqlTable('o_auth2_token', { export const oauth2Token = mysqlTable('o_auth2_token', {
id: int('id').autoincrement().notNull(), id: int('id').autoincrement().notNull(),
type: mysqlEnum('type', ['code', 'device_code', 'access_token', 'refresh_token']).notNull(), type: mysqlEnum('type', [
'code',
'device_code',
'access_token',
'refresh_token',
'par'
]).notNull(),
token: text('token').notNull(), token: text('token').notNull(),
scope: text('scope'), scope: text('scope'),
expires_at: timestamp('expires_at', { mode: 'date' }) expires_at: timestamp('expires_at', { mode: 'date' })
@ -148,6 +158,9 @@ export const oauth2Token = mysqlTable('o_auth2_token', {
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }), userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }), clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
nonce: text('nonce'), nonce: text('nonce'),
state: text('state'),
grants: text('grants'),
redirect_uri: text('redirect_uri'),
created_at: datetime('created_at', { mode: 'date', fsp: 6 }) created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`) .default(sql`current_timestamp(6)`)
.notNull(), .notNull(),

View File

@ -1,3 +1,4 @@
import type { OAuth2Token } from '$lib/server/drizzle';
import { Logger } from '$lib/server/logger'; import { Logger } from '$lib/server/logger';
import type { UserSession } from '../../users'; import type { UserSession } from '../../users';
import { import {
@ -14,59 +15,83 @@ import {
OAuth2AccessTokens, OAuth2AccessTokens,
OAuth2Clients, OAuth2Clients,
OAuth2Codes, OAuth2Codes,
OAuth2ParCodes,
OAuth2Tokens, OAuth2Tokens,
type CodeChallengeMethod type CodeChallengeMethod
} from '../model'; } from '../model';
import { OAuth2Users } from '../model/user'; import { OAuth2Users } from '../model/user';
import { OAuth2Response } from '../response'; import { OAuth2Response } from '../response';
type OAuth2ParToken = OAuth2Token & {
code_challenge?: string;
code_challenge_method?: CodeChallengeMethod;
};
export class OAuth2AuthorizationController { export class OAuth2AuthorizationController {
private static prehandle = async (url: URL, locals: App.Locals) => { private static prehandle = async (url: URL, locals: App.Locals) => {
if (!url.searchParams.has('redirect_uri')) { const requestUri = url.searchParams.get('request_uri');
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); let pushedRequest: OAuth2ParToken | undefined = undefined;
}
const redirectUri = url.searchParams.get('redirect_uri') as string; const clientId = url.searchParams.get('client_id') as string;
Logger.debug('Parameter redirect uri is', redirectUri); if (!clientId) {
if (!url.searchParams.has('client_id')) {
throw new InvalidRequest('client_id field is mandatory for authorization endpoint'); throw new InvalidRequest('client_id field is mandatory for authorization endpoint');
} }
if (requestUri) {
pushedRequest = await OAuth2ParCodes.getByRequestUri(clientId, requestUri);
if (!pushedRequest || !OAuth2Tokens.checkTTL(pushedRequest)) {
throw new InvalidRequest('The request_uri is invalid for this client');
}
Logger.debug('Taking parameters from Pushed Authorization Request');
}
if (!pushedRequest && !url.searchParams.has('redirect_uri')) {
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint');
}
const redirectUri = (
pushedRequest ? pushedRequest.redirect_uri : url.searchParams.get('redirect_uri')
) as string;
Logger.debug('Parameter redirect uri is', redirectUri);
// Check for client_secret (prevent passing it) // Check for client_secret (prevent passing it)
if (url.searchParams.has('client_secret')) { if (!pushedRequest && url.searchParams.has('client_secret')) {
throw new InvalidRequest( throw new InvalidRequest(
'client_secret field should not be passed to the authorization endpoint' 'client_secret field should not be passed to the authorization endpoint'
); );
} }
const clientId = url.searchParams.get('client_id') as string;
Logger.debug('Parameter client_id is', clientId); Logger.debug('Parameter client_id is', clientId);
if (!url.searchParams.has('response_type')) { if (!pushedRequest && !url.searchParams.has('response_type')) {
throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); throw new InvalidRequest('response_type field is mandatory for authorization endpoint');
} }
const responseType = url.searchParams.get('response_type') as string; let grantTypes: string[] = pushedRequest?.grants?.split(',') || [];
Logger.debug('Parameter response_type is', responseType);
// Support multiple types if (!pushedRequest) {
const responseTypes = responseType.split(' '); const responseType = url.searchParams.get('response_type') as string;
let grantTypes: string[] = []; Logger.debug('Parameter response_type is', responseType);
for (const responseType of responseTypes) {
switch (responseType) { // Support multiple types
case 'code': const responseTypes = responseType.split(' ');
grantTypes.push('authorization_code'); for (const responseType of responseTypes) {
break; switch (responseType) {
case 'token': case 'code':
grantTypes.push('implicit'); grantTypes.push('authorization_code');
break; break;
case 'id_token': case 'token':
case 'none': grantTypes.push('implicit');
grantTypes.push(responseType); break;
break; case 'id_token':
default: case 'none':
throw new UnsupportedResponseType('Unknown response_type parameter passed'); grantTypes.push(responseType);
break;
default:
throw new UnsupportedResponseType('Unknown response_type parameter passed');
}
} }
} }
@ -92,6 +117,11 @@ export class OAuth2AuthorizationController {
} }
Logger.debug('redirect_uri check passed'); Logger.debug('redirect_uri check passed');
if (client.enforce_par === 1 && !pushedRequest) {
throw new InvalidRequest('This client can only authorize Pushed Authorization Requests');
}
Logger.debug('request_uri check passed');
// The client needs to support all grant types // The client needs to support all grant types
for (const grantType of grantTypes) { for (const grantType of grantTypes) {
if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) { if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) {
@ -100,15 +130,21 @@ export class OAuth2AuthorizationController {
} }
Logger.debug('Grant type check passed'); Logger.debug('Grant type check passed');
const scope = OAuth2Clients.transformScope(url.searchParams.get('scope') as string); let scope: string | string[] = (
pushedRequest ? pushedRequest.scope : url.searchParams.get('scope')
) as string;
scope = OAuth2Clients.transformScope(scope);
if (!OAuth2Clients.checkScope(client, scope)) { if (!OAuth2Clients.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} }
Logger.debug('Scope check passed'); Logger.debug('Scope check passed');
const codeChallenge = url.searchParams.get('code_challenge') as string; const codeChallenge = pushedRequest
const codeChallengeMethod = ? pushedRequest.code_challenge
(url.searchParams.get('code_challenge_method') as CodeChallengeMethod) || 'plain'; : (url.searchParams.get('code_challenge') as string);
const codeChallengeMethod = pushedRequest
? pushedRequest.code_challenge_method
: (url.searchParams.get('code_challenge_method') as CodeChallengeMethod) || 'plain';
if (codeChallengeMethod && !OAuth2Tokens.challengeMethods.includes(codeChallengeMethod)) { if (codeChallengeMethod && !OAuth2Tokens.challengeMethods.includes(codeChallengeMethod)) {
throw new InvalidGrant('Invalid code challenge method'); throw new InvalidGrant('Invalid code challenge method');
@ -120,15 +156,23 @@ export class OAuth2AuthorizationController {
); );
} }
const nonce =
(pushedRequest ? pushedRequest.nonce : (url.searchParams.get('nonce') as string)) ||
undefined;
const state =
(pushedRequest ? pushedRequest.state : (url.searchParams.get('state') as string)) ||
undefined;
return { return {
client, client,
user: locals.user, user: locals.user,
redirectUri, redirectUri,
responseType,
grantTypes, grantTypes,
scope, scope,
codeChallenge, codeChallenge,
codeChallengeMethod codeChallengeMethod,
nonce,
state
}; };
}; };
@ -142,9 +186,15 @@ export class OAuth2AuthorizationController {
codeChallenge, codeChallenge,
codeChallengeMethod, codeChallengeMethod,
redirectUri, redirectUri,
responseType nonce,
state
}: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>> }: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>>
) => { ) => {
const requestUri = url.searchParams.get('request_uri');
if (requestUri) {
await OAuth2ParCodes.deleteByRequestUri(requestUri);
}
let resObj: Record<string, string | number> = {}; let resObj: Record<string, string | number> = {};
for (const grantType of grantTypes) { for (const grantType of grantTypes) {
let data = null; let data = null;
@ -155,7 +205,7 @@ export class OAuth2AuthorizationController {
client.client_id, client.client_id,
scope, scope,
OAuth2Tokens.codeTtl, OAuth2Tokens.codeTtl,
url.searchParams.get('nonce') as string, nonce,
codeChallenge, codeChallenge,
codeChallengeMethod codeChallengeMethod
); );
@ -184,12 +234,7 @@ export class OAuth2AuthorizationController {
break; break;
} }
data = await OAuth2Users.issueIdToken( data = await OAuth2Users.issueIdToken(user, client, scope, nonce);
user,
client,
scope,
url.searchParams.get('nonce') as string | undefined
);
resObj = { resObj = {
id_token: data, id_token: data,
@ -205,7 +250,13 @@ export class OAuth2AuthorizationController {
} }
// Return non-code response types as fragment instead of query // Return non-code response types as fragment instead of query
return OAuth2Response.responsePlain(url, resObj, redirectUri, responseType !== 'code'); return OAuth2Response.responsePlain(
url,
resObj,
redirectUri,
grantTypes.every((entry) => entry === 'authorization_code'),
state
);
}; };
static getRequest = async ({ locals, url }: { locals: App.Locals; url: URL }) => { static getRequest = async ({ locals, url }: { locals: App.Locals; url: URL }) => {

View File

@ -11,9 +11,23 @@ export class OAuth2DeviceAuthorizationController {
let clientId: string | null = null; let clientId: string | null = null;
let clientSecret: string | null = null; let clientSecret: string | null = null;
if (body.client_id) { let clientAssertionType: string | null = null;
let clientAssertion: string | null = null;
if (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;
Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret);
} else if (body.client_assertion) {
clientId = body.client_id as string;
clientAssertionType = body.client_assertion_type;
clientAssertion = body.client_assertion;
Logger.debug(
'Client assertion credentials parsed from body parameters',
clientId,
clientAssertionType,
clientAssertion
);
} 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');
@ -39,7 +53,7 @@ export class OAuth2DeviceAuthorizationController {
} }
if (!clientId) { if (!clientId) {
throw new InvalidClient('client_id body parameter is required'); throw new InvalidRequest('client_id field is mandatory for device authorization endpoint');
} }
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
@ -47,11 +61,22 @@ export class OAuth2DeviceAuthorizationController {
throw new InvalidClient('Client not found'); throw new InvalidClient('Client not found');
} }
if ( if (client.confidential === 1 || clientSecret || clientAssertion) {
(client.confidential === 1 || clientSecret) && if (clientAssertion && clientAssertionType) {
!OAuth2Clients.checkSecret(client, clientSecret) const valid = await OAuth2Clients.validateClientAssertionAuthentication(
) { client,
throw new UnauthorizedClient('Invalid client secret'); clientAssertionType,
clientAssertion
);
if (!valid) {
throw new UnauthorizedClient('Invalid client assertion');
}
} else {
const valid = OAuth2Clients.checkSecret(client, clientSecret);
if (!valid) {
throw new UnauthorizedClient('Invalid client secret');
}
}
} }
if (!OAuth2Clients.checkGrantType(client, 'device_code')) { if (!OAuth2Clients.checkGrantType(client, 'device_code')) {

View File

@ -1,4 +1,5 @@
export * from './authorization'; export * from './authorization';
export * from './pushed-authorization';
export * from './introspection'; export * from './introspection';
export * from './token'; export * from './token';
export * from './bearer'; export * from './bearer';

View File

@ -10,11 +10,23 @@ export class OAuth2IntrospectionController {
let clientId: string | null = null; let clientId: string | null = null;
let clientSecret: string | null = null; let clientSecret: string | null = null;
let clientAssertionType: string | null = null;
let clientAssertion: string | null = null;
if (body.client_id && body.client_secret) { if (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;
Logger.debug('Client credentials parsed from body parameters ', clientId, clientSecret); Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret);
} else if (body.client_assertion) {
clientId = body.client_id as string;
clientAssertionType = body.client_assertion_type;
clientAssertion = body.client_assertion;
Logger.debug(
'Client assertion credentials parsed from body parameters',
clientId,
clientAssertionType,
clientAssertion
);
} 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,15 +55,30 @@ export class OAuth2IntrospectionController {
throw new InvalidRequest('Token not provided in request body'); throw new InvalidRequest('Token not provided in request body');
} }
if (!clientId) {
throw new InvalidRequest('client_id field is mandatory for introspection endpoint');
}
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
if (!client || client.activated === 0) { if (!client || client.activated === 0) {
throw new InvalidClient('Client not found'); throw new InvalidClient('Client not found');
} }
const valid = OAuth2Clients.checkSecret(client, clientSecret); if (clientAssertion && clientAssertionType) {
if (!valid) { const valid = await OAuth2Clients.validateClientAssertionAuthentication(
throw new UnauthorizedClient('The client authentication was invalid'); client,
clientAssertionType,
clientAssertion
);
if (!valid) {
throw new UnauthorizedClient('Invalid client assertion');
}
} else {
const valid = OAuth2Clients.checkSecret(client, clientSecret);
if (!valid) {
throw new UnauthorizedClient('Invalid client secret');
}
} }
const token = await OAuth2AccessTokens.fetchByToken(body.token); const token = await OAuth2AccessTokens.fetchByToken(body.token);

View File

@ -0,0 +1,187 @@
import { ApiUtils } from '$lib/server/api-utils';
import { Logger } from '$lib/server/logger';
import {
InvalidRequest,
UnsupportedResponseType,
InvalidClient,
UnauthorizedClient,
InvalidScope,
InvalidGrant
} from '../error';
import { OAuth2Clients, OAuth2ParCodes, OAuth2Tokens, type CodeChallengeMethod } from '../model';
import { OAuth2Response } from '../response';
export class OAuth2PushedAuthorizationController {
static async postRequest({ request }: { request: Request }) {
const body = await ApiUtils.getJsonOrFormBody(request);
if (!body.redirect_uri) {
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint');
}
const redirectUri = body.redirect_uri as string;
Logger.debug('Parameter redirect uri is', redirectUri);
let clientId: string | null = null;
let clientSecret: string | null = null;
let clientAssertionType: string | null = null;
let clientAssertion: string | null = null;
if (body.client_secret) {
clientId = body.client_id as string;
clientSecret = body.client_secret as string;
Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret);
} else if (body.client_assertion) {
clientId = body.client_id as string;
clientAssertionType = body.client_assertion_type;
clientAssertion = body.client_assertion;
Logger.debug(
'Client assertion credentials parsed from body parameters',
clientId,
clientAssertionType,
clientAssertion
);
} else {
if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed');
}
let pieces = (request.headers.get('authorization') as string).split(' ', 2);
if (!pieces || pieces.length !== 2) {
throw new InvalidRequest('Authorization header is corrupted');
}
if (pieces[0] !== 'Basic') {
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`);
}
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
if (!pieces || pieces.length !== 2) {
throw new InvalidRequest('Authorization header has corrupted data');
}
clientId = pieces[0];
clientSecret = pieces[1];
Logger.debug('Client credentials parsed from basic auth header:', clientId, clientSecret);
}
if (!clientId) {
throw new InvalidRequest('client_id field is mandatory for authorization endpoint');
}
if (!body.response_type) {
throw new InvalidRequest('response_type field is mandatory for authorization endpoint');
}
const responseType = body.response_type as string;
Logger.debug('Parameter response_type is', responseType);
// Support multiple types
const responseTypes = responseType.split(' ');
let grantTypes: string[] = [];
for (const responseType of responseTypes) {
switch (responseType) {
case 'code':
grantTypes.push('authorization_code');
break;
case 'token':
grantTypes.push('implicit');
break;
case 'id_token':
case 'none':
grantTypes.push(responseType);
break;
default:
throw new UnsupportedResponseType('Unknown response_type parameter passed');
}
}
// Filter out duplicates
grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index);
// "None" type cannot be combined with others
if (grantTypes.length > 1 && grantTypes.includes('none')) {
throw new InvalidRequest('Grant type "none" cannot be combined with other grant types');
}
Logger.debug('Parameter grant_type is', grantTypes.join(' '));
const client = await OAuth2Clients.fetchById(clientId);
if (!client || client.activated === 0) {
throw new InvalidClient('Client not found');
}
if (client.confidential === 0) {
throw new InvalidRequest(
'Non-confidential clients cannot use Pushed Authorization Requests, as the origin of such requests cannot be verified'
);
}
if (clientAssertion && clientAssertionType) {
const valid = await OAuth2Clients.validateClientAssertionAuthentication(
client,
clientAssertionType,
clientAssertion
);
if (!valid) {
throw new UnauthorizedClient('Invalid client assertion');
}
} else {
const valid = OAuth2Clients.checkSecret(client, clientSecret);
if (!valid) {
throw new UnauthorizedClient('Invalid client secret');
}
}
Logger.debug('client secret check passed');
if (!(await OAuth2Clients.getRedirectUrls(client.client_id))?.length) {
throw new UnsupportedResponseType('The client has not set a redirect uri');
} else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) {
throw new InvalidRequest('Wrong RedirectUri provided');
}
Logger.debug('redirect_uri check passed');
// The client needs to support all grant types
for (const grantType of grantTypes) {
if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) {
throw new UnauthorizedClient('This client does not support grant type ' + grantType);
}
}
Logger.debug('Grant type check passed');
const scope = OAuth2Clients.transformScope(body.scope as string);
if (!OAuth2Clients.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope');
}
Logger.debug('Scope check passed');
const codeChallenge = body.code_challenge as string;
const codeChallengeMethod = (body.code_challenge_method as CodeChallengeMethod) || 'plain';
if (codeChallengeMethod && !OAuth2Tokens.challengeMethods.includes(codeChallengeMethod)) {
throw new InvalidGrant('Invalid code challenge method');
}
if (client.confidential === 0 && !codeChallenge && grantTypes.includes('authorization_code')) {
throw new InvalidGrant(
'A code_challenge is required for the authorization_code grant in non-confidential clients'
);
}
const nonce = body.nonce as string;
const state = body.state as string;
const response = await OAuth2ParCodes.create(
clientId,
scope,
nonce,
codeChallenge,
codeChallengeMethod,
state,
redirectUri,
grantTypes.join(',')
);
return OAuth2Response.createResponse(200, response);
}
}

View File

@ -16,14 +16,26 @@ export class OAuth2TokenController {
static postHandler = async ({ url, request }: { url: URL; request: Request }) => { static postHandler = async ({ url, request }: { url: URL; request: Request }) => {
let clientId: string | null = null; let clientId: string | null = null;
let clientSecret: string | null = null; let clientSecret: string | null = null;
let clientAssertionType: string | null = null;
let clientAssertion: string | null = null;
let grantType: string | null = null; let grantType: string | null = null;
const body = await ApiUtils.getJsonOrFormBody(request); const body = await ApiUtils.getJsonOrFormBody(request);
if (body.client_id) { if (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;
Logger.debug('Client credentials parsed from body parameters', clientId, clientSecret); Logger.debug('Client basic credentials parsed from body parameters', clientId, clientSecret);
} else if (body.client_assertion) {
clientId = body.client_id as string;
clientAssertionType = body.client_assertion_type;
clientAssertion = body.client_assertion;
Logger.debug(
'Client assertion credentials parsed from body parameters',
clientId,
clientAssertionType,
clientAssertion
);
} 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');
@ -65,16 +77,36 @@ export class OAuth2TokenController {
grantType = 'device_code'; grantType = 'device_code';
} }
if (!clientId) {
throw new InvalidRequest('client_id field is mandatory for token endpoint');
}
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
if (!client || client.activated === 0) { if (!client || client.activated === 0) {
throw new InvalidClient('Client not found'); throw new InvalidClient('Client not found');
} }
// client_credentials cannot be fetched in public clients. // client_credentials cannot be fetched in public clients.
if (client.confidential === 1 || clientSecret || grantType === 'client_credentials') { if (
const valid = OAuth2Clients.checkSecret(client, clientSecret); client.confidential === 1 ||
if (!valid) { clientSecret ||
throw new UnauthorizedClient('Invalid client secret'); clientAssertion ||
grantType === 'client_credentials'
) {
if (clientAssertion && clientAssertionType) {
const valid = await OAuth2Clients.validateClientAssertionAuthentication(
client,
clientAssertionType,
clientAssertion
);
if (!valid) {
throw new UnauthorizedClient('Invalid client assertion');
}
} else {
const valid = OAuth2Clients.checkSecret(client, clientSecret);
if (!valid) {
throw new UnauthorizedClient('Invalid client secret');
}
} }
} }

View File

@ -18,6 +18,7 @@ import { Uploads } from '$lib/server/upload';
import { UserTokens, Users } from '$lib/server/users'; import { UserTokens, Users } from '$lib/server/users';
import type { PaginationMeta } from '$lib/types'; import type { PaginationMeta } from '$lib/types';
import { and, count, eq, like, or, sql } from 'drizzle-orm'; import { and, count, eq, like, or, sql } from 'drizzle-orm';
import { createLocalJWKSet, exportJWK, importJWK, jwtVerify, type JWK } from 'jose';
export enum OAuth2ClientURLType { export enum OAuth2ClientURLType {
REDIRECT_URI = 'redirect_uri', REDIRECT_URI = 'redirect_uri',
@ -389,6 +390,48 @@ export class OAuth2Clients {
await DB.drizzle.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id)); await DB.drizzle.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id));
} }
static async updateJwks(client: OAuth2Client, jwksString: string) {
let parsedKeys: JWK[] = [];
try {
const parsed = JSON.parse(jwksString);
let preParseList: JWK[] = [];
if (Array.isArray(parsed)) {
preParseList = parsed;
} else if (parsed.keys) {
preParseList = parsed.keys;
} else {
preParseList = [parsed];
}
// Reassign fields that do not survive the import-export.
// This prevents entering of arbitrary JSON data while also validating the key
parsedKeys = await Promise.all(
preParseList.map(async (entry: JWK) => {
const imported = await importJWK(entry);
const exported = await exportJWK(imported);
(['use', 'kid'] as (keyof JWK)[]).forEach((nKey) => {
exported[nKey] = (entry[nKey] || undefined) as never;
});
return exported;
})
);
// Deduplicate
parsedKeys = parsedKeys.filter(
(entry, index, array) =>
array.findIndex((item) => JSON.stringify(item) === JSON.stringify(entry)) === index
);
} catch {
throw new Error('Failed to parse JWKs');
}
await DB.drizzle
.update(oauth2Client)
.set({ jwks: parsedKeys })
.where(eq(oauth2Client.id, client.id));
}
static async getManagers(client: OAuth2Client) { static async getManagers(client: OAuth2Client) {
return await DB.drizzle return await DB.drizzle
.select({ id: oauth2ClientManager.id, email: user.email }) .select({ id: oauth2ClientManager.id, email: user.email })
@ -480,4 +523,40 @@ export class OAuth2Clients {
disallowedScopes disallowedScopes
}; };
} }
static async validateClientAssertionAuthentication(
client: OAuth2Client,
assertionType: string,
assertionToken: string
) {
if (
assertionType !== 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' ||
!client.jwks
) {
return false;
}
const parsedSet = typeof client.jwks === 'string' ? JSON.parse(client.jwks) : client.jwks;
const set = createLocalJWKSet({ keys: parsedSet as JWK[] });
try {
const { payload } = await jwtVerify(assertionToken, set, {
subject: client.client_id
});
// Check audience, token must be intended for our service
const checkAudience = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!checkAudience.some((entry) => entry?.startsWith(env.PUBLIC_URL))) {
return false;
}
// exp claim is mandatory
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) {
return false;
}
return true;
} catch {
return false;
}
}
} }

View File

@ -17,7 +17,8 @@ export enum OAuth2TokenType {
CODE = 'code', CODE = 'code',
DEVICE_CODE = 'device_code', DEVICE_CODE = 'device_code',
ACCESS_TOKEN = 'access_token', ACCESS_TOKEN = 'access_token',
REFRESH_TOKEN = 'refresh_token' REFRESH_TOKEN = 'refresh_token',
PAR = 'par'
} }
export interface OAuth2Code extends OAuth2Token { export interface OAuth2Code extends OAuth2Token {
@ -39,6 +40,7 @@ export class OAuth2Tokens {
static codeTtl = 3600; static codeTtl = 3600;
static tokenTtl = 604800; static tokenTtl = 604800;
static refreshTtl = 3.154e7; static refreshTtl = 3.154e7;
static parTtl = 180;
static challengeMethods: CodeChallengeMethod[] = ['plain', 'S256']; static challengeMethods: CodeChallengeMethod[] = ['plain', 'S256'];
static async insert( static async insert(
@ -49,7 +51,10 @@ export class OAuth2Tokens {
expiry: Date, expiry: Date,
user?: User, user?: User,
nonce?: string, nonce?: string,
pcke?: string pcke?: string,
state?: string,
redirectUri?: string,
grants?: string
) { ) {
const [retval] = await DB.drizzle.insert(oauth2Token).values({ const [retval] = await DB.drizzle.insert(oauth2Token).values({
token, token,
@ -59,7 +64,10 @@ export class OAuth2Tokens {
clientId: client.id, clientId: client.id,
userId: user?.id, userId: user?.id,
nonce, nonce,
pcke pcke,
state,
redirect_uri: redirectUri,
grants
}); });
const [newToken] = await DB.drizzle const [newToken] = await DB.drizzle
@ -134,6 +142,16 @@ export class OAuth2Tokens {
static getTTL(token: OAuth2Token): number { static getTTL(token: OAuth2Token): number {
return new Date(token.expires_at).getTime() - Date.now(); return new Date(token.expires_at).getTime() - Date.now();
} }
static readPcke(input: string | undefined | null) {
let codeChallenge: string | undefined;
let codeChallengeMethod: CodeChallengeMethod | undefined;
if (input) {
codeChallengeMethod = OAuth2Tokens.challengeMethods[Number(input.substring(0, 1))];
codeChallenge = input.substring(2);
}
return { codeChallenge, codeChallengeMethod };
}
} }
export class OAuth2Codes { export class OAuth2Codes {
@ -179,12 +197,7 @@ export class OAuth2Codes {
return undefined; return undefined;
} }
let codeChallenge: string | undefined; let { codeChallenge, codeChallengeMethod } = OAuth2Tokens.readPcke(find.pcke);
let codeChallengeMethod: CodeChallengeMethod | undefined;
if (find.pcke) {
codeChallengeMethod = OAuth2Tokens.challengeMethods[Number(find.pcke.substring(0, 1))];
codeChallenge = find.pcke.substring(2);
}
const client = await OAuth2Clients.fetchById(find.clientId as number); const client = await OAuth2Clients.fetchById(find.clientId as number);
if (!client || client.activated === 0) { if (!client || client.activated === 0) {
@ -413,3 +426,84 @@ export class OAuth2DeviceCodes {
return true; return true;
} }
} }
export class OAuth2ParCodes {
static issuePrefix = 'urn:ietf:params:oauth:request_uri:';
static async create(
clientId: string,
scope: string | string[],
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: CodeChallengeMethod,
state?: string,
redirectUri?: string,
grants?: string
) {
const client = await OAuth2Clients.fetchById(clientId);
const parCode = CryptoUtils.generateString(32);
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');
const expiresAt = new Date(Date.now() + OAuth2Tokens.parTtl * 1000);
const pcke =
codeChallenge && codeChallengeMethod
? `${OAuth2Tokens.challengeMethods.indexOf(codeChallengeMethod)}:${codeChallenge}`
: undefined;
await OAuth2Tokens.insert(
parCode,
OAuth2TokenType.PAR,
client,
scopes,
expiresAt,
undefined,
nonce,
pcke,
state,
redirectUri,
grants
);
return {
request_uri: `${OAuth2ParCodes.issuePrefix}${parCode}`,
expires_in: OAuth2Tokens.parTtl
};
}
static async getByRequestUri(clientId: string, requestUri: string) {
const token = requestUri.startsWith(OAuth2ParCodes.issuePrefix)
? requestUri.substring(OAuth2ParCodes.issuePrefix.length)
: requestUri;
const requestParams = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.PAR);
if (!requestParams) {
return undefined;
}
const client = await OAuth2Clients.fetchById(clientId);
if (client.id !== requestParams.clientId) {
return undefined;
}
let { codeChallenge, codeChallengeMethod } = OAuth2Tokens.readPcke(requestParams.pcke);
return {
...requestParams,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod
};
}
static async deleteByRequestUri(requestUri: string) {
if (!requestUri) {
return false;
}
const token = requestUri.startsWith(OAuth2ParCodes.issuePrefix)
? requestUri.substring(OAuth2ParCodes.issuePrefix.length)
: requestUri;
const find = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.PAR);
await OAuth2Tokens.remove(find);
return true;
}
}

View File

@ -55,9 +55,10 @@ export class OAuth2Response {
url: URL, url: URL,
obj: OAuth2ResponseType, obj: OAuth2ResponseType,
redirectUri?: string, redirectUri?: string,
fragment: boolean = false fragment: boolean = false,
state?: string
) { ) {
OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment); OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment, state);
return OAuth2Response.createResponse(200, obj); return OAuth2Response.createResponse(200, obj);
} }
@ -66,9 +67,10 @@ export class OAuth2Response {
url: URL, url: URL,
obj: OAuth2ResponseType, obj: OAuth2ResponseType,
redirectUri?: string, redirectUri?: string,
fragment: boolean = false fragment: boolean = false,
state?: string
) { ) {
OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment); OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment, state);
return obj; return obj;
} }
@ -95,14 +97,15 @@ export class OAuth2Response {
url: URL, url: URL,
obj: OAuth2ResponseType, obj: OAuth2ResponseType,
redirectUri?: string, redirectUri?: string,
fragment: boolean = false fragment: boolean = false,
state?: string
) { ) {
if (!redirectUri) return; if (!redirectUri) return;
const searchJoinChar = redirectUri.includes('?') ? '&' : '?'; const searchJoinChar = redirectUri.includes('?') ? '&' : '?';
redirectUri += fragment ? '#' : searchJoinChar; redirectUri += fragment ? '#' : searchJoinChar;
if (url.searchParams.has('state')) { if (state || url.searchParams.has('state')) {
(obj as OAuth2TokenResponse).state = url.searchParams.get('state') as string; (obj as OAuth2TokenResponse).state = state || (url.searchParams.get('state') as string);
} }
redirectUri += new URLSearchParams(obj as Record<string, string>).toString(); redirectUri += new URLSearchParams(obj as Record<string, string>).toString();

View File

@ -11,6 +11,8 @@ export const GET = async () =>
userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`, userinfo_endpoint: `${publicEnv.PUBLIC_URL}/api/user`,
introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`, introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`,
device_authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/device_authorization`, device_authorization_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/device_authorization`,
pushed_authorization_request_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/par`,
require_pushed_authorization_requests: false,
response_types_supported: ['code', 'id_token'], response_types_supported: ['code', 'id_token'],
id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM], id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM],
subject_types_supported: ['public'], subject_types_supported: ['public'],

View File

@ -36,7 +36,7 @@ export const actions = {
} }
const body = await request.formData(); const body = await request.formData();
const { challenge, otpCode } = Changesets.take<ActivateRequest>(['challenge', 'otpCode'], body); const { challenge, otpCode } = Changesets.only(['challenge', 'otpCode'], body);
if (!challenge) { if (!challenge) {
return issueActivateChallenge(currentUser); return issueActivateChallenge(currentUser);
@ -72,7 +72,7 @@ export const actions = {
} }
const body = await request.formData(); const body = await request.formData();
const { challenge, otpCode } = Changesets.take<ActivateRequest>(['challenge', 'otpCode'], body); const { challenge, otpCode } = Changesets.only(['challenge', 'otpCode'], body);
const userOtp = await TimeOTP.getUserOtp(currentUser); const userOtp = await TimeOTP.getUserOtp(currentUser);
if (!userOtp) { if (!userOtp) {

View File

@ -45,7 +45,7 @@ export const actions = {
} }
const body = await request.formData(); const body = await request.formData();
const { email, password, challenge, otpCode } = Changesets.take<LoginParams>( const { email, password, challenge, otpCode } = Changesets.only(
['email', 'password', 'challenge', 'otpCode'], ['email', 'password', 'challenge', 'otpCode'],
body body
); );

View File

@ -33,7 +33,7 @@ export const actions = {
} }
const body = await request.formData(); const body = await request.formData();
const { email } = Changesets.take<{ email: string }>(['email'], body); const { email } = Changesets.only(['email'], body);
if (!email || !emailRegex.test(email)) { if (!email || !emailRegex.test(email)) {
return { errors: ['invalidEmail'] }; return { errors: ['invalidEmail'] };
@ -81,7 +81,7 @@ export const actions = {
} }
const body = await request.formData(); const body = await request.formData();
const { newPassword, repeatPassword } = Changesets.take<PasswordRequest>( const { newPassword, repeatPassword } = Changesets.only(
['newPassword', 'repeatPassword'], ['newPassword', 'repeatPassword'],
body body
); );

View File

@ -0,0 +1,27 @@
import { OAuth2Error, SlowDown } from '$lib/server/oauth2/error.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { OAuth2PushedAuthorizationController } from '$lib/server/oauth2/controller/pushed-authorization.js';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
import { Audit, AuditAction } from '$lib/server/audit';
const limiter = new RateLimiter({
IP: [15, 'm']
});
export const POST = async (event) => {
const { request, url } = event;
try {
if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, `oauth2 par attempt`);
throw new SlowDown('Please, slow down!');
}
return await OAuth2PushedAuthorizationController.postRequest({ request });
} catch (error) {
if (error instanceof OAuth2Error) {
return OAuth2Response.error(url, error);
}
throw error;
}
};

View File

@ -38,7 +38,7 @@ export const actions = {
} }
const body = await request.formData(); const body = await request.formData();
const changes = Changesets.take<RegisterData>(fields, body); const changes = Changesets.only(fields, body);
const { const {
username, username,
displayName, displayName,

View File

@ -20,7 +20,7 @@ export const load = async ({ url, parent }) => {
AdminUtils.checkPrivileges(userInfo, ['admin:audit']); AdminUtils.checkPrivileges(userInfo, ['admin:audit']);
const actions = url.searchParams.getAll('actions') as AuditAction[]; const actions = url.searchParams.getAll('actions') as AuditAction[];
const { page, pageSize, user, content, ip, flagged } = Changesets.take<AuditSearchParams>( const { page, pageSize, user, content, ip, flagged } = Changesets.only(
['page', 'pageSize', 'user', 'content', 'ip', 'flagged'], ['page', 'pageSize', 'user', 'content', 'ip', 'flagged'],
url.searchParams url.searchParams
); );

View File

@ -83,11 +83,10 @@ export const actions = {
const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid); const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { title, description, activated, verified, confidential } = const { title, description, activated, verified, confidential } = Changesets.only(
Changesets.take<UpdateRequest>( ['title', 'description', 'activated', 'verified', 'confidential'],
['title', 'description', 'activated', 'verified', 'confidential'], body
body );
);
if (!!verified && !fullPrivileges) { if (!!verified && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] }); return fail(403, { errors: ['forbidden'] });
@ -218,8 +217,9 @@ export const actions = {
const { details, currentUser } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body); const { type, url } = Changesets.only(['type', 'url'], body);
if (!type || !OAuth2Clients.availableUrlTypes.includes(type)) { const urlType = type as OAuth2ClientURLType;
if (!type || !OAuth2Clients.availableUrlTypes.includes(urlType)) {
return fail(400, { errors: ['invalidUrlType'] }); return fail(400, { errors: ['invalidUrlType'] });
} }
@ -236,7 +236,7 @@ export const actions = {
return fail(400, { errors: ['illegalUrl'] }); return fail(400, { errors: ['illegalUrl'] });
} }
await OAuth2Clients.addUrl(details, type, url); await OAuth2Clients.addUrl(details, urlType, url);
await Audit.insertRequest( await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE, AuditAction.OAUTH2_UPDATE,
@ -276,7 +276,7 @@ export const actions = {
const { details, currentUser } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body); const { name } = Changesets.only(['name'], body);
if (!name || !privilegeRegex.test(name)) { if (!name || !privilegeRegex.test(name)) {
return fail(400, { errors: ['invalidPrivilege'] }); return fail(400, { errors: ['invalidPrivilege'] });
@ -411,7 +411,7 @@ export const actions = {
const { currentUser, details, fullPrivileges } = await getActionData(locals, uuid); const { currentUser, details, fullPrivileges } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { email } = Changesets.take<InviteRequest>(['email'], body); const { email } = Changesets.only(['email'], body);
if (!email || !emailRegex.test(email)) { if (!email || !emailRegex.test(email)) {
return fail(400, { errors: ['invalidEmail'] }); return fail(400, { errors: ['invalidEmail'] });
@ -466,6 +466,32 @@ export const actions = {
`remove manager\nclient_id=${details.client_id}` `remove manager\nclient_id=${details.client_id}`
); );
return { errors: [] };
},
/**
* Update client JWKs
*/
jwks: async ({ locals, request, params: { uuid }, getClientAddress }) => {
const { currentUser, details } = await getActionData(locals, uuid);
const body = await request.formData();
const value = body.get('jwks') as string;
if (!value) {
return fail(403, { errors: ['jwksRequired'] });
}
try {
await OAuth2Clients.updateJwks(details, value);
} catch {
return fail(403, { errors: ['invalidJwks'] });
}
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`update jwks\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
} }
}; };

View File

@ -45,6 +45,8 @@
let splitScopes = $derived(data.details.scope?.split(' ') || []); let splitScopes = $derived(data.details.scope?.split(' ') || []);
let splitGrants = $derived(data.details.grants?.split(' ') || []); let splitGrants = $derived(data.details.grants?.split(' ') || []);
let uuidPrefix = $derived(data.details.client_id.split('-')[0] + ':'); let uuidPrefix = $derived(data.details.client_id.split('-')[0] + ':');
const jwkPlaceholder = JSON.stringify([{ kty: 'RSA', n: '...', e: 'AQAB' }]);
</script> </script>
<svelte:head> <svelte:head>
@ -365,6 +367,14 @@
></code ></code
> >
</li> </li>
<li>
{$t('admin.oauth2.apis.par')} -
<code
><a href={`/oauth2/par`} data-sveltekit-preload-data="off"
>{env.PUBLIC_URL}/oauth2/par</a
></code
>
</li>
<li> <li>
{$t('admin.oauth2.apis.token')} - {$t('admin.oauth2.apis.token')} -
<code <code
@ -408,6 +418,25 @@
</ColumnView> </ColumnView>
</SplitView> </SplitView>
<h2>{$t('admin.oauth2.jwks.title')}</h2>
<p>{@html $t('admin.oauth2.jwks.subtitle')}</p>
<form action="?/jwks" method="POST">
<FormWrapper>
<FormSection>
<FormControl>
<label for="jwks-form">{$t('admin.oauth2.jwks.input')}</label>
<textarea name="jwks" id="jwks-form" rows="4" placeholder={jwkPlaceholder}
>{data.details.jwks}</textarea
>
</FormControl>
<ButtonRow>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</ButtonRow>
</FormSection>
</FormWrapper>
</form>
<h2>{$t('admin.oauth2.authorizations')}</h2> <h2>{$t('admin.oauth2.authorizations')}</h2>
<p>{$t('admin.oauth2.authorizationsHint')}</p> <p>{$t('admin.oauth2.authorizationsHint')}</p>

View File

@ -44,7 +44,7 @@ export const actions = {
const availablePrivileges = await Users.getAvailablePrivileges(details.id); const availablePrivileges = await Users.getAvailablePrivileges(details.id);
const body = await request.formData(); const body = await request.formData();
const { privileges } = Changesets.take<PrivilegesRequest>(['privileges'], body); const { privileges } = Changesets.only(['privileges'], body);
const splitFilter = (privileges || '').split(',').reduce<number[]>((final, id) => { const splitFilter = (privileges || '').split(',').reduce<number[]>((final, id) => {
const privId = Number(id); const privId = Number(id);

View File

@ -18,7 +18,7 @@ export const actions = {
]); ]);
const body = await request.formData(); const body = await request.formData();
const { title, description, redirectUri, confidential } = Changesets.take<CreateClientRequest>( const { title, description, redirectUri, confidential } = Changesets.only(
['title', 'description', 'redirectUri', 'confidential'], ['title', 'description', 'redirectUri', 'confidential'],
body body
); );

View File

@ -97,7 +97,7 @@ export const actions = {
]); ]);
const body = await request.formData(); const body = await request.formData();
const { displayName, email, activated, privileges } = Changesets.take<UpdateRequest>( const { displayName, email, activated, privileges } = Changesets.only(
['displayName', 'email', 'activated', 'privileges'], ['displayName', 'email', 'activated', 'privileges'],
body body
); );