feat: add ability to deselect scopes on authorization

This commit is contained in:
Elias Schneider
2026-10-15 22:18:21 +02:00
parent 6b6ef9ad00
commit f81d62c16c
15 changed files with 479 additions and 31 deletions
+33 -3
View File
@@ -1,19 +1,23 @@
<script lang="ts">
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
import * as Item from '#lib/components/ui/item/index.js';
import { cn } from '#lib/utils/style.js';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
icon: LucideIcon;
name: string;
description: string;
selected?: boolean;
onSelectedChange?: (selected: boolean) => void;
}
let { icon, name, description }: Props = $props();
let { icon, name, description, selected = true, onSelectedChange }: Props = $props();
const SvelteComponent = $derived(icon);
</script>
<Item.Root class="px-0 py-1.5">
{#snippet content()}
<Item.Media class="bg-muted h-full !translate-y-0 !self-center rounded-lg p-2">
<SvelteComponent class="size-5" />
</Item.Media>
@@ -21,4 +25,30 @@
<Item.Title class="font-semibold">{name}</Item.Title>
<Item.Description>{description}</Item.Description>
</Item.Content>
</Item.Root>
{/snippet}
{#if onSelectedChange}
<!-- The whole row is a label so clicking anywhere on it toggles the scope -->
<Item.Root class="px-0 py-1.5">
{#snippet child({ props })}
<label
{...props}
class={cn(props.class as string, 'cursor-pointer', !selected && 'opacity-60')}
data-testid="scope-item"
>
{@render content()}
<Item.Actions>
<Checkbox
aria-label={name}
checked={selected}
onCheckedChange={(checked: boolean) => onSelectedChange(checked)}
/>
</Item.Actions>
</label>
{/snippet}
</Item.Root>
{:else}
<Item.Root class="px-0 py-1.5">
{@render content()}
</Item.Root>
{/if}
+31 -3
View File
@@ -7,23 +7,49 @@
let {
scopes,
scopeInfo = []
}: { scopes?: string[] | null; scopeInfo?: InteractionScopeInfo[] | null } = $props();
scopeInfo = [],
selectedScopes = $bindable()
}: {
scopes?: string[] | null;
scopeInfo?: InteractionScopeInfo[] | null;
// When bound, every displayed scope gets a checkbox so the user can deselect it
selectedScopes?: string[];
} = $props();
const standardScopes = ['openid', 'profile', 'email', 'groups', 'offline_access'];
const infoByKey = $derived(new Map((scopeInfo || []).map((info) => [info.key, info])));
const customScopes = $derived((scopes || []).filter((scope) => !standardScopes.includes(scope)));
function selectionProps(scope: string) {
if (!selectedScopes) {
return {};
}
return {
selected: selectedScopes.includes(scope),
onSelectedChange: (selected: boolean) => {
const others = (selectedScopes ?? []).filter((s) => s !== scope);
selectedScopes = selected ? [...others, scope] : others;
}
};
}
</script>
<Item.Group data-testid="scopes" class="gap-1">
{#if (scopes || []).includes('email')}
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
<ScopeItem
icon={LucideMail}
name={m.email()}
description={m.view_your_email_address()}
{...selectionProps('email')}
/>
{/if}
{#if (scopes || []).includes('profile')}
<ScopeItem
icon={LucideUser}
name={m.profile()}
description={m.view_your_profile_information()}
{...selectionProps('profile')}
/>
{/if}
{#if (scopes || []).includes('groups')}
@@ -31,6 +57,7 @@
icon={LucideUsers}
name={m.groups()}
description={m.view_the_groups_you_are_a_member_of()}
{...selectionProps('groups')}
/>
{/if}
{#each customScopes as scope (scope)}
@@ -38,6 +65,7 @@
icon={LucideKeyRound}
name={infoByKey.get(scope)?.name ?? scope}
description={infoByKey.get(scope)?.description || m.access_an_api_on_your_behalf()}
{...selectionProps(scope)}
/>
{/each}
</Item.Group>
+8 -4
View File
@@ -27,10 +27,14 @@ class OidcService extends APIService {
return data;
};
completeAuthorizeInteractionStep = async (id: string, step: InteractionStep) => {
completeAuthorizeInteractionStep = async (
id: string,
step: InteractionStep,
scopes?: string[]
) => {
const { data } = await this.api.post<CompleteInteractionResponse>(
`/oidc/interactions/${id}/complete`,
{ step }
{ step, scopes }
);
return data;
};
@@ -116,8 +120,8 @@ class OidcService extends APIService {
return res.data as OidcClientWithAllowedUserGroups;
};
verifyDeviceCode = async (userCode: string) => {
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
verifyDeviceCode = async (userCode: string, scopes?: string[]) => {
return await this.api.post(`/oidc/device/verify?code=${userCode}`, { scopes });
};
getDeviceCodeInfo = async (userCode: string): Promise<OidcDeviceCodeInfo> => {
+12 -2
View File
@@ -37,6 +37,7 @@
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let selectedScopes: string[] = $state([]);
let reauthenticationRequired = $state(false);
let reauthenticated = $state(false);
let normalizedUserCode = $derived(
@@ -79,6 +80,8 @@
if (info.authorizationRequired && !authorizationRequired) {
authorizationRequired = true;
// Every requested scope starts selected so the user only has to deselect the ones they do not want to grant
selectedScopes = [...(info.scope ?? [])];
return;
}
@@ -92,7 +95,10 @@
reauthenticated = true;
}
await oidcService.verifyDeviceCode(normalizedUserCode);
await oidcService.verifyDeviceCode(
normalizedUserCode,
authorizationRequired ? selectedScopes : undefined
);
success = true;
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
@@ -236,7 +242,11 @@
</Card.Description>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scopes={deviceInfo!.scope || []} scopeInfo={deviceInfo!.scopeInfo || []} />
<ScopeList
scopes={deviceInfo!.scope || []}
scopeInfo={deviceInfo!.scopeInfo || []}
bind:selectedScopes
/>
</Card.Content>
</Card.Root>
</div>
+8 -1
View File
@@ -30,6 +30,8 @@
let errorMessage: string | null = $state(null);
let errorCode: string | undefined = $state();
let currentStep = $derived(interactionSession.currentStep);
// Every requested scope starts selected so the user only has to deselect the ones they do not want to grant
let selectedScopes = $state([...(data.interactionSession.scopes ?? [])]);
const fullName = $derived.by(() => {
if (!$userStore) {
@@ -93,7 +95,11 @@
}
async function completeInteraction(step: InteractionStep, skipRedirect = false) {
const result = await oidcService.completeAuthorizeInteractionStep(interactionSession.id, step);
const result = await oidcService.completeAuthorizeInteractionStep(
interactionSession.id,
step,
step === 'consent' ? selectedScopes : undefined
);
if (result.interaction) {
interactionSession = result.interaction;
if (interactionSession.currentStep == 'reauthenticate') {
@@ -193,6 +199,7 @@
<ScopeList
scopes={interactionSession.scopes || []}
scopeInfo={interactionSession.scopeInfo ?? []}
bind:selectedScopes
/>
</Card.Content>
</Card.Root>