JWT client authentication, OAuth2 PAR support
This commit is contained in:
parent
563e0a0350
commit
5faa30d691
5
migrations/0006_clean_mandrill.sql
Normal file
5
migrations/0006_clean_mandrill.sql
Normal 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;
|
1
migrations/0007_slim_dexter_bennett.sql
Normal file
1
migrations/0007_slim_dexter_bennett.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `o_auth2_client` ADD `jwks` json;
|
1179
migrations/meta/0006_snapshot.json
Normal file
1179
migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1186
migrations/meta/0007_snapshot.json
Normal file
1186
migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
@ -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: {
|
||||
|
@ -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": {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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 }) => {
|
||||
|
@ -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');
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from './authorization';
|
||||
export * from './pushed-authorization';
|
||||
export * from './introspection';
|
||||
export * from './token';
|
||||
export * from './bearer';
|
||||
|
@ -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);
|
||||
|
187
src/lib/server/oauth2/controller/pushed-authorization.ts
Normal file
187
src/lib/server/oauth2/controller/pushed-authorization.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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'],
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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
|
||||
);
|
||||
|
27
src/routes/oauth2/par/+server.ts
Normal file
27
src/routes/oauth2/par/+server.ts
Normal 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;
|
||||
}
|
||||
};
|
@ -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,
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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: [] };
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user