mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-23 22:39:24 +00:00
Merge branch 'main' of https://github.com/pocket-id/pocket-id
This commit is contained in:
@@ -12,7 +12,11 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
if (event.url.pathname.startsWith('/settings') && !event.url.pathname.startsWith('/login')) {
|
||||
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login');
|
||||
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
|
||||
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
||||
|
||||
if (!isUnauthenticatedOnlyPath && !isPublicPath) {
|
||||
if (!isSignedIn) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
@@ -21,14 +25,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith('/login') && isSignedIn) {
|
||||
if (isUnauthenticatedOnlyPath && isSignedIn) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: '/settings' }
|
||||
});
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith('/settings/admin') && !isAdmin) {
|
||||
if (isAdminPath && !isAdmin) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: '/settings' }
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
import Logo from '../logo.svelte';
|
||||
import HeaderAvatar from './header-avatar.svelte';
|
||||
|
||||
let isAuthPage = $derived(
|
||||
!$page.error &&
|
||||
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
|
||||
);
|
||||
const authUrls = ['/authorize', '/login', '/logout'];
|
||||
let isAuthPage = $derived(!$page.error && authUrls.includes($page.url.pathname));
|
||||
|
||||
</script>
|
||||
|
||||
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OidcClient = {
|
||||
name: string;
|
||||
logoURL: string;
|
||||
callbackURLs: [string, ...string[]];
|
||||
logoutCallbackURLs: string[];
|
||||
hasLogo: boolean;
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { AxiosError } from 'axios';
|
||||
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
@@ -60,11 +59,7 @@
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') {
|
||||
authorizationRequired = true;
|
||||
} else {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
}
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
43
frontend/src/routes/logout/+page.svelte
Normal file
43
frontend/src/routes/logout/+page.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import Logo from '$lib/components/logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import userStore from '$lib/stores/user-store.js';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util.js';
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
async function signOut() {
|
||||
isLoading = true;
|
||||
await webauthnService
|
||||
.logout()
|
||||
.then(() => goto('/'))
|
||||
.catch(axiosErrorToast);
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Logout</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-muted rounded-2xl p-3">
|
||||
<Logo class="h-10 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1>
|
||||
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>?
|
||||
</p>
|
||||
<div class="mt-10 flex w-full justify-stretch gap-2">
|
||||
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button>
|
||||
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
@@ -1,7 +1,6 @@
|
||||
<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';
|
||||
@@ -17,6 +16,7 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
import UserGroupSelection from '../user-group-selection.svelte';
|
||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let client = $state({
|
||||
@@ -33,6 +33,7 @@
|
||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
|
||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
|
||||
});
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
label,
|
||||
callbackURLs = $bindable(),
|
||||
error = $bindable(null),
|
||||
allowEmpty = false,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
label: string;
|
||||
callbackURLs: string[];
|
||||
error?: string | null;
|
||||
allowEmpty?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
@@ -20,12 +24,12 @@
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput label="Callback URLs">
|
||||
<FormInput {label}>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each callbackURLs as _, i}
|
||||
<div class="flex gap-x-2">
|
||||
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
||||
{#if callbackURLs.length > 1}
|
||||
{#if callbackURLs.length > 1 || allowEmpty}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -49,7 +53,7 @@
|
||||
on:click={() => (callbackURLs = [...callbackURLs, ''])}
|
||||
>
|
||||
<LucidePlus class="mr-1 h-4 w-4" />
|
||||
Add another
|
||||
{callbackURLs.length === 0 ? 'Add' : 'Add another'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
OidcClientCreateWithLogo
|
||||
} from '$lib/types/oidc.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { set, z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||
|
||||
let {
|
||||
@@ -30,6 +30,7 @@
|
||||
const client: OidcClientCreate = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURLs: existingClient?.callbackURLs || [''],
|
||||
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||
isPublic: existingClient?.isPublic || false,
|
||||
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
|
||||
};
|
||||
@@ -37,6 +38,7 @@
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
callbackURLs: z.array(z.string()).nonempty(),
|
||||
logoutCallbackURLs: z.array(z.string()),
|
||||
isPublic: z.boolean(),
|
||||
pkceEnabled: z.boolean()
|
||||
});
|
||||
@@ -78,11 +80,20 @@
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
|
||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||
<div></div>
|
||||
<OidcCallbackUrlInput
|
||||
label="Callback URLs"
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||
bind:error={$inputs.callbackURLs.error}
|
||||
/>
|
||||
<OidcCallbackUrlInput
|
||||
label="Logout Callback URLs"
|
||||
class="w-full"
|
||||
allowEmpty
|
||||
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
|
||||
bind:error={$inputs.logoutCallbackURLs.error}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="public-client"
|
||||
label="Public Client"
|
||||
@@ -104,7 +115,7 @@
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
|
||||
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
|
||||
<img
|
||||
class="m-auto max-h-full max-w-full object-contain"
|
||||
src={logoDataURL}
|
||||
|
||||
@@ -25,7 +25,8 @@ export const oidcClients = {
|
||||
nextcloud: {
|
||||
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
|
||||
name: 'Nextcloud',
|
||||
callbackUrl: 'http://nextcloud/auth/callback'
|
||||
callbackUrl: 'http://nextcloud/auth/callback',
|
||||
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback'
|
||||
},
|
||||
immich: {
|
||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||
|
||||
@@ -37,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
|
||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback');
|
||||
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
|
||||
@@ -89,10 +89,11 @@ test('Authorize new client fails with user group not allowed', async ({ page })
|
||||
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expect(page.getByRole('paragraph').first()).toHaveText("You're not allowed to access this service.");
|
||||
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,
|
||||
@@ -103,3 +104,33 @@ function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
|
||||
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
|
||||
});
|
||||
}
|
||||
|
||||
test('End session without id token hint shows confirmation page', async ({ page }) => {
|
||||
await page.goto('/api/oidc/end-session');
|
||||
|
||||
await expect(page).toHaveURL('/logout');
|
||||
await page.getByRole('button', { name: 'Sign out' }).click();
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
test('End session with id token hint redirects to callback URL', async ({ page }) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const idToken =
|
||||
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';
|
||||
|
||||
let redirectedCorrectly = false;
|
||||
await page
|
||||
.goto(
|
||||
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
redirectedCorrectly = true;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
expect(redirectedCorrectly).toBeTruthy();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user