oauth2 client adminning

This commit is contained in:
Evert Prants 2024-06-02 12:42:45 +03:00
parent d258880ac4
commit d11403a073
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 1295 additions and 32 deletions

View File

@ -0,0 +1,101 @@
<script lang="ts">
import { t } from '$lib/i18n';
import type { PageData } from '../../../routes/ssoadmin/oauth2/$types';
export let client: PageData['list'][0];
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
const formatDate = dateFormat.format.bind(null);
</script>
<div class="client">
<div class="client-avatar-wrapper">
<img class="client-avatar" src={`/api/avatar/client/${client.client_id}`} alt={client.title} />
</div>
<div class="client-info">
<h2 class="client-name">{client.title}</h2>
<span class="client-description">{client.description}</span>
<dl>
<dt>{$t('admin.oauth2.clientId')}</dt>
<dd>{client.client_id}</dd>
<dt>{$t('admin.oauth2.scopes')}</dt>
<dd>{client.scope}</dd>
<dt>{$t('admin.oauth2.grants')}</dt>
<dd>{client.grants}</dd>
<dt>{$t('admin.oauth2.activated')}</dt>
<dd>{$t(`common.bool.${Boolean(client.activated)}`)}</dd>
<dt>{$t('admin.oauth2.verified')}</dt>
<dd>{$t(`common.bool.${Boolean(client.verified)}`)}</dd>
<dt>{$t('admin.oauth2.created')}</dt>
<dd>{formatDate(client.created_at)}</dd>
<dt>{$t('admin.oauth2.owner')}</dt>
<dd>
{client.ownerInfo?.uuid} ({client.ownerInfo?.name})
{#if client.isOwner}<i> - {$t('admin.oauth2.ownerMe')}</i>{/if}
</dd>
<dt>{$t('admin.oauth2.urls.title')}</dt>
<dd>
{#each client.urls as url}
<a href={url.url} target="_blank" rel="nofollow noreferrer" class="client-url"
>{$t(`admin.oauth2.urls.types.${url.type}`)} &lt;{url.url}&gt;</a
>
{/each}
</dd>
</dl>
</div>
</div>
<style>
.client {
display: flex;
gap: 1rem;
padding: 8px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
flex-grow: 1;
transition: transform 100ms linear;
&:hover {
transform: scale(1.01);
}
& .client-avatar {
width: 128px;
height: 128px;
object-fit: contain;
}
& .client-info {
display: flex;
flex-direction: column;
& > dl {
margin: 0;
& > dt {
font-weight: 600;
margin-top: 0.5rem;
}
}
}
& .client-name {
margin: 0;
}
& .client-url {
display: block;
}
}
</style>

View File

@ -10,6 +10,7 @@
import { allowedImages } from '$lib/constants';
export let show: Writable<boolean>;
export let url: string = '/account';
let cropper: Cropper;
let image: HTMLImageElement;
@ -75,7 +76,7 @@
const data = new FormData();
data.append('file', resultBlob);
await fetch(`/account?/avatar`, {
await fetch(`${url}?/avatar`, {
method: 'POST',
body: data,
credentials: 'include',

View File

@ -3,6 +3,8 @@
</div>
<style>
:global(textarea),
:global(select),
:global(input) {
background-color: var(--in-input-background);
color: var(--in-input-color);
@ -11,6 +13,7 @@
&:not([type]),
&[type='text'],
&[type='password'],
&[type='url'],
&[type='email'] {
padding: 8px;
font-size: 1rem;
@ -47,6 +50,7 @@
display: flex;
flex-direction: column;
width: 100%;
position: relative;
}
.form-control > :global(label):has(+ input[required])::after {

View File

@ -2,7 +2,7 @@
"title": "Admin",
"menu": {
"users": "Users",
"oauth2": "OAuth2 clients",
"oauth2": "OAuth2 applications",
"audit": "Audit logs"
},
"users": {
@ -27,5 +27,86 @@
"invalidDisplayName": "Invalid display name",
"invalidEmail": "Invalid email address"
}
},
"oauth2": {
"title": "OAuth2 applications",
"clientTitle": "Application name",
"clientId": "Client ID",
"clientSecret": "Client secret",
"description": "Application description",
"reveal": "Reveal secret",
"regenerate": "Regenerate secret",
"activated": "Activated",
"verified": "Verified",
"scopes": "Available scopes",
"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.",
"created": "Created at",
"owner": "Created by",
"ownerMe": "that's you!",
"actions": "Actions",
"delete": "Delete application permanently",
"avatar": {
"title": "Application icon",
"remove": "Delete application icon",
"change": "Change application icon"
},
"authorizations": "Authorized users",
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.",
"revoked": "This authorization has been revoked by the user",
"noAuthorizations": "There are no authorizations on record for this application.",
"privileges": {
"title": "Application privileges",
"name": "Privilege name",
"nameHint": "An unique prefix \"{{prefix}}\" will be prepended automatically. English alphabet and ._-: characters only.",
"add": "Add a new privilege",
"addHint": "You may assign application-specific privileges to your authorized users. You may use this system for permissions or for tagging your users, all up to you!",
"remove": "Remove",
"manage": "Manage user privileges",
"new": "Add a new privilege"
},
"urls": {
"title": "Application URLs",
"new": "Add a new URL",
"type": "URL type",
"url": "URL",
"types": {
"website": "Website",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"redirect_uri": "Redirect URI"
},
"add": "Add URL"
},
"grantTexts": {
"authorization_code": "Authorization code",
"client_credentials": "Client credentials",
"refresh_token": "Refresh token",
"implicit": "Implicit token",
"id_token": "ID token (OpenID Connect)"
},
"scopeTexts": {
"picture": "Access profile picture URL",
"profile": "Basic profile information",
"email": "Access user email address",
"privileges": "Access user privilege list",
"management": "Manage your application",
"account": "Change user account settings",
"openid": "Get an ID token JWT (OpenID Connect)"
},
"errors": {
"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.",
"invalidTitle": "Name must be between 3 and 32 characters long.",
"invalidDescription": "Description must be at most 1000 characters long.",
"deleteActivated": "Cannot delete an active application. Please deactivate it first.",
"invalidUrlId": "Invalid URL ID for deletion.",
"invalidUrlType": "Invalid URL type provided.",
"invalidUrl": "Invalid URL provided.",
"invalidPrivilegeId": "Invalid privilege ID for deletion.",
"invalidPrivilege": "Invalid privilege provided.",
"noFile": "Please upload a file first."
}
}
}

View File

@ -15,5 +15,6 @@
"false": "No"
},
"available": "Available",
"current": "Current"
"current": "Current",
"remove": "Remove"
}

View File

@ -1,7 +1,8 @@
import i18n, { type Config } from 'sveltekit-i18n';
interface Params {
siteName: string;
siteName?: string;
prefix?: string;
}
const config: Config<Params> = {

View File

@ -1,5 +1,17 @@
import { db, oauth2Client, oauth2ClientUrl, type OAuth2Client } from '$lib/server/drizzle';
import { and, eq } from 'drizzle-orm';
import {
db,
oauth2Client,
oauth2ClientManager,
oauth2ClientUrl,
privilege,
user,
type OAuth2Client,
type OAuth2ClientUrl,
type User
} from '$lib/server/drizzle';
import { Uploads } from '$lib/server/upload';
import type { PaginationMeta } from '$lib/types';
import { and, count, eq, ilike, like, or } from 'drizzle-orm';
export enum OAuth2ClientURLType {
REDIRECT_URI = 'redirect_uri',
@ -8,17 +20,25 @@ export enum OAuth2ClientURLType {
WEBSITE = 'website'
}
export interface OAuth2ClientAdminListItem
extends PartialK<OAuth2Client, 'client_secret' | 'ownerId'> {
isOwner?: boolean;
ownerInfo?: { uuid: string; name: string };
urls: Omit<OAuth2ClientUrl, 'created_at' | 'updated_at' | 'clientId'>[];
}
export class OAuth2Clients {
public static availableGrantTypes = [
'authorization_code',
'client_credentials',
'refresh_token',
'id_token',
'implicit'
];
public static availableScopes = [
'picture',
'profile',
'picture',
'email',
'privileges',
'management',
@ -26,9 +46,26 @@ export class OAuth2Clients {
'openid'
];
public static userSetScopes = [
'profile',
'picture',
'email',
'privileges',
'management',
'openid'
];
public static userSetGrants = [
'authorization_code',
'client_credentials',
'refresh_token',
'id_token'
];
public static describedScopes = ['email', 'picture', 'account'];
public static alwaysPresentScopes = ['profile'];
public static availableUrlTypes: OAuth2ClientURLType[] = Object.values(OAuth2ClientURLType);
static async fetchById(id: string | number) {
const [client] = await db
.select()
@ -104,6 +141,145 @@ export class OAuth2Clients {
return scope.join(' ');
}
static async getClientByAdminUser(
subject: User,
filters?: {
clientId?: string;
filter?: string;
limit?: number;
offset?: number;
omitSecret?: boolean;
listAll?: boolean;
}
) {
const filterText = `%${filters?.filter}%`;
const limit = filters?.limit || 20;
const allowedClients = db
.select({ id: oauth2Client.id })
.from(oauth2Client)
.leftJoin(oauth2ClientManager, eq(oauth2ClientManager.clientId, oauth2Client.id))
.where(
and(
!filters?.listAll
? or(eq(oauth2Client.ownerId, subject.id), eq(oauth2ClientManager.userId, subject.id))
: undefined,
filters?.clientId ? eq(oauth2Client.client_id, filters.clientId) : undefined,
filters?.filter
? or(ilike(oauth2Client.title, filterText), like(oauth2Client.client_id, filterText))
: undefined
)
)
.groupBy(oauth2Client.id)
.limit(limit)
.offset(filters?.offset || 0)
.as('allowedClients');
const [{ rowCount }] = await db
.select({
rowCount: count(oauth2Client.id).mapWith(Number)
})
.from(allowedClients)
.innerJoin(oauth2Client, eq(allowedClients.id, oauth2Client.id));
const junkList = await db
.select({
o_auth2_client: oauth2Client,
o_auth2_client_url: oauth2ClientUrl,
user: user
})
.from(allowedClients)
.innerJoin(oauth2Client, eq(allowedClients.id, oauth2Client.id))
.leftJoin(oauth2ClientUrl, eq(oauth2ClientUrl.clientId, oauth2Client.id))
.leftJoin(user, eq(oauth2Client.ownerId, user.id));
const list = junkList.reduce<OAuth2ClientAdminListItem[]>((accum, dbo) => {
let client = accum.find(({ id }) => id === dbo.o_auth2_client.id);
if (!client) {
client = {
...dbo.o_auth2_client,
isOwner: dbo.o_auth2_client.ownerId === subject.id,
urls: []
};
accum.push(client);
}
if (dbo.o_auth2_client_url) {
if (!client.urls.some(({ id }) => dbo.o_auth2_client_url?.id === id)) {
client.urls.push({
...dbo.o_auth2_client_url,
clientId: undefined,
created_at: undefined,
updated_at: undefined
} as OAuth2ClientAdminListItem['urls'][0]);
}
}
if (dbo.user) {
client.ownerInfo = {
uuid: dbo.user.uuid,
name: dbo.user.display_name
};
}
if (filters?.omitSecret) {
delete client.client_secret;
}
return accum;
}, []);
const meta: PaginationMeta = {
rowCount,
pageSize: limit,
pageCount: Math.ceil(rowCount / limit)
};
return {
list,
meta
};
}
static async deleteClient(client: OAuth2Client) {
if (client.pictureId) {
await Uploads.removeClientAvatar(client);
}
await db.delete(privilege).where(eq(privilege.clientId, client.id));
await db.delete(oauth2Client).where(eq(oauth2Client.id, client.id));
}
static async deleteUrl(client: OAuth2Client, urlId: number) {
await db
.delete(oauth2ClientUrl)
.where(and(eq(oauth2ClientUrl.clientId, client.id), eq(oauth2ClientUrl.id, urlId)));
}
static async addUrl(client: OAuth2Client, type: OAuth2ClientURLType, url: string) {
await db.insert(oauth2ClientUrl).values({
type,
url,
clientId: client.id
});
}
static async deletePrivilege(client: OAuth2Client, privilegeId: number) {
await db
.delete(privilege)
.where(and(eq(privilege.clientId, client.id), eq(privilege.id, privilegeId)));
}
static async addPrivilege(client: OAuth2Client, name: string) {
const realName = `${client.client_id.split('-')[0]}:${name}`;
await db.insert(privilege).values({
name: realName,
clientId: client.id
});
}
static async update(client: OAuth2Client, body: Partial<OAuth2Client>) {
await db.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id));
}
static async authorizeClientInfo(client: OAuth2Client, scope: string[]) {
const links = await OAuth2Clients.getClientUrls(client);
const filteredLinks = links
@ -127,8 +303,6 @@ export class OAuth2Clients {
allowedScopes.push('management');
}
// TODO: client picture
return {
links: filteredLinks,
client_id: client.client_id,

View File

@ -1,16 +1,37 @@
import { eq } from 'drizzle-orm';
import { db, upload, user, type Upload, type User } from './drizzle';
import {
db,
oauth2Client,
upload,
user,
type OAuth2Client,
type Upload,
type User
} from './drizzle';
import { Users } from './users';
import { readFile, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import * as mime from 'mime-types';
import { OAuth2Clients } from './oauth2';
const fallbackImage = await readFile(join('static', 'avatar.png'));
const userFallbackImage = await readFile(join('static', 'avatar.png'));
const clientFallbackImage = await readFile(join('static', 'application.png'));
export class Uploads {
static fallbackImage = fallbackImage;
static userFallbackImage = userFallbackImage;
static clientFallbackImage = clientFallbackImage;
static uploads = join('uploads');
static async removeUpload(subject: Upload) {
try {
unlink(join(Uploads.uploads, subject.file));
} catch {
// ignore unlink error
}
await db.delete(upload).where(eq(upload.id, subject.id));
}
static async getAvatarByUuid(
uuid: string
): Promise<{ file: string; mimetype: string } | undefined> {
@ -26,14 +47,19 @@ export class Uploads {
return picture;
}
static async removeUpload(subject: Upload) {
try {
unlink(join(Uploads.uploads, subject.file));
} catch {
// ignore unlink error
static async getClientAvatarById(
id: string
): Promise<{ file: string; mimetype: string } | undefined> {
const client = await OAuth2Clients.fetchById(id);
if (!client?.pictureId) {
return undefined;
}
await db.delete(upload).where(eq(upload.id, subject.id));
const [picture] = await db
.select({ mimetype: upload.mimetype, file: upload.file })
.from(upload)
.where(eq(upload.id, client.pictureId));
return picture;
}
static async removeAvatar(subject: User) {
@ -47,6 +73,17 @@ export class Uploads {
await db.update(user).set({ pictureId: null }).where(eq(user.id, subject.id));
}
static async removeClientAvatar(client: OAuth2Client) {
if (!client.pictureId) return;
const [fileinfo] = await db.select().from(upload).where(eq(upload.id, client.pictureId));
if (fileinfo) {
await Uploads.removeUpload(fileinfo);
}
await db.update(oauth2Client).set({ pictureId: null }).where(eq(oauth2Client.id, client.id));
}
static async saveAvatar(subject: User, file: File) {
const ext = mime.extension(file.type);
const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`;
@ -64,4 +101,25 @@ export class Uploads {
});
await db.update(user).set({ pictureId: retval.insertId }).where(eq(user.id, subject.id));
}
static async saveClientAvatar(client: OAuth2Client, uploader: User, file: File) {
const ext = mime.extension(file.type);
const newName = `client-${client.client_id.substring(0, 8)}-${Math.floor(Date.now() / 1000)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
// Write to filesystem
await writeFile(join(Uploads.uploads, newName), Buffer.from(arrayBuffer));
// Remove old
await Uploads.removeClientAvatar(client);
// Update DB
const [retval] = await db.insert(upload).values({
original_name: file.name,
mimetype: file.type,
file: newName,
uploaderId: uploader.id
});
await db
.update(oauth2Client)
.set({ pictureId: retval.insertId })
.where(eq(oauth2Client.id, client.id));
}
}

View File

@ -221,14 +221,19 @@ export class Users {
.where(clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId));
}
static async getUserPrivileges(subject: User) {
static async getUserPrivileges(subject: User, clientId?: number) {
const list = await db
.select({
privilege: privilege.name
})
.from(privilege)
.innerJoin(userPrivilegesPrivilege, eq(privilege.id, userPrivilegesPrivilege.privilegeId))
.where(eq(userPrivilegesPrivilege.userId, subject.id));
.where(
and(
eq(userPrivilegesPrivilege.userId, subject.id),
clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId)
)
);
return list.reduce<string[]>(
(accum, { privilege }) => (!accum.includes(privilege) ? [...accum, privilege] : accum),

View File

@ -2,3 +2,4 @@ export const emailRegex =
/^[-!#$%&'*+\\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
export const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,}$/;
export const usernameRegex = /^[a-zA-Z0-9_\-.]{3,26}$/;
export const privilegeRegex = /^[a-zA-Z0-9_\-.:]{3,26}$/;

View File

@ -2,11 +2,10 @@ import { Uploads } from '$lib/server/upload.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params }) {
const uuid = params.slug;
export async function GET({ params: { uuid } }) {
const uploadFile = await Uploads.getAvatarByUuid(uuid);
if (!uploadFile) {
return new Response(Uploads.fallbackImage, {
return new Response(Uploads.userFallbackImage, {
status: 200,
headers: {
'Content-Type': 'image/png'

View File

@ -0,0 +1,23 @@
import { Uploads } from '$lib/server/upload.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params: { uuid } }) {
const uploadFile = await Uploads.getClientAvatarById(uuid);
if (!uploadFile) {
return new Response(Uploads.clientFallbackImage, {
status: 200,
headers: {
'Content-Type': 'image/png'
}
});
}
const readUpload = await readFile(join(Uploads.uploads, uploadFile.file));
return new Response(readUpload, {
status: 200,
headers: {
'Content-Type': uploadFile.mimetype
}
});
}

View File

@ -9,6 +9,7 @@ import { Users } from '$lib/server/users/index.js';
export const GET = async ({ request, url, locals }) => {
let user: User | undefined = undefined;
let clientId: number | undefined = undefined;
let tokenScopes: string[] | undefined = undefined;
if (locals.session?.data?.user) {
@ -21,6 +22,7 @@ export const GET = async ({ request, url, locals }) => {
if (token?.userId) {
tokenScopes = OAuth2Clients.splitScope(token.scope || '');
user = await Users.getById(token.userId);
clientId = token.clientId || undefined;
}
} catch (error) {
if (error instanceof OAuth2Error) {
@ -58,7 +60,9 @@ export const GET = async ({ request, url, locals }) => {
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
}
// TODO: privileges
if (scopelessAccess || tokenScopes?.includes('privileges')) {
userData.privileges = await Users.getUserPrivileges(user, clientId);
}
return ApiUtils.json(userData);
};

View File

@ -46,8 +46,7 @@
{/if}
<div class="graphic" aria-hidden="true"></div>
<div class="card">
<!-- TODO: client pictures -->
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
<AvatarCard src={`/api/avatar/client/${data.client.client_id}`} alt={data.client.title}>
<div class="card-inner">
<span class="card-display-name">{data.client.title}</span>
<span class="card-user-name">{data.client.description}</span>

View File

@ -0,0 +1,44 @@
import { AdminUtils } from '$lib/server/admin-utils';
import type { User } from '$lib/server/drizzle/schema.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { Users } from '$lib/server/users';
import { hasPrivileges } from '$lib/utils.js';
const PAGE_SIZE = 20;
export const load = async ({ parent, url }) => {
const { user } = await parent();
const currentUser = await Users.getBySession(user);
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
let limit = PAGE_SIZE;
let page = 1;
let filter: string | undefined = undefined;
if (url.searchParams.has('page')) {
page = Number(url.searchParams.get('page')) || 1;
}
if (url.searchParams.has('pageSize')) {
limit = Number(url.searchParams.get('pageSize')) || PAGE_SIZE;
}
if (url.searchParams.has('filter')) {
filter = url.searchParams.get('filter') as string;
}
const offset = (page - 1) * limit;
const fullPrivileges = hasPrivileges(user.privileges, ['admin:oauth2']);
const data = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
filter,
limit,
offset,
omitSecret: true,
listAll: fullPrivileges
});
return {
fullPrivileges,
...data
};
};

View File

@ -0,0 +1,33 @@
<script lang="ts">
import Paginator from '$lib/components/Paginator.svelte';
import { t } from '$lib/i18n';
import type { PageData } from './$types';
import ClientCard from '$lib/components/admin/AdminClientCard.svelte';
export let data: PageData;
</script>
<h1>{$t('admin.oauth2.title')}</h1>
<div class="client-list">
<Paginator meta={data.meta} />
{#each data.list as client}
<a href={`oauth2/${client.client_id}`} class="client-link">
<ClientCard {client} />
</a>
{/each}
<Paginator meta={data.meta} />
</div>
<style>
.client-link {
text-decoration: none;
display: flex;
}
.client-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@ -0,0 +1,402 @@
import { AdminUtils } from '$lib/server/admin-utils';
import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils.js';
import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2';
import { Uploads } from '$lib/server/upload.js';
import { Users } from '$lib/server/users';
import { UsersAdmin } from '$lib/server/users/admin';
import { hasPrivileges } from '$lib/utils';
import { privilegeRegex } from '$lib/validators.js';
import { error, fail, redirect } from '@sveltejs/kit';
interface AddUrlRequest {
type: OAuth2ClientURLType;
url: string;
}
interface UpdateRequest {
title: string;
description: string;
activated?: string;
verified?: string;
}
interface AddPrivilegeRequest {
name: string;
}
export const actions = {
update: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const body = await request.formData();
const { title, description, activated, verified } = Changesets.take<UpdateRequest>(
['title', 'description', 'activated', 'verified'],
body
);
if (!!verified && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] });
}
const actuallyVerified = fullPrivileges ? Number(!!verified) : undefined;
const actuallyActivated = Number(!!activated);
if (title && (title.length < 3 || title.length > 32)) {
return fail(403, { errors: ['invalidTitle'] });
}
if (description && description.length > 1000) {
return fail(403, { errors: ['invalidDescription'] });
}
await OAuth2Clients.update(details as OAuth2Client, {
title,
description,
verified: actuallyVerified,
activated: actuallyActivated
});
return { errors: [] };
},
delete: async ({ locals, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
if (details.activated === 1) {
return fail(400, { errors: ['deleteActivated'] });
}
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
if (details.ownerId !== currentUser.id && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] });
}
await OAuth2Clients.deleteClient(details as OAuth2Client);
return redirect(303, '/ssoadmin/oauth2');
},
regenerate: async ({ locals, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
if (!fullPrivileges && !details.isOwner) {
return fail(403, { errors: ['forbidden'] });
}
await OAuth2Clients.update(details as OAuth2Client, {
client_secret: CryptoUtils.generateSecret()
});
return { errors: [] };
},
removeUrl: async ({ locals, url, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const id = Number(url.searchParams.get('id'));
if (isNaN(id)) {
return fail(400, { errors: ['invalidUrlId'] });
}
await OAuth2Clients.deleteUrl(details as OAuth2Client, id);
return { errors: [] };
},
addUrl: async ({ locals, request, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const body = await request.formData();
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body);
if (!type || !OAuth2Clients.availableUrlTypes.includes(type)) {
return fail(400, { errors: ['invalidUrlType'] });
}
if (!url) {
return fail(400, { errors: ['invalidUrl'] });
}
await OAuth2Clients.addUrl(details as OAuth2Client, type, url);
return { errors: [] };
},
removePrivilege: async ({ locals, url, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const id = Number(url.searchParams.get('id'));
if (isNaN(id)) {
return fail(400, { errors: ['invalidPrivilegeId'] });
}
await OAuth2Clients.deletePrivilege(details as OAuth2Client, id);
return { errors: [] };
},
addPrivilege: async ({ locals, request, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const body = await request.formData();
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body);
if (!name || !privilegeRegex.test(name)) {
return fail(400, { errors: ['invalidPrivilege'] });
}
await OAuth2Clients.addPrivilege(details as OAuth2Client, name);
return { errors: [] };
},
grants: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const allowedGrants = fullPrivileges
? OAuth2Clients.availableGrantTypes
: OAuth2Clients.userSetGrants;
const body = await request.formData();
const values = Array.from(body.keys());
values.unshift('authorization_code');
const deduplicatedAllowedGrants = values.filter(
(value, index, array) => allowedGrants.includes(value) && array.indexOf(value) === index
);
await OAuth2Clients.update(details as OAuth2Client, {
grants: deduplicatedAllowedGrants.join(' ')
});
return { errors: [] };
},
scopes: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const allowedScopes = fullPrivileges
? OAuth2Clients.availableScopes
: OAuth2Clients.userSetScopes;
const body = await request.formData();
const values = Array.from(body.keys());
values.unshift('profile');
const deduplicatedAllowedScopes = values.filter(
(value, index, array) => allowedScopes.includes(value) && array.indexOf(value) === index
);
await OAuth2Clients.update(details as OAuth2Client, {
scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes)
});
return { errors: [] };
},
avatar: async ({ request, locals, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const formData = Object.fromEntries(await request.formData());
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
return fail(400, {
errors: ['noFile']
});
}
const { file } = formData as { file: File };
await Uploads.saveClientAvatar(details as OAuth2Client, currentUser, file);
return { errors: [] };
},
removeAvatar: async ({ locals, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
await Uploads.removeClientAvatar(details as OAuth2Client);
return { errors: [] };
}
};
export const load = async ({ params: { uuid }, parent }) => {
const { user } = await parent();
const currentUser = await Users.getBySession(user);
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
const fullPrivileges = hasPrivileges(user.privileges, ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const privileges = await Users.getAvailablePrivileges(details.id);
return {
availableUrls: OAuth2Clients.availableUrlTypes,
availablePrivileges: privileges,
availableGrants: fullPrivileges
? OAuth2Clients.availableGrantTypes
: OAuth2Clients.userSetGrants,
availableScopes: fullPrivileges ? OAuth2Clients.availableScopes : OAuth2Clients.userSetScopes,
fullPrivileges,
details
};
};

View File

@ -0,0 +1,333 @@
<script lang="ts">
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import SplitView from '$lib/components/container/SplitView.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
import { page } from '$app/stores';
import type { ActionData, PageData } from './$types';
import { writable } from 'svelte/store';
import FormErrors from '$lib/components/form/FormErrors.svelte';
export let data: PageData;
export let form: ActionData;
let showAvatarModal = writable(false);
let secret = false;
let addingUrl = false;
let addingPrivilege = false;
$: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri');
$: availableUrls = data.availableUrls.filter((type) => {
// Can have up to three redirect URIs, only one of other types
const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length;
if (type === 'redirect_uri') {
return countOfType < 3;
}
return !countOfType;
});
$: splitScopes = data.details.scope?.split(' ') || [];
$: splitGrants = data.details.grants?.split(' ') || [];
$: uuidPrefix = data.details.client_id.split('-')[0] + ':';
</script>
<h1>{$t('admin.oauth2.title')} / {data.details.title}</h1>
<ColumnView>
<SplitView>
<ColumnView>
<AvatarCard src={`/api/avatar/client/${data.details.client_id}?t=${data.renderrt}`}>
<ColumnView>
<div>
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
>{$t('admin.oauth2.avatar.change')}</Button
>
</div>
{#if data.details.pictureId}
<form action="?/removeAvatar" method="POST">
<Button type="submit" variant="link">{$t('admin.oauth2.avatar.remove')}</Button>
</form>
{/if}
</ColumnView>
</AvatarCard>
<form action="?/update" method="POST">
<FormWrapper>
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<FormSection>
<FormControl>
<label for="client-title">{$t('admin.oauth2.clientTitle')}</label>
<input name="title" id="client-title" value={data.details.title} />
</FormControl>
<FormControl>
<label for="client-id">{$t('admin.oauth2.clientId')}</label>
<input readonly id="client-id" value={data.details.client_id} />
</FormControl>
<FormControl>
<label for="client-secret">{$t('admin.oauth2.clientSecret')}</label>
{#if secret}
<input readonly id="client-secret" value={data.details.client_secret} />
{:else}
<Button on:click={() => (secret = true)}>{$t('admin.oauth2.reveal')}</Button>
{/if}
</FormControl>
<FormControl>
<label for="client-description">{$t('admin.oauth2.description')}</label>
<textarea
name="description"
id="client-description"
value={data.details.description}
rows="3"
/>
</FormControl>
<FormControl>
<label for="client-activated">{$t('admin.oauth2.activated')}</label>
<input
type="checkbox"
name="activated"
id="client-activated"
checked={Boolean(data.details.activated)}
/>
</FormControl>
{#if data.fullPrivileges}
<FormControl>
<label for="client-verified">{$t('admin.oauth2.verified')}</label>
<input
type="checkbox"
name="verified"
id="client-verified"
checked={Boolean(data.details.verified)}
/>
</FormControl>
{/if}
</FormSection>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</FormWrapper>
</form>
</ColumnView>
<ColumnView>
<h2>{$t('admin.oauth2.actions')}</h2>
{#if data.fullPrivileges || data.details.isOwner}
{#if !data.details.activated}
<form action="?/delete" method="POST">
<Button type="submit" variant="link">{$t('admin.oauth2.delete')}</Button>
</form>
{:else}
<form action="?/regenerate" method="POST">
<Button type="submit" variant="link">{$t('admin.oauth2.regenerate')}</Button>
</form>
{/if}
{/if}
<h2>{$t('admin.oauth2.urls.title')}</h2>
{#if noRedirects}
<Alert type="error">
{$t('admin.oauth2.errors.noRedirect')}
</Alert>
{/if}
<div class="urls addremove">
{#each data.details.urls as url}
<div class="url addremove-item">
<div>
<div class="url-type">{$t(`admin.oauth2.urls.types.${url.type}`)}</div>
<a class="url-url" href={url.url} target="_blank" rel="nofollow noreferrer"
>{url.url}</a
>
</div>
<form action="?/removeUrl&id={url.id}" method="POST">
<Button type="submit" variant="link">{$t('common.remove')}</Button>
</form>
</div>
{/each}
</div>
{#if !addingUrl && availableUrls.length}
<div>
<Button variant="link" on:click={() => (addingUrl = true)}
>+ {$t('admin.oauth2.urls.add')}</Button
>
</div>
{:else if addingUrl}
<form action="?/addUrl" method="POST">
<FormWrapper>
<FormSection title={$t('admin.oauth2.urls.new')}>
<FormControl>
<label for="newurl-type">{$t('admin.oauth2.urls.type')}</label>
<select name="type" id="newurl-type">
{#each availableUrls as urlType}
<option value={urlType}>{$t(`admin.oauth2.urls.types.${urlType}`)}</option>
{/each}
</select>
</FormControl>
<FormControl>
<label for="newurl-url">{$t('admin.oauth2.urls.url')}</label>
<input name="url" type="url" id="newurl-url" />
</FormControl>
</FormSection>
<ButtonRow>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
<Button variant="link" on:click={() => (addingUrl = false)}
>{$t('common.cancel')}</Button
>
</ButtonRow>
</FormWrapper>
</form>
{/if}
<h2>{$t('admin.oauth2.privileges.title')}</h2>
<p>{$t('admin.oauth2.privileges.addHint')}</p>
<div class="privileges addremove">
{#each data.availablePrivileges as privilege}
{@const [idPart, ...rest] = privilege.name.split(':')}
<div class="privilege addremove-item">
<span class="privilege-name"
><span class="privilege-id">{idPart}:</span>{rest.join(':')}</span
>
<form action="?/removePrivilege&id={privilege.id}" method="POST">
<Button type="submit" variant="link">{$t('common.remove')}</Button>
</form>
</div>
{/each}
</div>
{#if !addingPrivilege}
<div>
<Button variant="link" on:click={() => (addingPrivilege = true)}
>+ {$t('admin.oauth2.privileges.add')}</Button
>
</div>
{:else}
<form action="?/addPrivilege" method="POST">
<FormWrapper>
<FormSection title={$t('admin.oauth2.privileges.new')}>
<FormControl>
<label for="newpriv-name">{$t('admin.oauth2.privileges.name')}</label>
<input name="name" id="newpriv-name" />
<span>{$t('admin.oauth2.privileges.nameHint', { prefix: uuidPrefix })}</span>
</FormControl>
</FormSection>
<ButtonRow>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
<Button variant="link" on:click={() => (addingPrivilege = false)}
>{$t('common.cancel')}</Button
>
</ButtonRow>
</FormWrapper>
</form>
{/if}
</ColumnView>
</SplitView>
<h2>{$t('admin.oauth2.grants')}</h2>
<p>{$t('admin.oauth2.grantsHint')}</p>
<form action="?/grants" method="POST">
<div class="scope-cloud">
{#each data.availableGrants as grant}
<FormControl>
<input
type="checkbox"
id={`grant-${grant}`}
name={grant}
disabled={grant === 'authorization_code'}
checked={grant === 'authorization_code' ? true : splitGrants.includes(grant)}
/>
<label for={`grant-${grant}`}
><code>{grant}</code> - {$t(`admin.oauth2.grantTexts.${grant}`)}</label
>
</FormControl>
{/each}
</div>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</form>
<h2>{$t('admin.oauth2.scopes')}</h2>
<p>{$t('admin.oauth2.scopesHint')}</p>
<form action="?/scopes" method="POST">
<div class="scope-cloud">
{#each data.availableScopes as scope}
<FormControl>
<input
type="checkbox"
id={`scope-${scope}`}
name={scope}
disabled={scope === 'profile'}
checked={scope === 'profile' ? true : splitScopes.includes(scope)}
/>
<label for={`scope-${scope}`}
><code>{scope}</code> - {$t(`admin.oauth2.scopeTexts.${scope}`)}</label
>
</FormControl>
{/each}
</div>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</form>
<h2>{$t('admin.oauth2.authorizations')}</h2>
<p>{$t('admin.oauth2.authorizationsHint')}</p>
<b>{$t('admin.oauth2.noAuthorizations')}</b>
</ColumnView>
<AvatarModal show={showAvatarModal} url={$page.url.pathname} />
<style>
h2,
p {
margin: 0;
}
.addremove {
display: flex;
flex-direction: column;
gap: 4px;
& .addremove-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
padding: 8px;
border-radius: 4px;
}
& .privilege-id {
color: #646464;
}
& .url-type {
font-weight: bold;
}
& .url-url {
overflow-wrap: break-word;
word-break: break-word;
}
}
.scope-cloud {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
margin-bottom: 1rem;
}
</style>

View File

@ -18,7 +18,7 @@ interface UpdateRequest {
export const actions = {
removeOtp: async () => {},
removeAvatar: async ({ locals, params: { slug: uuid } }) => {
removeAvatar: async ({ locals, params: { uuid } }) => {
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
const targetUser = await Users.getByUuid(uuid, false);
@ -30,7 +30,7 @@ export const actions = {
return { errors: [] };
},
deleteInfo: async ({ locals, params: { slug: uuid } }) => {
deleteInfo: async ({ locals, params: { uuid } }) => {
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
const targetUser = await Users.getByUuid(uuid, false);
@ -56,7 +56,7 @@ export const actions = {
return { errors: [] };
},
email: async ({ locals, params: { slug: uuid }, url }) => {
email: async ({ locals, params: { uuid }, url }) => {
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
const type = url.searchParams.get('type') as 'password' | 'activate';
@ -84,7 +84,7 @@ export const actions = {
return { errors: [] };
},
update: async ({ locals, params: { slug: uuid }, request }) => {
update: async ({ locals, params: { uuid }, request }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
'admin',
'admin:user'
@ -136,11 +136,10 @@ export const actions = {
}
};
export const load = async ({ parent, params }) => {
export const load = async ({ parent, params: { uuid } }) => {
const { user } = await parent();
AdminUtils.checkPrivileges(user, ['admin:user']);
const uuid = params.slug;
const userInfo = await UsersAdmin.getUserDetails(uuid);
if (!userInfo) {
error(404, 'User not found');