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, "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
} }
] ]
} }

View File

@ -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>

View File

@ -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>

View File

@ -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",

View File

@ -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 })

View File

@ -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,

View File

@ -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');
} }

View File

@ -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');

View File

@ -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 {

View File

@ -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);

View File

@ -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({

View File

@ -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: [] };

View File

@ -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">

View File

@ -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}`);
} }

View File

@ -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>