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,
|
||||
"tag": "0003_round_killmonger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1717853591270,
|
||||
"tag": "0004_quiet_wolfsbane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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 })
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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({
|
||||
|
@ -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: [] };
|
||||
|
@ -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">
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user