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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -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: {
|
||||||
|
@ -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": {
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
||||||
|
@ -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,45 +15,68 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let grantTypes: string[] = pushedRequest?.grants?.split(',') || [];
|
||||||
|
|
||||||
|
if (!pushedRequest) {
|
||||||
const responseType = url.searchParams.get('response_type') as string;
|
const responseType = url.searchParams.get('response_type') as string;
|
||||||
Logger.debug('Parameter response_type is', responseType);
|
Logger.debug('Parameter response_type is', responseType);
|
||||||
|
|
||||||
// Support multiple types
|
// Support multiple types
|
||||||
const responseTypes = responseType.split(' ');
|
const responseTypes = responseType.split(' ');
|
||||||
let grantTypes: string[] = [];
|
|
||||||
for (const responseType of responseTypes) {
|
for (const responseType of responseTypes) {
|
||||||
switch (responseType) {
|
switch (responseType) {
|
||||||
case 'code':
|
case 'code':
|
||||||
@ -69,6 +93,7 @@ export class OAuth2AuthorizationController {
|
|||||||
throw new UnsupportedResponseType('Unknown response_type parameter passed');
|
throw new UnsupportedResponseType('Unknown response_type parameter passed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out duplicates
|
// Filter out duplicates
|
||||||
grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index);
|
grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index);
|
||||||
@ -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 }) => {
|
||||||
|
@ -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,12 +61,23 @@ 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,
|
||||||
|
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');
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!OAuth2Clients.checkGrantType(client, 'device_code')) {
|
if (!OAuth2Clients.checkGrantType(client, 'device_code')) {
|
||||||
throw new UnauthorizedClient('This client does not support grant type device');
|
throw new UnauthorizedClient('This client does not support grant type device');
|
||||||
|
@ -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';
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const valid = OAuth2Clients.checkSecret(client, clientSecret);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new UnauthorizedClient('The client authentication was invalid');
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await OAuth2AccessTokens.fetchByToken(body.token);
|
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 }) => {
|
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,18 +77,38 @@ 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 (
|
||||||
|
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);
|
const valid = OAuth2Clients.checkSecret(client, clientSecret);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new UnauthorizedClient('Invalid client secret');
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
|
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
|
||||||
throw new UnauthorizedClient('Invalid grant type for the client');
|
throw new UnauthorizedClient('Invalid grant type for the client');
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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'],
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
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 body = await request.formData();
|
||||||
const changes = Changesets.take<RegisterData>(fields, body);
|
const changes = Changesets.only(fields, body);
|
||||||
const {
|
const {
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -83,8 +83,7 @@ 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
|
||||||
);
|
);
|
||||||
@ -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: [] };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user