feat: map allowed groups to OIDC clients (#202)

This commit is contained in:
Elias Schneider
2025-02-03 18:41:15 +01:00
committed by GitHub
parent 430421e98b
commit 13b02a072f
30 changed files with 518 additions and 218 deletions

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from 'lucide-svelte';
import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
let {
id,
title,
description,
defaultExpanded = false,
children
}: {
id: string;
title: string;
description?: string;
defaultExpanded?: boolean;
children: Snippet;
} = $props();
let expanded = $state(defaultExpanded);
function loadExpandedState() {
const state = JSON.parse(localStorage.getItem('collapsible-cards-expanded') || '{}');
expanded = state[id] || false;
}
function saveExpandedState() {
const state = JSON.parse(localStorage.getItem('collapsible-cards-expanded') || '{}');
state[id] = expanded;
localStorage.setItem('collapsible-cards-expanded', JSON.stringify(state));
}
function toggleExpanded() {
expanded = !expanded;
saveExpandedState();
}
onMount(() => {
if (defaultExpanded) {
saveExpandedState();
}
loadExpandedState();
});
</script>
<Card.Root>
<Card.Header class="cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>
<Card.Title>{title}</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label="Expand card">
<LucideChevronDown
class={cn(
'h-5 w-5 transition-transform duration-200',
expanded && 'rotate-180 transform'
)}
/>
</Button>
</div>
</Card.Header>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<Card.Content>
{@render children()}
</Card.Content>
</div>
{/if}
</Card.Root>

View File

@@ -8,6 +8,6 @@
export { className as class };
</script>
<p class={cn('text-sm text-muted-foreground', className)} {...$$restProps}>
<p class={cn('text-sm text-muted-foreground mt-1', className)} {...$$restProps}>
<slot />
</p>

View File

@@ -1,4 +1,9 @@
import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
import type {
AuthorizeResponse,
OidcClient,
OidcClientCreate,
OidcClientWithAllowedUserGroups
} from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
@@ -23,24 +28,13 @@ class OidcService extends APIService {
return res.data as AuthorizeResponse;
}
async authorizeNewClient(
clientId: string,
scope: string,
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string
) {
const res = await this.api.post('/oidc/authorize/new-client', {
async isAuthorizationRequired(clientId: string, scope: string) {
const res = await this.api.post('/oidc/authorization-required', {
scope,
nonce,
callbackURL,
clientId,
codeChallenge,
codeChallengeMethod
clientId
});
return res.data as AuthorizeResponse;
return res.data.authorizationRequired as boolean;
}
async listClients(options?: SearchPaginationSortRequest) {
@@ -59,7 +53,7 @@ class OidcService extends APIService {
}
async getClient(id: string) {
return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClient;
return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
}
async updateClient(id: string, client: OidcClientCreate) {
@@ -88,6 +82,11 @@ class OidcService extends APIService {
async createClientSecret(id: string) {
return (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string;
}
async updateAllowedUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
return res.data as OidcClientWithAllowedUserGroups;
}
}
export default OidcService;

View File

@@ -1,3 +1,5 @@
import type { UserGroup } from './user-group.type';
export type OidcClient = {
id: string;
name: string;
@@ -8,6 +10,10 @@ export type OidcClient = {
pkceEnabled: boolean;
};
export type OidcClientWithAllowedUserGroups = OidcClient & {
allowedUserGroups: UserGroup[];
};
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & {

View File

@@ -23,6 +23,7 @@
let success = false;
let errorMessage: string | null = null;
let authorizationRequired = false;
let authorizationConfirmed = false;
export let data: PageData;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
@@ -40,7 +41,17 @@
if (!$userStore?.id) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication(loginOptions);
await webauthnService.finishLogin(authResponse);
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
}
if (!authorizationConfirmed) {
authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope);
if (authorizationRequired) {
isLoading = false;
authorizationConfirmed = true;
return;
}
}
await oidService
@@ -49,7 +60,7 @@
onSuccess(code, callbackURL);
});
} catch (e) {
if (e instanceof AxiosError && e.response?.status === 403) {
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') {
authorizationRequired = true;
} else {
errorMessage = getWebauthnErrorMessage(e);
@@ -58,27 +69,6 @@
}
}
async function authorizeNewClient() {
isLoading = true;
try {
await oidService
.authorizeNewClient(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod
)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
isLoading = false;
}
}
function onSuccess(code: string, callbackURL: string) {
success = true;
setTimeout(() => {
@@ -100,14 +90,14 @@
{:else}
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage}
<p class="mb-10 mt-2 text-muted-foreground">
{errorMessage}. Please try again.
<p class="text-muted-foreground mb-10 mt-2">
{errorMessage}.
</p>
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="mb-10 mt-2 text-muted-foreground">
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$appConfigStore.appName}</b> account?
</p>
@@ -115,7 +105,7 @@
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5">
<p class="text-start text-muted-foreground">
<p class="text-muted-foreground text-start">
<b>{client.name}</b> wants to access the following information:
</p>
</Card.Header>
@@ -146,13 +136,7 @@
<div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
{#if !errorMessage}
<Button
class="w-full"
{isLoading}
on:click={authorizationRequired ? authorizeNewClient : authorize}
>
Sign in
</Button>
<Button class="w-full" {isLoading} on:click={authorize}>Sign in</Button>
{:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button>
{/if}

View File

@@ -30,7 +30,7 @@
<div class="flex justify-center gap-3">
<div
class=" rounded-2xl bg-muted p-3 transition-transform duration-500 ease-in {success || error
class=" bg-muted transition-translate rounded-2xl p-3 duration-500 ease-in {success || error
? 'translate-x-[108px]'
: ''}"
>
@@ -38,10 +38,12 @@
</div>
<ConnectArrow
class="arrow-fade-out h-w-32 w-32 {success || error ? 'opacity-0' : 'opacity-100'}"
class="h-w-32 w-32 transition-opacity duration-500 {success || error
? 'opacity-0'
: 'opacity-100 delay-300'}"
/>
<div
class="rounded-2xl p-3 [transition:transform_500ms_ease-in,background-color_200ms] {success ||
class="rounded-2xl p-3 [transition:translate_500ms_ease-in,background-color_200ms] {success ||
error
? '-translate-x-[108px]'
: ''} {animationDone ? (success ? 'bg-green-200' : 'bg-red-200') : 'bg-muted'}"

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
@@ -55,45 +55,27 @@
<title>Application Configuration</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
</Card.Header>
<Card.Content>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<Card.Root>
<Card.Header>
<Card.Title>Email</Card.Title>
<Card.Description>
Enable email notifications to alert users when a login is detected from a new device or
location.
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<CollapsibleCard
id="application-configuration-email"
title="Email"
description="Enable email notifications to alert users when a login is detected from a new device or
location."
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<Card.Root>
<Card.Header>
<Card.Title>LDAP</Card.Title>
<Card.Description>
Configure LDAP settings to sync users and groups from an LDAP server.
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<CollapsibleCard
id="application-configuration-ldap"
title="LDAP"
description="Configure LDAP settings to sync users and groups from an LDAP server."
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<Card.Root>
<Card.Header>
<Card.Title>Images</Card.Title>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>
<CollapsibleCard id="application-configuration-images" title="Images">
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte';
import OidcService from '$lib/services/oidc-service';
import UserGroupService from '$lib/services/user-group-service';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -14,12 +16,17 @@
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import UserGroupSelection from '../user-group-selection.svelte';
let { data } = $props();
let client = $state(data);
let client = $state({
...data,
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
});
let showAllDetails = $state(false);
const oidcService = new OidcService();
const userGroupService = new UserGroupService();
const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`,
@@ -74,6 +81,17 @@
});
}
async function updateUserGroupClients(allowedGroups: string[]) {
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success('Allowed user groups updated successfully');
})
.catch((e) => {
axiosErrorToast(e);
});
}
beforeNavigate(() => {
clientSecretStore.clear();
});
@@ -84,7 +102,7 @@
</svelte:head>
<div>
<a class="flex text-sm text-muted-foreground" href="/settings/admin/oidc-clients"
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
</div>
@@ -97,7 +115,7 @@
<div class="mb-2 flex">
<Label class="mb-0 w-44">Client ID</Label>
<CopyToClipboard value={client.id}>
<span class="text-sm text-muted-foreground" data-testid="client-id"> {client.id}</span>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
@@ -105,12 +123,12 @@
<Label class="w-44">Client secret</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-sm text-muted-foreground" data-testid="client-secret">
<span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore}
</span>
</CopyToClipboard>
{:else}
<span class="text-sm text-muted-foreground" data-testid="client-secret"
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button
@@ -129,7 +147,7 @@
<div class="mb-5 flex">
<Label class="mb-0 w-44">{key}</Label>
<CopyToClipboard {value}>
<span class="text-sm text-muted-foreground">{value}</span>
<span class="text-muted-foreground text-sm">{value}</span>
</CopyToClipboard>
</div>
{/each}
@@ -151,3 +169,15 @@
<OidcForm existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>
<CollapsibleCard
id="allowed-user-groups"
title="Allowed User Groups"
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client."
>
{#await userGroupService.list() then groups}
<UserGroupSelection {groups} bind:selectedGroupIds={client.allowedUserGroupIds} />
{/await}
<div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
</div>
</CollapsibleCard>

View File

@@ -76,7 +76,7 @@
</script>
<form onsubmit={onSubmit}>
<div class="grid grid-cols-2 gap-3 sm:flex-row">
<div class="grid grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<OidcCallbackUrlInput
class="w-full"

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
let {
groups: initialGroups,
selectionDisabled = false,
selectedGroupIds = $bindable()
}: {
groups: Paginated<UserGroup>;
selectionDisabled?: boolean;
selectedGroupIds: string[];
} = $props();
const userGroupService = new UserGroupService();
let groups = $state(initialGroups);
</script>
<AdvancedTable
items={groups}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'name' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>
{#snippet rows({ item })}
<Table.Cell>{item.name}</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -61,7 +62,7 @@
</svelte:head>
<div class="flex items-center justify-between">
<a class="flex text-sm text-muted-foreground" href="/settings/admin/user-groups"
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
{#if !!userGroup.ldapId}
@@ -100,19 +101,13 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Custom Claims</Card.Title>
<Card.Description>
Custom claims are key-value pairs that can be used to store additional information about a
user. These claims will be included in the ID token if the scope "profile" is requested.
Custom claims defined on the user will be prioritized if there are conflicts.
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
</div>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-group-custom-claims"
title="Custom Claims"
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts."
>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
</div>
</CollapsibleCard>

View File

@@ -32,10 +32,10 @@
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list(requestOptions!);
toast.success('User group deleted successfully');
} catch (e) {
axiosErrorToast(e);
}
toast.success('User group deleted successfully');
}
}
}
});

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -45,7 +46,7 @@
</svelte:head>
<div class="flex items-center justify-between">
<a class="flex text-sm text-muted-foreground" href="/settings/admin/users"
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
{#if !!user.ldapId}
@@ -61,18 +62,13 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Custom Claims</Card.Title>
<Card.Description>
Custom claims are key-value pairs that can be used to store additional information about a
user. These claims will be included in the ID token if the scope "profile" is requested.
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
</div>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-custom-claims"
title="Custom Claims"
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested."
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
</div>
</CollapsibleCard>

View File

@@ -40,7 +40,7 @@ test('Update account details fails with already taken username', async ({ page }
test('Add passkey to an account', async ({ page }) => {
await page.goto('/settings/account');
await (await passkeyUtil.init(page)).addPasskey('new');
await (await passkeyUtil.init(page)).addPasskey('timNew');
await page.click('button:text("Add Passkey")');

View File

@@ -24,6 +24,8 @@ test('Update general configuration', async ({ page }) => {
test('Update email configuration', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
await page.getByLabel('SMTP Port').fill('587');
await page.getByLabel('SMTP User').fill('test@gmail.com');
@@ -47,14 +49,53 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('Email One Time Access')).toBeChecked();
});
test('Update LDAP configuration', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
await page.getByLabel('LDAP URL').fill('ldap://localhost:389');
await page.getByLabel('LDAP Bind DN').fill('cn=admin,dc=example,dc=com');
await page.getByLabel('LDAP Bind Password').fill('password');
await page.getByLabel('LDAP Base DN').fill('dc=example,dc=com');
await page.getByLabel('User Unique Identifier Attribute').fill('uuid');
await page.getByLabel('Username Attribute').fill('uid');
await page.getByLabel('User Mail Attribute').fill('mail');
await page.getByLabel('User First Name Attribute').fill('givenName');
await page.getByLabel('User Last Name Attribute').fill('sn');
await page.getByLabel('Group Unique Identifier Attribute').fill('uuid');
await page.getByLabel('Group Name Attribute').fill('cn');
await page.getByLabel('Admin Group Name').fill('admin');
await page.getByRole('button', { name: 'Enable' }).click();
await expect(page.getByRole('status')).toHaveText('LDAP configuration updated successfully');
await page.reload();
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await expect(page.getByLabel('LDAP URL')).toHaveValue('ldap://localhost:389');
await expect(page.getByLabel('LDAP Bind DN')).toHaveValue('cn=admin,dc=example,dc=com');
await expect(page.getByLabel('LDAP Bind Password')).toHaveValue('password');
await expect(page.getByLabel('LDAP Base DN')).toHaveValue('dc=example,dc=com');
await expect(page.getByLabel('User Unique Identifier Attribute')).toHaveValue('uuid');
await expect(page.getByLabel('Username Attribute')).toHaveValue('uid');
await expect(page.getByLabel('User Mail Attribute')).toHaveValue('mail');
await expect(page.getByLabel('User First Name Attribute')).toHaveValue('givenName');
await expect(page.getByLabel('User Last Name Attribute')).toHaveValue('sn');
await expect(page.getByLabel('Admin Group Name')).toHaveValue('admin');
});
test('Update application images', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(3).click();
await page.getByLabel('Favicon').setInputFiles('tests/assets/w3-schools-favicon.ico');
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Images updated successfully');

View File

@@ -75,6 +75,24 @@ test('Authorize new client while not signed in', async ({ page }) => {
});
});
test('Authorize new client fails with user group not allowed', async ({ page }) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('paragraph').first()).toHaveText("You're not allowed to access this service.");
});
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
return new URLSearchParams({
client_id: oidcClient.id,

View File

@@ -77,6 +77,8 @@ test('Delete user group', async ({ page }) => {
test('Update user group custom claims', async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
await page.getByRole('button', { name: 'Expand card' }).click();
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();

View File

@@ -142,6 +142,8 @@ test('Update user fails with already taken username', async ({ page }) => {
test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('button', { name: 'Expand card' }).click();
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();

View File

@@ -2,18 +2,21 @@ import type { CDPSession, Page } from '@playwright/test';
// The existing passkeys are already stored in the database
const passkeys = {
existing1: {
credentialId: 'test-credential-1',
tim: {
credentialId: 'test-credential-tim',
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG'
},
existing2: {
credentialId: 'test-credential-2',
craig: {
credentialId: 'test-credential-craig',
userHandle: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG'
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL1UaeWG1KYpN+HcxQvXEJysiQjT9Fn7Zif3i5cY+s+yhRANCAASPioDQ+tnODwKjULbufJRvOunwTCOvt46UYjYt+vOZsvmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouH'
},
new: {
credentialId: 'new-test-credential',
timNew: {
credentialId: 'new-test-credential-tim',
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFl2lIlRyc2G7O9D8WWrw2N8D7NTlhgWcKFY7jYxrfcmhRANCAASmvbCFrXshUvW7avTIysV9UymbhmUwGb7AonUMQPgqK2Jur7PWp9V0AIe5YMuXYH1oxsqY5CoAbdY2YsPmhYoX'
}
@@ -48,9 +51,9 @@ async function addVirtualAuthenticator(client: CDPSession): Promise<string> {
async function addPasskey(
authenticatorId: string,
client: CDPSession,
passkeyName?: keyof typeof passkeys
passkeyName: keyof typeof passkeys = 'tim'
): Promise<void> {
const passkey = passkeys[passkeyName ?? 'existing1'];
const passkey = passkeys[passkeyName];
await client.send('WebAuthn.addCredential', {
authenticatorId,
credential: {
@@ -58,9 +61,8 @@ async function addPasskey(
isResidentCredential: true,
rpId: 'localhost',
privateKey: passkey.privateKey,
userHandle: btoa('f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e'),
userHandle: btoa(passkey.userHandle),
signCount: Math.round((new Date().getTime() - 1704444610871) / 1000 / 2)
// signCount: 2,
}
});
}