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,
"tag": "0005_happy_meltdown",
"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;
export const handle = sequence(
csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization']),
csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization', '/oauth2/par']),
handleSession({
secret: SESSION_SECRET,
cookie: {

View File

@ -85,12 +85,13 @@
"add": "Add URL"
},
"apis": {
"title": "OAuth2 APIs",
"authorize": "OAuth2 Authorization endpoint",
"token": "OAuth2 Token endpoint",
"introspect": "OAuth2 Introspection endpoint",
"title": "OAuth 2.0 APIs",
"authorize": "OAuth 2.0 Authorization endpoint",
"par": "OAuth 2.0 Pushed Authorization Requests endpoint",
"token": "OAuth 2.0 Token endpoint",
"introspect": "OAuth 2.0 Introspection endpoint",
"userinfo": "User information endpoint (Bearer)",
"device": "OAuth2 Device Authorization endpoint",
"device": "OAuth 2.0 Device Authorization endpoint",
"openid": "OpenID Connect configuration"
},
"grantTexts": {
@ -116,6 +117,11 @@
"add": "Invite a new member",
"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": {
"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.",
@ -131,7 +137,9 @@
"invalidEmail": "Invalid email address.",
"emailExists": "This email address is already added.",
"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": {

View File

@ -1,4 +1,7 @@
export class Changesets {
/**
* @deprecated use `Changesets.only`
*/
static take<TRes>(
fields: (keyof TRes)[],
body: FormData | URLSearchParams,
@ -11,4 +14,17 @@ export class Changesets {
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,
mysqlEnum,
index,
type AnyMySqlColumn
type AnyMySqlColumn,
json
} from 'drizzle-orm/mysql-core';
import type { JWK } from 'jose';
export const jwks = mysqlTable('jwks', {
uuid: varchar('uuid', { length: 36 }).primaryKey(),
@ -70,6 +72,8 @@ export const oauth2Client = mysqlTable(
activated: tinyint('activated').default(0).notNull(),
verified: tinyint('verified').default(0).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' }),
ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }),
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', {
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(),
scope: text('scope'),
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' }),
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
nonce: text('nonce'),
state: text('state'),
grants: text('grants'),
redirect_uri: text('redirect_uri'),
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),

View File

@ -1,3 +1,4 @@
import type { OAuth2Token } from '$lib/server/drizzle';
import { Logger } from '$lib/server/logger';
import type { UserSession } from '../../users';
import {
@ -14,45 +15,68 @@ import {
OAuth2AccessTokens,
OAuth2Clients,
OAuth2Codes,
OAuth2ParCodes,
OAuth2Tokens,
type CodeChallengeMethod
} from '../model';
import { OAuth2Users } from '../model/user';
import { OAuth2Response } from '../response';
type OAuth2ParToken = OAuth2Token & {
code_challenge?: string;
code_challenge_method?: CodeChallengeMethod;
};
export class OAuth2AuthorizationController {
private static prehandle = async (url: URL, locals: App.Locals) => {
if (!url.searchParams.has('redirect_uri')) {
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint');
}
const requestUri = url.searchParams.get('request_uri');
let pushedRequest: OAuth2ParToken | undefined = undefined;
const redirectUri = url.searchParams.get('redirect_uri') as string;
Logger.debug('Parameter redirect uri is', redirectUri);
if (!url.searchParams.has('client_id')) {
const clientId = url.searchParams.get('client_id') as string;
if (!clientId) {
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)
if (url.searchParams.has('client_secret')) {
if (!pushedRequest && url.searchParams.has('client_secret')) {
throw new InvalidRequest(
'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);
if (!url.searchParams.has('response_type')) {
if (!pushedRequest && !url.searchParams.has('response_type')) {
throw new InvalidRequest('response_type field is mandatory for authorization endpoint');
}
let grantTypes: string[] = pushedRequest?.grants?.split(',') || [];
if (!pushedRequest) {
const responseType = url.searchParams.get('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':
@ -69,6 +93,7 @@ export class OAuth2AuthorizationController {
throw new UnsupportedResponseType('Unknown response_type parameter passed');
}
}
}
// Filter out duplicates
grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index);
@ -92,6 +117,11 @@ export class OAuth2AuthorizationController {
}
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
for (const grantType of grantTypes) {
if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) {
@ -100,15 +130,21 @@ export class OAuth2AuthorizationController {
}
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)) {
throw new InvalidScope('Client does not allow access to this scope');
}
Logger.debug('Scope check passed');
const codeChallenge = url.searchParams.get('code_challenge') as string;
const codeChallengeMethod =
(url.searchParams.get('code_challenge_method') as CodeChallengeMethod) || 'plain';
const codeChallenge = pushedRequest
? pushedRequest.code_challenge
: (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)) {
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 {
client,
user: locals.user,
redirectUri,
responseType,
grantTypes,
scope,
codeChallenge,
codeChallengeMethod
codeChallengeMethod,
nonce,
state
};
};
@ -142,9 +186,15 @@ export class OAuth2AuthorizationController {
codeChallenge,
codeChallengeMethod,
redirectUri,
responseType
nonce,
state
}: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>>
) => {
const requestUri = url.searchParams.get('request_uri');
if (requestUri) {
await OAuth2ParCodes.deleteByRequestUri(requestUri);
}
let resObj: Record<string, string | number> = {};
for (const grantType of grantTypes) {
let data = null;
@ -155,7 +205,7 @@ export class OAuth2AuthorizationController {
client.client_id,
scope,
OAuth2Tokens.codeTtl,
url.searchParams.get('nonce') as string,
nonce,
codeChallenge,
codeChallengeMethod
);
@ -184,12 +234,7 @@ export class OAuth2AuthorizationController {
break;
}
data = await OAuth2Users.issueIdToken(
user,
client,
scope,
url.searchParams.get('nonce') as string | undefined
);
data = await OAuth2Users.issueIdToken(user, client, scope, nonce);
resObj = {
id_token: data,
@ -205,7 +250,13 @@ export class OAuth2AuthorizationController {
}
// 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 }) => {

View File

@ -11,9 +11,23 @@ export class OAuth2DeviceAuthorizationController {
let clientId: 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;
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');
@ -39,7 +53,7 @@ export class OAuth2DeviceAuthorizationController {
}
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);
@ -47,12 +61,23 @@ export class OAuth2DeviceAuthorizationController {
throw new InvalidClient('Client not found');
}
if (
(client.confidential === 1 || clientSecret) &&
!OAuth2Clients.checkSecret(client, clientSecret)
) {
if (client.confidential === 1 || clientSecret || clientAssertion) {
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');
}
}
}
if (!OAuth2Clients.checkGrantType(client, 'device_code')) {
throw new UnauthorizedClient('This client does not support grant type device');

View File

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

View File

@ -10,11 +10,23 @@ export class OAuth2IntrospectionController {
let clientId: 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;
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 {
if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed');
@ -43,15 +55,30 @@ export class OAuth2IntrospectionController {
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);
if (!client || client.activated === 0) {
throw new InvalidClient('Client not found');
}
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('The client authentication was invalid');
throw new UnauthorizedClient('Invalid client secret');
}
}
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 }) => {
let clientId: string | null = null;
let clientSecret: string | null = null;
let clientAssertionType: string | null = null;
let clientAssertion: string | null = null;
let grantType: string | null = null;
const body = await ApiUtils.getJsonOrFormBody(request);
if (body.client_id) {
if (body.client_secret) {
clientId = body.client_id 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 {
if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed');
@ -65,18 +77,38 @@ export class OAuth2TokenController {
grantType = 'device_code';
}
if (!clientId) {
throw new InvalidRequest('client_id field is mandatory for token endpoint');
}
const client = await OAuth2Clients.fetchById(clientId);
if (!client || client.activated === 0) {
throw new InvalidClient('Client not found');
}
// client_credentials cannot be fetched in public clients.
if (client.confidential === 1 || clientSecret || grantType === 'client_credentials') {
if (
client.confidential === 1 ||
clientSecret ||
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');
}
}
}
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
throw new UnauthorizedClient('Invalid grant type for the client');

View File

@ -18,6 +18,7 @@ import { Uploads } from '$lib/server/upload';
import { UserTokens, Users } from '$lib/server/users';
import type { PaginationMeta } from '$lib/types';
import { and, count, eq, like, or, sql } from 'drizzle-orm';
import { createLocalJWKSet, exportJWK, importJWK, jwtVerify, type JWK } from 'jose';
export enum OAuth2ClientURLType {
REDIRECT_URI = 'redirect_uri',
@ -389,6 +390,48 @@ export class OAuth2Clients {
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) {
return await DB.drizzle
.select({ id: oauth2ClientManager.id, email: user.email })
@ -480,4 +523,40 @@ export class OAuth2Clients {
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',
DEVICE_CODE = 'device_code',
ACCESS_TOKEN = 'access_token',
REFRESH_TOKEN = 'refresh_token'
REFRESH_TOKEN = 'refresh_token',
PAR = 'par'
}
export interface OAuth2Code extends OAuth2Token {
@ -39,6 +40,7 @@ export class OAuth2Tokens {
static codeTtl = 3600;
static tokenTtl = 604800;
static refreshTtl = 3.154e7;
static parTtl = 180;
static challengeMethods: CodeChallengeMethod[] = ['plain', 'S256'];
static async insert(
@ -49,7 +51,10 @@ export class OAuth2Tokens {
expiry: Date,
user?: User,
nonce?: string,
pcke?: string
pcke?: string,
state?: string,
redirectUri?: string,
grants?: string
) {
const [retval] = await DB.drizzle.insert(oauth2Token).values({
token,
@ -59,7 +64,10 @@ export class OAuth2Tokens {
clientId: client.id,
userId: user?.id,
nonce,
pcke
pcke,
state,
redirect_uri: redirectUri,
grants
});
const [newToken] = await DB.drizzle
@ -134,6 +142,16 @@ export class OAuth2Tokens {
static getTTL(token: OAuth2Token): number {
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 {
@ -179,12 +197,7 @@ export class OAuth2Codes {
return undefined;
}
let codeChallenge: string | undefined;
let codeChallengeMethod: CodeChallengeMethod | undefined;
if (find.pcke) {
codeChallengeMethod = OAuth2Tokens.challengeMethods[Number(find.pcke.substring(0, 1))];
codeChallenge = find.pcke.substring(2);
}
let { codeChallenge, codeChallengeMethod } = OAuth2Tokens.readPcke(find.pcke);
const client = await OAuth2Clients.fetchById(find.clientId as number);
if (!client || client.activated === 0) {
@ -413,3 +426,84 @@ export class OAuth2DeviceCodes {
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,
obj: OAuth2ResponseType,
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);
}
@ -66,9 +67,10 @@ export class OAuth2Response {
url: URL,
obj: OAuth2ResponseType,
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;
}
@ -95,14 +97,15 @@ export class OAuth2Response {
url: URL,
obj: OAuth2ResponseType,
redirectUri?: string,
fragment: boolean = false
fragment: boolean = false,
state?: string
) {
if (!redirectUri) return;
const searchJoinChar = redirectUri.includes('?') ? '&' : '?';
redirectUri += fragment ? '#' : searchJoinChar;
if (url.searchParams.has('state')) {
(obj as OAuth2TokenResponse).state = url.searchParams.get('state') as string;
if (state || url.searchParams.has('state')) {
(obj as OAuth2TokenResponse).state = state || (url.searchParams.get('state') as string);
}
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`,
introspection_endpoint: `${publicEnv.PUBLIC_URL}/oauth2/introspect`,
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'],
id_token_signing_alg_values_supported: [privateEnv.JWT_ALGORITHM],
subject_types_supported: ['public'],

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ export const load = async ({ url, parent }) => {
AdminUtils.checkPrivileges(userInfo, ['admin:audit']);
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'],
url.searchParams
);

View File

@ -83,8 +83,7 @@ export const actions = {
const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid);
const body = await request.formData();
const { title, description, activated, verified, confidential } =
Changesets.take<UpdateRequest>(
const { title, description, activated, verified, confidential } = Changesets.only(
['title', 'description', 'activated', 'verified', 'confidential'],
body
);
@ -218,8 +217,9 @@ export const actions = {
const { details, currentUser } = await getActionData(locals, uuid);
const body = await request.formData();
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body);
if (!type || !OAuth2Clients.availableUrlTypes.includes(type)) {
const { type, url } = Changesets.only(['type', 'url'], body);
const urlType = type as OAuth2ClientURLType;
if (!type || !OAuth2Clients.availableUrlTypes.includes(urlType)) {
return fail(400, { errors: ['invalidUrlType'] });
}
@ -236,7 +236,7 @@ export const actions = {
return fail(400, { errors: ['illegalUrl'] });
}
await OAuth2Clients.addUrl(details, type, url);
await OAuth2Clients.addUrl(details, urlType, url);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
@ -276,7 +276,7 @@ export const actions = {
const { details, currentUser } = await getActionData(locals, uuid);
const body = await request.formData();
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body);
const { name } = Changesets.only(['name'], body);
if (!name || !privilegeRegex.test(name)) {
return fail(400, { errors: ['invalidPrivilege'] });
@ -411,7 +411,7 @@ export const actions = {
const { currentUser, details, fullPrivileges } = await getActionData(locals, uuid);
const body = await request.formData();
const { email } = Changesets.take<InviteRequest>(['email'], body);
const { email } = Changesets.only(['email'], body);
if (!email || !emailRegex.test(email)) {
return fail(400, { errors: ['invalidEmail'] });
@ -466,6 +466,32 @@ export const actions = {
`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: [] };
}
};

View File

@ -45,6 +45,8 @@
let splitScopes = $derived(data.details.scope?.split(' ') || []);
let splitGrants = $derived(data.details.grants?.split(' ') || []);
let uuidPrefix = $derived(data.details.client_id.split('-')[0] + ':');
const jwkPlaceholder = JSON.stringify([{ kty: 'RSA', n: '...', e: 'AQAB' }]);
</script>
<svelte:head>
@ -365,6 +367,14 @@
></code
>
</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>
{$t('admin.oauth2.apis.token')} -
<code
@ -408,6 +418,25 @@
</ColumnView>
</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>
<p>{$t('admin.oauth2.authorizationsHint')}</p>

View File

@ -44,7 +44,7 @@ export const actions = {
const availablePrivileges = await Users.getAvailablePrivileges(details.id);
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 privId = Number(id);

View File

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

View File

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