client confidentiality toggle
This commit is contained in:
parent
27fdcc1cac
commit
3b16762f0e
1
migrations/0004_quiet_wolfsbane.sql
Normal file
1
migrations/0004_quiet_wolfsbane.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `o_auth2_client` ADD `confidential` tinyint DEFAULT 1 NOT NULL;
|
1072
migrations/meta/0004_snapshot.json
Normal file
1072
migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,13 @@
|
|||||||
"when": 1717843139528,
|
"when": 1717843139528,
|
||||||
"tag": "0003_round_killmonger",
|
"tag": "0003_round_killmonger",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1717853591270,
|
||||||
|
"tag": "0004_quiet_wolfsbane",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -33,6 +33,9 @@
|
|||||||
<dt>{$t('admin.oauth2.verified')}</dt>
|
<dt>{$t('admin.oauth2.verified')}</dt>
|
||||||
<dd>{$t(`common.bool.${Boolean(client.verified)}`)}</dd>
|
<dd>{$t(`common.bool.${Boolean(client.verified)}`)}</dd>
|
||||||
|
|
||||||
|
<dt>{$t('admin.oauth2.confidential')}</dt>
|
||||||
|
<dd>{$t(`common.bool.${Boolean(client.confidential)}`)}</dd>
|
||||||
|
|
||||||
<dt>{$t('admin.oauth2.created')}</dt>
|
<dt>{$t('admin.oauth2.created')}</dt>
|
||||||
<dd>{formatDate(client.created_at)}</dd>
|
<dd>{formatDate(client.created_at)}</dd>
|
||||||
|
|
||||||
|
@ -39,6 +39,10 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[type='checkbox'] {
|
||||||
|
grid-area: box;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control > :global(label) {
|
.form-control > :global(label) {
|
||||||
@ -66,12 +70,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-control:has(input[type='checkbox']) {
|
.form-control:has(input[type='checkbox']) {
|
||||||
flex-direction: row;
|
display: grid;
|
||||||
gap: 1rem;
|
grid-template-areas: 'box label' 'hint hint';
|
||||||
|
grid-template-columns: min-content auto;
|
||||||
|
justify-content: start;
|
||||||
|
column-gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control:has(input[type='checkbox']) > :global(span) {
|
||||||
|
grid-area: hint;
|
||||||
|
}
|
||||||
|
|
||||||
.form-control:has(input[type='checkbox']) > :global(label) {
|
.form-control:has(input[type='checkbox']) > :global(label) {
|
||||||
|
grid-area: label;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -43,7 +43,8 @@
|
|||||||
"scopesHint": "The level of access to information you will be needing for this application.",
|
"scopesHint": "The level of access to information you will be needing for this application.",
|
||||||
"grants": "Available grant types",
|
"grants": "Available grant types",
|
||||||
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
|
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
|
||||||
"grantsWarning": "Please note that id_token, implicit and device_code grant types are less secure than other flows, as they do not require a server to authenticate this application with its secret and there is potential for impersonation. You might want to make a separate application for using these flows and give them access to less information.",
|
"confidential": "Confidential",
|
||||||
|
"confidentialHint": "Uncheck this checkbox if you cannot reasonably guarantee the secrecy of your Client secret, such as in the case of your application being a native (desktop, mobile, etc.) application. Unchecking this will allow you to get tokens without client authentication. Some functionality may be limited in public applications.",
|
||||||
"created": "Created at",
|
"created": "Created at",
|
||||||
"owner": "Created by",
|
"owner": "Created by",
|
||||||
"ownerMe": "that's you!",
|
"ownerMe": "that's you!",
|
||||||
@ -97,8 +98,8 @@
|
|||||||
"client_credentials": "Client credentials",
|
"client_credentials": "Client credentials",
|
||||||
"refresh_token": "Refresh token",
|
"refresh_token": "Refresh token",
|
||||||
"device_code": "Device authorization",
|
"device_code": "Device authorization",
|
||||||
"implicit": "Implicit token",
|
"implicit": "Implicit access token (not recommended)",
|
||||||
"id_token": "ID token (OpenID Connect)"
|
"id_token": "Implicit ID token (not recommended)"
|
||||||
},
|
},
|
||||||
"scopeTexts": {
|
"scopeTexts": {
|
||||||
"picture": "Access profile picture URL",
|
"picture": "Access profile picture URL",
|
||||||
|
@ -52,6 +52,7 @@ export const oauth2Client = mysqlTable(
|
|||||||
grants: text('grants').default('authorization_code').notNull(),
|
grants: text('grants').default('authorization_code').notNull(),
|
||||||
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(),
|
||||||
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 })
|
||||||
|
@ -113,6 +113,12 @@ export class OAuth2AuthorizationController {
|
|||||||
throw new InvalidGrant('Invalid code challenge method');
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
|
@ -11,8 +11,8 @@ 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) {
|
if (body.client_id) {
|
||||||
clientId = body.client_id;
|
clientId = body.client_id as string;
|
||||||
clientSecret = body.client_secret;
|
clientSecret = body.client_secret as string;
|
||||||
} 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');
|
||||||
@ -46,8 +46,10 @@ export class OAuth2DeviceAuthorizationController {
|
|||||||
throw new InvalidClient('Client not found');
|
throw new InvalidClient('Client not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// This flow is the only one we allow to use public access (secret not required)
|
if (
|
||||||
if (clientSecret && !OAuth2Clients.checkSecret(client, clientSecret)) {
|
(client.confidential === 1 || clientSecret) &&
|
||||||
|
!OAuth2Clients.checkSecret(client, clientSecret)
|
||||||
|
) {
|
||||||
throw new UnauthorizedClient('Invalid client secret');
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,8 +69,8 @@ export class OAuth2TokenController {
|
|||||||
throw new InvalidClient('Client not found');
|
throw new InvalidClient('Client not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device code flow does not require client authentication
|
// client_credentials cannot be fetched in public clients.
|
||||||
if (grantType !== 'device_code' || clientSecret) {
|
if (client.confidential === 1 || clientSecret || grantType === 'client_credentials') {
|
||||||
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');
|
||||||
|
@ -61,32 +61,30 @@ export async function authorizationCode(
|
|||||||
const userId = code.userId as number;
|
const userId = code.userId as number;
|
||||||
const clientId = client.client_id;
|
const clientId = client.client_id;
|
||||||
|
|
||||||
if (OAuth2Codes.getCodeChallenge) {
|
const { challenge, method } = OAuth2Codes.getCodeChallenge(code);
|
||||||
const { challenge, method } = OAuth2Codes.getCodeChallenge(code);
|
|
||||||
|
|
||||||
if (challenge && method) {
|
if (challenge && method) {
|
||||||
if (!codeVerifier) {
|
if (!codeVerifier) {
|
||||||
throw new InvalidGrant('Code verifier is required!');
|
throw new InvalidGrant('Code verifier is required!');
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'plain' && !CryptoUtils.safeCompare(challenge, codeVerifier)) {
|
|
||||||
throw new InvalidGrant('Invalid code verifier!');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
method === 'S256' &&
|
|
||||||
!CryptoUtils.safeCompare(
|
|
||||||
CryptoUtils.createS256(codeVerifier),
|
|
||||||
CryptoUtils.sanitizeS256(challenge)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new InvalidGrant('Invalid code verifier!');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.debug('Code passed PCKE check');
|
if (method === 'plain' && !CryptoUtils.safeCompare(challenge, codeVerifier)) {
|
||||||
|
throw new InvalidGrant('Invalid code verifier!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
method === 'S256' &&
|
||||||
|
!CryptoUtils.safeCompare(
|
||||||
|
CryptoUtils.createS256(codeVerifier),
|
||||||
|
CryptoUtils.sanitizeS256(challenge)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new InvalidGrant('Invalid code verifier!');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.debug('Code passed PCKE check');
|
||||||
|
|
||||||
if (!OAuth2Clients.checkGrantType(client, 'refresh_token')) {
|
if (!OAuth2Clients.checkGrantType(client, 'refresh_token')) {
|
||||||
// console.debug('Client does not allow grant type refresh_token, skip creation');
|
// console.debug('Client does not allow grant type refresh_token, skip creation');
|
||||||
} else {
|
} else {
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
OAuth2AccessTokens,
|
OAuth2AccessTokens,
|
||||||
OAuth2Clients,
|
OAuth2Clients,
|
||||||
OAuth2DeviceCodes,
|
OAuth2DeviceCodes,
|
||||||
|
OAuth2RefreshTokens,
|
||||||
OAuth2Tokens,
|
OAuth2Tokens,
|
||||||
OAuth2Users
|
OAuth2Users
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
@ -55,6 +56,18 @@ export async function device(client: OAuth2Client, deviceCode: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OAuth2Clients.checkGrantType(client, 'refresh_token')) {
|
||||||
|
try {
|
||||||
|
resObj.refresh_token = await OAuth2RefreshTokens.create(
|
||||||
|
user.id,
|
||||||
|
client.client_id,
|
||||||
|
cleanScope
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call refreshTokens.create function');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resObj.expires_in = OAuth2Tokens.tokenTtl;
|
resObj.expires_in = OAuth2Tokens.tokenTtl;
|
||||||
|
|
||||||
await OAuth2DeviceCodes.removeByCode(deviceCode);
|
await OAuth2DeviceCodes.removeByCode(deviceCode);
|
||||||
|
@ -176,7 +176,7 @@ export class OAuth2Clients {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
static checkSecret(client: OAuth2Client, secret: string) {
|
static checkSecret(client: OAuth2Client, secret: string | null) {
|
||||||
return client.client_secret === secret;
|
return client.client_secret === secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,7 +316,13 @@ export class OAuth2Clients {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createClient(subject: User, title: string, redirect: string, description?: string) {
|
static async createClient(
|
||||||
|
subject: User,
|
||||||
|
title: string,
|
||||||
|
redirect: string,
|
||||||
|
description?: string,
|
||||||
|
confidential: boolean = true
|
||||||
|
) {
|
||||||
const uid = CryptoUtils.createUUID();
|
const uid = CryptoUtils.createUUID();
|
||||||
const secret = CryptoUtils.generateSecret();
|
const secret = CryptoUtils.generateSecret();
|
||||||
|
|
||||||
@ -330,7 +336,8 @@ export class OAuth2Clients {
|
|||||||
ownerId: subject.id,
|
ownerId: subject.id,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
activated: 1,
|
activated: 1,
|
||||||
verified: 0
|
verified: 0,
|
||||||
|
confidential: Number(confidential)
|
||||||
});
|
});
|
||||||
|
|
||||||
await DB.drizzle.insert(oauth2ClientUrl).values({
|
await DB.drizzle.insert(oauth2ClientUrl).values({
|
||||||
|
@ -25,6 +25,7 @@ interface UpdateRequest {
|
|||||||
description: string;
|
description: string;
|
||||||
activated?: string;
|
activated?: string;
|
||||||
verified?: string;
|
verified?: string;
|
||||||
|
confidential?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddPrivilegeRequest {
|
interface AddPrivilegeRequest {
|
||||||
@ -80,16 +81,18 @@ export const actions = {
|
|||||||
const { details, fullPrivileges } = await getActionData(locals, uuid);
|
const { details, fullPrivileges } = await getActionData(locals, uuid);
|
||||||
|
|
||||||
const body = await request.formData();
|
const body = await request.formData();
|
||||||
const { title, description, activated, verified } = Changesets.take<UpdateRequest>(
|
const { title, description, activated, verified, confidential } =
|
||||||
['title', 'description', 'activated', 'verified'],
|
Changesets.take<UpdateRequest>(
|
||||||
body
|
['title', 'description', 'activated', 'verified', 'confidential'],
|
||||||
);
|
body
|
||||||
|
);
|
||||||
|
|
||||||
if (!!verified && !fullPrivileges) {
|
if (!!verified && !fullPrivileges) {
|
||||||
return fail(403, { errors: ['forbidden'] });
|
return fail(403, { errors: ['forbidden'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const actuallyVerified = fullPrivileges ? Number(!!verified) : undefined;
|
const actuallyVerified = fullPrivileges ? Number(!!verified) : undefined;
|
||||||
|
const actuallyConfidential = fullPrivileges ? Number(!!confidential) : undefined;
|
||||||
const actuallyActivated = Number(!!activated);
|
const actuallyActivated = Number(!!activated);
|
||||||
|
|
||||||
if (title && (title.length < 3 || title.length > 32)) {
|
if (title && (title.length < 3 || title.length > 32)) {
|
||||||
@ -104,7 +107,8 @@ export const actions = {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
verified: actuallyVerified,
|
verified: actuallyVerified,
|
||||||
activated: actuallyActivated
|
activated: actuallyActivated,
|
||||||
|
confidential: actuallyConfidential
|
||||||
});
|
});
|
||||||
|
|
||||||
return { errors: [] };
|
return { errors: [] };
|
||||||
|
@ -102,6 +102,18 @@
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<label for="client-confidential">{$t('admin.oauth2.confidential')}</label>
|
||||||
|
<input
|
||||||
|
disabled={!data.fullPrivileges}
|
||||||
|
type="checkbox"
|
||||||
|
name="confidential"
|
||||||
|
id="client-confidential"
|
||||||
|
checked={Boolean(data.details.confidential)}
|
||||||
|
/>
|
||||||
|
<span>{$t('admin.oauth2.confidentialHint')}</span>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="client-activated">{$t('admin.oauth2.activated')}</label>
|
<label for="client-activated">{$t('admin.oauth2.activated')}</label>
|
||||||
<input
|
<input
|
||||||
@ -245,7 +257,6 @@
|
|||||||
|
|
||||||
<h2>{$t('admin.oauth2.grants')}</h2>
|
<h2>{$t('admin.oauth2.grants')}</h2>
|
||||||
<p>{$t('admin.oauth2.grantsHint')}</p>
|
<p>{$t('admin.oauth2.grantsHint')}</p>
|
||||||
<p><b>{$t('admin.oauth2.grantsWarning')}</b></p>
|
|
||||||
|
|
||||||
<form action="?/grants" method="POST">
|
<form action="?/grants" method="POST">
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid">
|
||||||
|
@ -7,6 +7,7 @@ interface CreateClientRequest {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
confidential?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@ -16,8 +17,8 @@ export const actions = {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const body = await request.formData();
|
const body = await request.formData();
|
||||||
const { title, description, redirectUri } = Changesets.take<CreateClientRequest>(
|
const { title, description, redirectUri, confidential } = Changesets.take<CreateClientRequest>(
|
||||||
['title', 'description', 'redirectUri'],
|
['title', 'description', 'redirectUri', 'confidential'],
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -33,7 +34,13 @@ export const actions = {
|
|||||||
return fail(400, { errors: ['noRedirect'] });
|
return fail(400, { errors: ['noRedirect'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const uuid = await OAuth2Clients.createClient(currentUser, title, redirectUri, description);
|
const uuid = await OAuth2Clients.createClient(
|
||||||
|
currentUser,
|
||||||
|
title,
|
||||||
|
redirectUri,
|
||||||
|
description,
|
||||||
|
!!confidential
|
||||||
|
);
|
||||||
|
|
||||||
return redirect(303, `/ssoadmin/oauth2/${uuid}`);
|
return redirect(303, `/ssoadmin/oauth2/${uuid}`);
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import type { ActionData } from './$types';
|
import type { ActionData } from './$types';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||||
|
|
||||||
export let form: ActionData;
|
export let form: ActionData;
|
||||||
</script>
|
</script>
|
||||||
@ -17,27 +18,35 @@
|
|||||||
|
|
||||||
<h1>{$t('admin.oauth2.new')}</h1>
|
<h1>{$t('admin.oauth2.new')}</h1>
|
||||||
|
|
||||||
<form action="" method="POST">
|
<SplitView>
|
||||||
<FormWrapper>
|
<form action="" method="POST">
|
||||||
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
|
<FormWrapper>
|
||||||
|
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
|
||||||
|
|
||||||
<FormSection required>
|
<FormSection required>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="form-title">{$t('admin.oauth2.clientTitle')}</label>
|
<label for="form-title">{$t('admin.oauth2.clientTitle')}</label>
|
||||||
<input name="title" id="form-title" required />
|
<input name="title" id="form-title" required />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="form-redirectUri">{$t('admin.oauth2.urls.types.redirect_uri')}</label>
|
<label for="client-confidential">{$t('admin.oauth2.confidential')}</label>
|
||||||
<input name="redirectUri" type="url" id="form-redirectUri" required />
|
<input type="checkbox" name="confidential" id="client-confidential" checked />
|
||||||
</FormControl>
|
<span>{$t('admin.oauth2.confidentialHint')}</span>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="client-description">{$t('admin.oauth2.description')}</label>
|
<label for="form-redirectUri">{$t('admin.oauth2.urls.types.redirect_uri')}</label>
|
||||||
<textarea name="description" id="client-description" rows="3" />
|
<input name="redirectUri" type="url" id="form-redirectUri" required />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
<FormControl>
|
||||||
</FormWrapper>
|
<label for="client-description">{$t('admin.oauth2.description')}</label>
|
||||||
</form>
|
<textarea name="description" id="client-description" rows="3" />
|
||||||
|
</FormControl>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||||
|
</FormWrapper>
|
||||||
|
</form>
|
||||||
|
</SplitView>
|
||||||
|
Loading…
Reference in New Issue
Block a user