client confidentiality toggle

This commit is contained in:
Evert Prants 2024-06-08 17:09:27 +03:00
parent 27fdcc1cac
commit 3b16762f0e
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
17 changed files with 1218 additions and 64 deletions

View File

@ -0,0 +1 @@
ALTER TABLE `o_auth2_client` ADD `confidential` tinyint DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,13 @@
"when": 1717843139528,
"tag": "0003_round_killmonger",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1717853591270,
"tag": "0004_quiet_wolfsbane",
"breakpoints": true
}
]
}

View File

@ -33,6 +33,9 @@
<dt>{$t('admin.oauth2.verified')}</dt>
<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>
<dd>{formatDate(client.created_at)}</dd>

View File

@ -39,6 +39,10 @@
padding: 1rem;
border-radius: 4px;
}
&[type='checkbox'] {
grid-area: box;
}
}
.form-control > :global(label) {
@ -66,12 +70,20 @@
}
.form-control:has(input[type='checkbox']) {
flex-direction: row;
gap: 1rem;
display: grid;
grid-template-areas: 'box label' 'hint hint';
grid-template-columns: min-content auto;
justify-content: start;
column-gap: 1rem;
align-items: center;
}
.form-control:has(input[type='checkbox']) > :global(span) {
grid-area: hint;
}
.form-control:has(input[type='checkbox']) > :global(label) {
grid-area: label;
margin: 0;
}
</style>

View File

@ -43,7 +43,8 @@
"scopesHint": "The level of access to information you will be needing for this application.",
"grants": "Available grant types",
"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",
"owner": "Created by",
"ownerMe": "that's you!",
@ -97,8 +98,8 @@
"client_credentials": "Client credentials",
"refresh_token": "Refresh token",
"device_code": "Device authorization",
"implicit": "Implicit token",
"id_token": "ID token (OpenID Connect)"
"implicit": "Implicit access token (not recommended)",
"id_token": "Implicit ID token (not recommended)"
},
"scopeTexts": {
"picture": "Access profile picture URL",

View File

@ -52,6 +52,7 @@ export const oauth2Client = mysqlTable(
grants: text('grants').default('authorization_code').notNull(),
activated: tinyint('activated').default(0).notNull(),
verified: tinyint('verified').default(0).notNull(),
confidential: tinyint('confidential').default(1).notNull(),
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 })

View File

@ -113,6 +113,12 @@ export class OAuth2AuthorizationController {
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 {
client,
user: locals.user,

View File

@ -11,8 +11,8 @@ export class OAuth2DeviceAuthorizationController {
let clientId: string | null = null;
let clientSecret: string | null = null;
if (body.client_id) {
clientId = body.client_id;
clientSecret = body.client_secret;
clientId = body.client_id as string;
clientSecret = body.client_secret as string;
} else {
if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed');
@ -46,8 +46,10 @@ export class OAuth2DeviceAuthorizationController {
throw new InvalidClient('Client not found');
}
// This flow is the only one we allow to use public access (secret not required)
if (clientSecret && !OAuth2Clients.checkSecret(client, clientSecret)) {
if (
(client.confidential === 1 || clientSecret) &&
!OAuth2Clients.checkSecret(client, clientSecret)
) {
throw new UnauthorizedClient('Invalid client secret');
}

View File

@ -69,8 +69,8 @@ export class OAuth2TokenController {
throw new InvalidClient('Client not found');
}
// Device code flow does not require client authentication
if (grantType !== 'device_code' || clientSecret) {
// client_credentials cannot be fetched in public clients.
if (client.confidential === 1 || clientSecret || grantType === 'client_credentials') {
const valid = OAuth2Clients.checkSecret(client, clientSecret);
if (!valid) {
throw new UnauthorizedClient('Invalid client secret');

View File

@ -61,32 +61,30 @@ export async function authorizationCode(
const userId = code.userId as number;
const clientId = client.client_id;
if (OAuth2Codes.getCodeChallenge) {
const { challenge, method } = OAuth2Codes.getCodeChallenge(code);
const { challenge, method } = OAuth2Codes.getCodeChallenge(code);
if (challenge && method) {
if (!codeVerifier) {
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!');
}
if (challenge && method) {
if (!codeVerifier) {
throw new InvalidGrant('Code verifier is required!');
}
// 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')) {
// console.debug('Client does not allow grant type refresh_token, skip creation');
} else {

View File

@ -5,6 +5,7 @@ import {
OAuth2AccessTokens,
OAuth2Clients,
OAuth2DeviceCodes,
OAuth2RefreshTokens,
OAuth2Tokens,
OAuth2Users
} 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;
await OAuth2DeviceCodes.removeByCode(deviceCode);

View File

@ -176,7 +176,7 @@ export class OAuth2Clients {
return list;
}
static checkSecret(client: OAuth2Client, secret: string) {
static checkSecret(client: OAuth2Client, secret: string | null) {
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 secret = CryptoUtils.generateSecret();
@ -330,7 +336,8 @@ export class OAuth2Clients {
ownerId: subject.id,
created_at: new Date(),
activated: 1,
verified: 0
verified: 0,
confidential: Number(confidential)
});
await DB.drizzle.insert(oauth2ClientUrl).values({

View File

@ -25,6 +25,7 @@ interface UpdateRequest {
description: string;
activated?: string;
verified?: string;
confidential?: string;
}
interface AddPrivilegeRequest {
@ -80,16 +81,18 @@ export const actions = {
const { details, fullPrivileges } = await getActionData(locals, uuid);
const body = await request.formData();
const { title, description, activated, verified } = Changesets.take<UpdateRequest>(
['title', 'description', 'activated', 'verified'],
body
);
const { title, description, activated, verified, confidential } =
Changesets.take<UpdateRequest>(
['title', 'description', 'activated', 'verified', 'confidential'],
body
);
if (!!verified && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] });
}
const actuallyVerified = fullPrivileges ? Number(!!verified) : undefined;
const actuallyConfidential = fullPrivileges ? Number(!!confidential) : undefined;
const actuallyActivated = Number(!!activated);
if (title && (title.length < 3 || title.length > 32)) {
@ -104,7 +107,8 @@ export const actions = {
title,
description,
verified: actuallyVerified,
activated: actuallyActivated
activated: actuallyActivated,
confidential: actuallyConfidential
});
return { errors: [] };

View File

@ -102,6 +102,18 @@
/>
</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>
<label for="client-activated">{$t('admin.oauth2.activated')}</label>
<input
@ -245,7 +257,6 @@
<h2>{$t('admin.oauth2.grants')}</h2>
<p>{$t('admin.oauth2.grantsHint')}</p>
<p><b>{$t('admin.oauth2.grantsWarning')}</b></p>
<form action="?/grants" method="POST">
<div class="checkbox-grid">

View File

@ -7,6 +7,7 @@ interface CreateClientRequest {
title: string;
description: string;
redirectUri: string;
confidential?: string;
}
export const actions = {
@ -16,8 +17,8 @@ export const actions = {
]);
const body = await request.formData();
const { title, description, redirectUri } = Changesets.take<CreateClientRequest>(
['title', 'description', 'redirectUri'],
const { title, description, redirectUri, confidential } = Changesets.take<CreateClientRequest>(
['title', 'description', 'redirectUri', 'confidential'],
body
);
@ -33,7 +34,13 @@ export const actions = {
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}`);
}

View File

@ -7,6 +7,7 @@
import { t } from '$lib/i18n';
import type { ActionData } from './$types';
import { env } from '$env/dynamic/public';
import SplitView from '$lib/components/container/SplitView.svelte';
export let form: ActionData;
</script>
@ -17,27 +18,35 @@
<h1>{$t('admin.oauth2.new')}</h1>
<form action="" method="POST">
<FormWrapper>
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<SplitView>
<form action="" method="POST">
<FormWrapper>
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<FormSection required>
<FormControl>
<label for="form-title">{$t('admin.oauth2.clientTitle')}</label>
<input name="title" id="form-title" required />
</FormControl>
<FormSection required>
<FormControl>
<label for="form-title">{$t('admin.oauth2.clientTitle')}</label>
<input name="title" id="form-title" required />
</FormControl>
<FormControl>
<label for="form-redirectUri">{$t('admin.oauth2.urls.types.redirect_uri')}</label>
<input name="redirectUri" type="url" id="form-redirectUri" required />
</FormControl>
<FormControl>
<label for="client-confidential">{$t('admin.oauth2.confidential')}</label>
<input type="checkbox" name="confidential" id="client-confidential" checked />
<span>{$t('admin.oauth2.confidentialHint')}</span>
</FormControl>
<FormControl>
<label for="client-description">{$t('admin.oauth2.description')}</label>
<textarea name="description" id="client-description" rows="3" />
</FormControl>
</FormSection>
<FormControl>
<label for="form-redirectUri">{$t('admin.oauth2.urls.types.redirect_uri')}</label>
<input name="redirectUri" type="url" id="form-redirectUri" required />
</FormControl>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</FormWrapper>
</form>
<FormControl>
<label for="client-description">{$t('admin.oauth2.description')}</label>
<textarea name="description" id="client-description" rows="3" />
</FormControl>
</FormSection>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</FormWrapper>
</form>
</SplitView>