feat: add qr code alternative sign in method (#1594)

Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2026-07-28 01:30:13 +02:00
committed by GitHub
co-authored by ItalyPaleAle
parent 968f97fa61
commit e1fd1d320f
38 changed files with 2215 additions and 73 deletions
@@ -38,24 +38,13 @@
});
const isDesktop = new MediaQuery('(min-width: 1024px)');
let alternativeSignInButton = $state({
href: '/login/alternative',
let alternativeSignInButton = $derived({
href:
page.url.pathname === '/login'
? `/login/alternative${page.url.search}`
: `/login/alternative?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`,
label: m.alternative_sign_in_methods()
});
appConfigStore.subscribe((config) => {
if (config.emailOneTimeAccessAsUnauthenticatedEnabled) {
alternativeSignInButton.href = '/login/alternative';
alternativeSignInButton.label = m.alternative_sign_in_methods();
} else {
alternativeSignInButton.href = '/login/alternative/code';
alternativeSignInButton.label = m.sign_in_with_login_code();
}
if (page.url.pathname != '/login') {
alternativeSignInButton.href = `${alternativeSignInButton.href}?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`;
}
});
</script>
{#if backgroundImageExists === undefined}
@@ -32,9 +32,14 @@
}
};
QRCode.toCanvas(canvasEl, value, options).catch((error: Error) => {
console.error('Error generating QR Code:', error);
});
QRCode.toCanvas(canvasEl, value, options)
.then(() => {
canvasEl?.style.removeProperty('height');
canvasEl?.style.removeProperty('width');
})
.catch((error: Error) => {
console.error('Error generating QR Code:', error);
});
}
});
</script>
@@ -0,0 +1,35 @@
import type {
DeviceLoginDecision,
DeviceLoginExchangeResult,
DeviceLoginRequest,
DeviceLoginVerificationInfo
} from '$lib/types/device-login.type';
import APIService from './api-service';
export default class DeviceLoginService extends APIService {
createRequest = async (signal?: AbortSignal) => {
const response = await this.api.post('/device-login/requests', undefined, { signal });
return response.data as DeviceLoginRequest;
};
exchangeRequest = async (
requestId: string,
signal?: AbortSignal
): Promise<DeviceLoginExchangeResult> => {
const response = await this.api.post(
`/device-login/requests/${requestId}/exchange`,
undefined,
{ signal }
);
return response.status === 202 ? null : response.data;
};
inspectRequest = async (code: string) => {
const response = await this.api.post('/device-login/verification', { code });
return response.data as DeviceLoginVerificationInfo;
};
decideRequest = async (code: string, decision: DeviceLoginDecision) => {
await this.api.post('/device-login/verification/decision', { code, decision });
};
}
@@ -0,0 +1,21 @@
import type { User } from './user.type';
export type DeviceLoginRequest = {
id: string;
userCode: string;
verificationUri: string;
verificationUriComplete: string;
expiresAt: string;
interval: number;
};
export type DeviceLoginVerificationInfo = {
userCode: string;
device: string;
ipAddress?: string;
expiresAt: string;
};
export type DeviceLoginDecision = 'approve' | 'deny';
export type DeviceLoginExchangeResult = User | null;
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages';
export const eventTypes: Record<string, string> = {
SIGN_IN: m.sign_in(),
TOKEN_SIGN_IN: m.token_sign_in(),
REMOTE_SIGN_IN: m.remote_sign_in(),
CLIENT_AUTHORIZATION: m.client_authorization(),
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
ACCOUNT_CREATED: m.account_created(),
+157 -28
View File
@@ -4,12 +4,15 @@
import ScopeList from '$lib/components/scope-list.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as InputOTP from '$lib/components/ui/input-otp';
import { Spinner } from '$lib/components/ui/spinner';
import { m } from '$lib/paraglide/messages';
import DeviceLoginService from '$lib/services/device-login-service';
import OIDCService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type';
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
@@ -21,17 +24,26 @@
let { data } = $props();
const deviceLoginService = new DeviceLoginService();
const oidcService = new OIDCService();
const webauthnService = new WebAuthnService();
let userCode = $state(data.code || '');
let isLoading = $state(false);
let deviceInfo: OidcDeviceCodeInfo | undefined = $state();
let deviceLoginInfo: DeviceLoginVerificationInfo | undefined = $state();
let success = $state(false);
let deviceLoginOutcome: 'approved' | 'denied' | undefined = $state();
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let reauthenticationRequired = $state(false);
let reauthenticated = $state(false);
let normalizedUserCode = $derived(
userCode.trim().toUpperCase().replaceAll('I', '1').replaceAll('O', '0')
);
let codeComplete = $derived(normalizedUserCode.length === 8);
let completed = $derived(success || deviceLoginOutcome !== undefined);
onMount(() => {
if (data.code && $userStore) {
@@ -40,28 +52,29 @@
});
async function authorize() {
if (!data.code && !codeComplete) return;
isLoading = true;
errorMessage = null;
try {
// Get access token if not signed in
if (!$userStore) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
await authenticateUserIfNeeded();
let isDeviceLoginCode = normalizedUserCode.startsWith('P');
if (isDeviceLoginCode) {
deviceLoginInfo = await deviceLoginService.inspectRequest(normalizedUserCode);
return;
}
const info = await oidcService.getDeviceCodeInfo(userCode);
const info = await oidcService.getDeviceCodeInfo(normalizedUserCode);
deviceInfo = info;
if (info.authorizationRequired && !authorizationRequired) {
authorizationRequired = true;
isLoading = false;
return;
}
if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) {
reauthenticationRequired = true;
isLoading = false;
return;
}
@@ -70,16 +83,42 @@
reauthenticated = true;
}
await oidcService.verifyDeviceCode(userCode);
await oidcService.verifyDeviceCode(normalizedUserCode);
success = true;
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
} finally {
isLoading = false;
}
}
async function authenticateUserIfNeeded() {
if ($userStore) return;
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
}
async function decideDeviceLogin(decision: 'approve' | 'deny') {
isLoading = true;
deviceLoginDecision = decision;
errorMessage = null;
try {
if (decision === 'approve') {
await reauthenticate();
}
await deviceLoginService.decideRequest(normalizedUserCode, decision);
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
} finally {
isLoading = false;
deviceLoginDecision = undefined;
}
}
async function reauthenticate() {
try {
await webauthnService.reauthenticate();
@@ -89,6 +128,16 @@
await webauthnService.reauthenticate(authResponse);
}
}
function retry() {
errorMessage = null;
userCode = '';
if (!deviceLoginInfo) {
deviceInfo = undefined;
authorizationRequired = false;
reauthenticationRequired = false;
}
}
</script>
<svelte:head>
@@ -100,16 +149,52 @@
{#if deviceInfo?.client}
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
{:else}
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
<LoginLogoErrorSuccessIndicator
success={success || deviceLoginOutcome === 'approved'}
error={!!errorMessage}
/>
{/if}
</div>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
<h1 class="font-gloock mt-5 text-4xl font-bold">
{m.authorize_device()}
</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}
</p>
{:else if deviceLoginOutcome === 'approved'}
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
{:else if deviceLoginOutcome === 'denied'}
<p class="text-muted-foreground mt-2">{m.the_sign_in_request_was_denied()}</p>
{:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if deviceLoginInfo}
<p class="text-muted-foreground mt-2">{m.review_the_request_before_approving_it()}</p>
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 text-start">
<Card.Content>
<dl class="flex flex-col gap-4 text-sm">
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.code()}</dt>
<dd class="font-medium">
{deviceLoginInfo.userCode.substring(0, 4)} - {deviceLoginInfo.userCode.substring(
4,
8
)}
</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.device()}</dt>
<dd class="text-right font-medium">{deviceLoginInfo.device}</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.ip_address()}</dt>
<dd class="font-medium">{deviceLoginInfo.ipAddress || m.unknown()}</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
</div>
{:else if reauthenticationRequired && deviceInfo?.client}
<p class="text-muted-foreground mt-2">
<FormattedMessage
@@ -120,16 +205,16 @@
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 gap-2">
<Card.Header>
<p class="text-muted-foreground text-start">
<Card.Description class="text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
/>
</p>
</Card.Description>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scopes={deviceInfo!.scope || []} scopeInfo={deviceInfo!.scopeInfo || []} />
@@ -138,19 +223,63 @@
</div>
{:else}
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
<form id="device-code-form" onsubmit={preventDefault(authorize)} class="w-full max-w-[450px]">
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
<form
id="device-code-form"
onsubmit={preventDefault(authorize)}
class="mt-7 flex w-full max-w-112.5 justify-center"
>
<InputOTP.Root
maxlength={8}
aria-label={m.code()}
bind:value={userCode}
onValueChange={(value) => (userCode = value.toUpperCase())}
pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(4) as cell}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</form>
{/if}
{#if !success}
<div class="mt-10 flex w-full max-w-[450px] gap-2">
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage}
<Button form="device-code-form" class="flex-1" onclick={authorize} {isLoading}
>{m.authorize()}</Button
{#if !completed}
<div class="mt-10 flex w-full max-w-112.5 gap-2">
{#if errorMessage}
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
{:else if deviceLoginInfo}
<Button
class="flex-1"
variant="secondary"
disabled={isLoading}
onclick={() => decideDeviceLogin('deny')}
>
{#if deviceLoginDecision === 'deny'}<Spinner data-icon="inline-start" />{/if}
{m.deny()}
</Button>
<Button class="flex-1" {isLoading} onclick={() => decideDeviceLogin('approve')}>
{m.approve()}
</Button>
{:else}
<Button class="flex-1" onclick={() => (errorMessage = null)}>{m.try_again()}</Button>
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
<Button
form="device-code-form"
class="flex-1"
disabled={isLoading || !codeComplete}
onclick={authorize}
>
{#if isLoading}<Spinner data-icon="inline-start" />{/if}
{m.authorize()}
</Button>
{/if}
</div>
{/if}
@@ -5,7 +5,12 @@
import * as Item from '$lib/components/ui/item/index.js';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from '@lucide/svelte';
import {
LucideChevronRight,
LucideMail,
LucideQrCode,
LucideRectangleEllipsis
} from '@lucide/svelte';
const methods = [
{
@@ -13,6 +18,12 @@
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code'
},
{
icon: LucideQrCode,
title: m.sign_in_with_another_device(),
description: m.sign_in_with_another_device_description(),
href: '/login/alternative/device'
}
];
@@ -0,0 +1,156 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Separator } from '$lib/components/ui/separator';
import { Spinner } from '$lib/components/ui/spinner';
import { m } from '$lib/paraglide/messages';
import DeviceLoginService from '$lib/services/device-login-service';
import userStore from '$lib/stores/user-store';
import type { DeviceLoginRequest } from '$lib/types/device-login.type';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
let { data } = $props();
const deviceLoginService = new DeviceLoginService();
let request: DeviceLoginRequest | undefined = $state();
let errorMessage: string | null = $state(null);
let isStarting = $state(true);
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let requestController: AbortController | undefined;
onMount(() => {
void startRequest();
return () => {
stopRequest();
};
});
async function startRequest() {
stopRequest();
const controller = new AbortController();
requestController = controller;
request = undefined;
errorMessage = null;
isStarting = true;
try {
request = await deviceLoginService.createRequest(controller.signal);
if (requestController !== controller) return;
schedulePoll(controller);
} catch (error) {
if (controller.signal.aborted) return;
errorMessage = getAxiosErrorMessage(error);
} finally {
if (requestController === controller) {
isStarting = false;
}
}
}
function schedulePoll(controller: AbortController) {
if (!request || requestController !== controller) return;
pollTimer = setTimeout(() => void exchangeRequest(controller), request.interval * 1000);
}
async function exchangeRequest(controller: AbortController) {
if (!request || requestController !== controller) return;
try {
const user = await deviceLoginService.exchangeRequest(request.id, controller.signal);
if (requestController !== controller) return;
if (!user) {
schedulePoll(controller);
return;
}
clearTimers();
await userStore.setUser(user);
if (requestController !== controller) return;
await goto(data.redirect);
} catch (error) {
if (controller.signal.aborted) return;
clearTimers();
errorMessage = getAxiosErrorMessage(error);
}
}
function stopRequest() {
requestController?.abort();
requestController = undefined;
clearTimers();
}
function clearTimers() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = undefined;
}
</script>
<svelte:head>
<title>{m.sign_in_with_another_device()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!errorMessage} />
</div>
<h1 class="font-gloock mt-5 text-2xl font-bold sm:text-4xl">
{m.sign_in_with_another_device()}
</h1>
<p class="text-muted-foreground mt-2">
{errorMessage ? errorMessage : m.sign_in_with_another_device_description()}
</p>
{#if isStarting}
<div class="mt-10 flex items-center gap-2 text-sm">
<Spinner />
{m.creating_device_login_request()}
</div>
{:else if request && !errorMessage}
<Card.Root class="mt-8 w-full max-w-sm shrink-0">
<Card.Content class="flex flex-col items-center gap-5">
<Qrcode
value={request.verificationUriComplete}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
aria-label={m.device_login_qr_code()}
class="h-[12dvh]"
/>
<div class="flex w-full items-center gap-3">
<Separator class="flex-1" />
<span class="text-muted-foreground text-xs">{m.or()}</span>
<Separator class="flex-1" />
</div>
<div>
<p class="text-muted-foreground text-sm mb-2">
{m.visit_and_enter({ url: request.verificationUri })}
</p>
<CopyToClipboard value={request.userCode}>
<p class="text-xl sm:text-2xl font-bold tracking-wider" data-testid="device-login-code">
{request.userCode.substring(0, 4)}
<span class="text-muted-foreground font-normal">-</span>
{request.userCode.substring(4, 8)}
</p>
</CopyToClipboard>
</div>
</Card.Content>
</Card.Root>
{/if}
<div class="flex mt-7 md:mt-15 gap-3 w-full max-w-112.5">
<Button class="flex-1" href={'/login/alternative' + page.url.search} variant="secondary"
>{m.go_back()}</Button
>
<Button class="flex-1" isLoading={!errorMessage} onclick={startRequest}>
{errorMessage ? m.try_again() : m.waiting_for_approval()}
</Button>
</div>
</SignInWrapper>
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
return {
redirect: url.searchParams.get('redirect') || '/settings'
};
};