feat(qr-device-login): Introduce QR-based device login components and utilities with React component, hook, and client-side error handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 23:51:17 -07:00
parent 504fa0d92b
commit 8bd39e401b
7 changed files with 632 additions and 0 deletions

View file

@ -0,0 +1,125 @@
/**
* @lilith/qr-device-login-client
*
* Server-side SDK used by consuming apps' backends. Talks to the service's
* admin API over mTLS (via undici's Agent) and exposes three typed methods:
* createSession, pollSession, exchangeCode.
*
* Consuming apps also need a small nginx / reverse-proxy config that passes
* through the client certificate but that's deployment, not SDK.
*/
import { Agent, request } from 'undici';
import {
ADMIN_PATHS,
type CreateSessionRequest,
type CreateSessionResponse,
type ErrorBody,
type ExchangeCodeRequest,
type ExchangeCodeResponse,
type PollSessionResponse,
type SessionMetadata,
} from '@lilith/qr-device-login-protocol';
import { errorFromCode, QrLoginError } from './errors';
export interface QrLoginClientOptions {
/** e.g. "https://qr-auth.lilith.live" */
baseUrl: string;
/** PEM-encoded client certificate (full chain). */
cert: string | Buffer;
/** PEM-encoded private key matching `cert`. */
key: string | Buffer;
/** PEM-encoded CA bundle the service's server certificate chains to. */
ca: string | Buffer;
/** Request timeout in ms. Defaults to 5s. */
timeoutMs?: number;
}
export interface QrLoginClient {
createSession(input: CreateSessionRequest): Promise<CreateSessionResponse>;
pollSession(id: string): Promise<PollSessionResponse>;
exchangeCode(id: string, code: string): Promise<ExchangeCodeResponse>;
}
export function createQrLoginClient(options: QrLoginClientOptions): QrLoginClient {
const baseUrl = options.baseUrl.replace(/\/$/, '');
const timeoutMs = options.timeoutMs ?? 5_000;
const agent = new Agent({
connect: {
cert: options.cert,
key: options.key,
ca: options.ca,
rejectUnauthorized: true,
},
headersTimeout: timeoutMs,
bodyTimeout: timeoutMs,
});
async function send<T>(
path: string,
init: { method: 'GET' | 'POST'; body?: unknown },
): Promise<T> {
let res;
try {
res = await request(`${baseUrl}${path}`, {
method: init.method,
dispatcher: agent,
headers: init.body !== undefined
? { 'content-type': 'application/json' }
: {},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
});
} catch (err) {
throw new QrLoginError('internal_error', `transport failure: ${String(err)}`, 0);
}
const text = await res.body.text();
if (res.statusCode >= 200 && res.statusCode < 300) {
if (text.length === 0) return {} as T;
return JSON.parse(text) as T;
}
let parsed: ErrorBody | null = null;
try {
parsed = JSON.parse(text) as ErrorBody;
} catch {
parsed = null;
}
if (parsed?.error?.code) {
throw errorFromCode(parsed.error.code, parsed.error.message, res.statusCode);
}
throw new QrLoginError('internal_error', `http ${res.statusCode}: ${text}`, res.statusCode);
}
return {
async createSession(input: CreateSessionRequest): Promise<CreateSessionResponse> {
if (!input.callbackUrl || typeof input.callbackUrl !== 'string') {
throw new QrLoginError('invalid_request', 'callbackUrl is required', 400);
}
const body: CreateSessionRequest = { callbackUrl: input.callbackUrl };
if (input.metadata) body.metadata = sanitizeMetadata(input.metadata);
return send<CreateSessionResponse>(ADMIN_PATHS.createSession, { method: 'POST', body });
},
async pollSession(id: string): Promise<PollSessionResponse> {
if (!id) throw new QrLoginError('invalid_request', 'id is required', 400);
return send<PollSessionResponse>(ADMIN_PATHS.getSession(id), { method: 'GET' });
},
async exchangeCode(id: string, code: string): Promise<ExchangeCodeResponse> {
if (!id) throw new QrLoginError('invalid_request', 'id is required', 400);
if (!code) throw new QrLoginError('invalid_request', 'code is required', 400);
const body: ExchangeCodeRequest = { code };
return send<ExchangeCodeResponse>(ADMIN_PATHS.exchangeCode(id), { method: 'POST', body });
},
};
}
function sanitizeMetadata(input: SessionMetadata): SessionMetadata {
const out: SessionMetadata = {};
for (const [k, v] of Object.entries(input)) {
if (typeof v === 'string') out[k] = v;
}
return out;
}

View file

@ -0,0 +1,73 @@
/**
* Typed errors returned by the client SDK.
*
* We intentionally expose a thin hierarchy keyed to the service's
* `ErrorCode` values consumers can either catch the base class and
* inspect `code`, or catch individual subclasses.
*/
import type { ErrorCode } from '@lilith/qr-device-login-protocol';
export class QrLoginError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly httpStatus: number,
) {
super(message);
this.name = 'QrLoginError';
}
}
export class QrLoginUnauthorized extends QrLoginError {
constructor(message = 'mTLS rejected by qr-device-login service') {
super('unauthorized', message, 401);
this.name = 'QrLoginUnauthorized';
}
}
export class QrLoginSessionExpired extends QrLoginError {
constructor(message = 'session expired') {
super('session_expired', message, 410);
this.name = 'QrLoginSessionExpired';
}
}
export class QrLoginAlreadyConsumed extends QrLoginError {
constructor(message = 'session already consumed') {
super('session_already_consumed', message, 410);
this.name = 'QrLoginAlreadyConsumed';
}
}
export class QrLoginWrongCode extends QrLoginError {
constructor(message = 'exchange code mismatch') {
super('wrong_code', message, 403);
this.name = 'QrLoginWrongCode';
}
}
export class QrLoginSessionNotFound extends QrLoginError {
constructor(message = 'session not found') {
super('session_not_found', message, 404);
this.name = 'QrLoginSessionNotFound';
}
}
/** Maps the wire `ErrorCode` to a typed subclass. */
export function errorFromCode(code: ErrorCode, message: string, httpStatus: number): QrLoginError {
switch (code) {
case 'unauthorized':
return new QrLoginUnauthorized(message);
case 'session_expired':
return new QrLoginSessionExpired(message);
case 'session_already_consumed':
return new QrLoginAlreadyConsumed(message);
case 'wrong_code':
return new QrLoginWrongCode(message);
case 'session_not_found':
return new QrLoginSessionNotFound(message);
default:
return new QrLoginError(code, message, httpStatus);
}
}

View file

@ -0,0 +1,29 @@
/**
* Public surface of @lilith/qr-device-login-client.
*
* Import the factory, construct once per app, reuse across requests:
*
* const qr = createQrLoginClient({ baseUrl, cert, key, ca });
* const session = await qr.createSession({ callbackUrl });
*/
export { createQrLoginClient } from './client';
export type { QrLoginClient, QrLoginClientOptions } from './client';
export {
QrLoginError,
QrLoginUnauthorized,
QrLoginSessionExpired,
QrLoginAlreadyConsumed,
QrLoginWrongCode,
QrLoginSessionNotFound,
} from './errors';
export type {
CreateSessionRequest,
CreateSessionResponse,
PollSessionResponse,
ExchangeCodeRequest,
ExchangeCodeResponse,
SessionMetadata,
SessionStatus,
ErrorCode,
} from '@lilith/qr-device-login-protocol';

View file

@ -0,0 +1,109 @@
/**
* <DeviceLoginQR /> drop-in component for consuming apps.
*
* Styling uses @lilith/ui-styled-components per project convention
* (single ThemeProvider instance across lilith apps).
*/
import { styled } from '@lilith/ui-styled-components';
import type { ReactElement } from 'react';
import { useDeviceLogin, type UseDeviceLoginOptions } from './useDeviceLogin';
export interface DeviceLoginQRProps extends UseDeviceLoginOptions {
/** Button label when idle. Defaults to "Generate QR Code". */
ctaLabel?: string;
/** Optional heading rendered above the component. */
heading?: string;
}
const Wrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
`;
const Heading = styled.h3`
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #e0e0e0);
`;
const QrImage = styled.img`
width: 240px;
height: 240px;
border-radius: 8px;
background: #fff;
padding: 8px;
`;
const Status = styled.p`
margin: 0;
font-size: 0.85rem;
color: var(--color-muted, #9ca3af);
`;
const ActionButton = styled.button`
padding: 0.6rem 1.1rem;
border-radius: 8px;
border: 1px solid rgba(167, 139, 250, 0.4);
background: rgba(167, 139, 250, 0.12);
color: var(--color-accent, #a78bfa);
font-size: 0.9rem;
cursor: pointer;
&:hover { background: rgba(167, 139, 250, 0.2); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
`;
const ErrorText = styled.p`
margin: 0;
color: #f87171;
font-size: 0.85rem;
`;
export function DeviceLoginQR(props: DeviceLoginQRProps): ReactElement {
const { ctaLabel = 'Generate QR Code', heading, ...hookOptions } = props;
const { state, start, reset } = useDeviceLogin(hookOptions);
return (
<Wrapper>
{heading ? <Heading>{heading}</Heading> : null}
{state.kind === 'idle' && (
<ActionButton type="button" onClick={() => { void start(); }}>
{ctaLabel}
</ActionButton>
)}
{state.kind === 'starting' && <Status>Generating QR code</Status>}
{state.kind === 'active' && (
<>
<QrImage src={state.session.qrDataUrl} alt="Scan to log in" />
<Status>
Scan with your phone expires in {state.secondsRemaining}s
</Status>
</>
)}
{state.kind === 'scanned' && <Status>Scanned logging you in</Status>}
{state.kind === 'expired' && (
<>
<Status>QR code expired.</Status>
<ActionButton type="button" onClick={() => { void start(); }}>
Generate new code
</ActionButton>
</>
)}
{state.kind === 'error' && (
<>
<ErrorText>{state.message}</ErrorText>
<ActionButton type="button" onClick={reset}>
Try again
</ActionButton>
</>
)}
</Wrapper>
);
}

View file

@ -0,0 +1,12 @@
/**
* Public surface of @lilith/qr-device-login-react.
*/
export { DeviceLoginQR } from './DeviceLoginQR';
export type { DeviceLoginQRProps } from './DeviceLoginQR';
export { useDeviceLogin } from './useDeviceLogin';
export type {
UseDeviceLoginOptions,
UseDeviceLoginResult,
DeviceLoginState,
} from './useDeviceLogin';

View file

@ -0,0 +1,162 @@
/**
* useDeviceLogin lifecycle hook for a QR device-login session.
*
* Owns:
* - POST to the consumer's /start endpoint to mint a session.
* - Poll /poll/:id on an interval until status flips to `scanned` or `expired`.
* - Countdown until expiry.
* - Cleanup on unmount.
*
* The consumer's backend is responsible for the actual proxying to the
* qr-device-login service. This hook deliberately knows nothing about mTLS,
* certs, or the service URL it only talks to same-origin URLs.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CreateSessionResponse,
PollSessionResponse,
SessionStatus,
} from '@lilith/qr-device-login-protocol';
export type DeviceLoginState =
| { kind: 'idle' }
| { kind: 'starting' }
| {
kind: 'active';
session: CreateSessionResponse;
status: SessionStatus;
secondsRemaining: number;
}
| { kind: 'expired' }
| { kind: 'scanned'; session: CreateSessionResponse }
| { kind: 'error'; message: string };
export interface UseDeviceLoginOptions {
/** POST endpoint on the consuming app that creates a session. */
startUrl: string;
/** GET endpoint on the consuming app that polls a session by id (`:id` appended). */
pollUrl: string;
/** Polling interval in ms. Default 3000. */
pollIntervalMs?: number;
/** Called once when the poll flips to `scanned`. */
onSuccess?: () => void;
/** Called on any transport or protocol error. */
onError?: (err: Error) => void;
}
export interface UseDeviceLoginResult {
state: DeviceLoginState;
start: () => Promise<void>;
reset: () => void;
}
export function useDeviceLogin(options: UseDeviceLoginOptions): UseDeviceLoginResult {
const { startUrl, pollUrl, pollIntervalMs = 3_000, onSuccess, onError } = options;
const [state, setState] = useState<DeviceLoginState>({ kind: 'idle' });
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef<boolean>(true);
const clearTimers = useCallback((): void => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
useEffect((): (() => void) => {
mountedRef.current = true;
return (): void => {
mountedRef.current = false;
clearTimers();
};
}, [clearTimers]);
const reset = useCallback((): void => {
clearTimers();
if (mountedRef.current) setState({ kind: 'idle' });
}, [clearTimers]);
const start = useCallback(async (): Promise<void> => {
clearTimers();
setState({ kind: 'starting' });
let session: CreateSessionResponse;
try {
const res = await fetch(startUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!res.ok) {
throw new Error(`start failed: HTTP ${res.status}`);
}
session = (await res.json()) as CreateSessionResponse;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (mountedRef.current) setState({ kind: 'error', message });
onError?.(err instanceof Error ? err : new Error(message));
return;
}
const expiresAtMs = Date.parse(session.expiresAt);
const tick = (): void => {
if (!mountedRef.current) return;
const secondsRemaining = Math.max(0, Math.round((expiresAtMs - Date.now()) / 1000));
setState((prev) => {
if (prev.kind !== 'active') return prev;
if (secondsRemaining <= 0) {
clearTimers();
return { kind: 'expired' };
}
return { ...prev, secondsRemaining };
});
};
setState({
kind: 'active',
session,
status: 'pending',
secondsRemaining: Math.max(0, Math.round((expiresAtMs - Date.now()) / 1000)),
});
countdownRef.current = setInterval(tick, 1_000);
const poll = async (): Promise<void> => {
try {
const res = await fetch(`${pollUrl}/${session.id}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`poll failed: HTTP ${res.status}`);
const body = (await res.json()) as PollSessionResponse;
if (!mountedRef.current) return;
if (body.status === 'scanned') {
clearTimers();
setState({ kind: 'scanned', session });
onSuccess?.();
return;
}
if (body.status === 'expired') {
clearTimers();
setState({ kind: 'expired' });
return;
}
setState((prev) => (prev.kind === 'active' ? { ...prev, status: body.status } : prev));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
clearTimers();
if (mountedRef.current) setState({ kind: 'error', message });
onError?.(err instanceof Error ? err : new Error(message));
}
};
pollRef.current = setInterval(() => { void poll(); }, pollIntervalMs);
}, [startUrl, pollUrl, pollIntervalMs, onSuccess, onError, clearTimers]);
return useMemo(() => ({ state, start, reset }), [state, start, reset]);
}

View file

@ -0,0 +1,122 @@
/**
* Wire protocol for the QR device-login service.
*
* All types here are transport-level: the shapes exchanged between a consuming
* app's server and the qr-device-login service. They are versioned under
* `/admin/v1/...` paths on the service side.
*
* Keep this file free of runtime dependencies it must be safely importable
* from both Node and browser contexts.
*/
/**
* Lifecycle state of a pending scan session.
*
* - `pending` created, not yet scanned
* - `scanned` phone has hit /scan/:token and been redirected to callbackUrl
* - `expired` TTL elapsed, or explicitly burned by a successful exchange
*/
export type SessionStatus = 'pending' | 'scanned' | 'expired';
/**
* Arbitrary string-keyed metadata the consumer can stash on a session.
*
* The service treats this as an opaque blob: it is stored with the pending
* session and handed back verbatim during `exchangeCode`. Consumers typically
* use it to carry the userId that should be logged in on the phone.
*/
export type SessionMetadata = Record<string, string>;
// ---------------------------------------------------------------------------
// POST /admin/v1/sessions — create a new QR session
// ---------------------------------------------------------------------------
export interface CreateSessionRequest {
/**
* Fully-qualified URL on the consuming app that will receive the phone's
* redirect after a successful scan. Must match one of the consumer's
* `allowedCallbacks` prefixes on the service side.
*/
callbackUrl: string;
/**
* Optional opaque metadata echoed back on `exchangeCode`.
*/
metadata?: SessionMetadata;
}
export interface CreateSessionResponse {
/** Opaque session id — used for polling + exchange. */
id: string;
/** Pre-rendered QR as a data URL (PNG). */
qrDataUrl: string;
/** The raw URL encoded in the QR (useful for copy-link fallbacks). */
scanUrl: string;
/** ISO-8601 expiry timestamp. */
expiresAt: string;
}
// ---------------------------------------------------------------------------
// GET /admin/v1/sessions/:id — poll status
// ---------------------------------------------------------------------------
export interface PollSessionResponse {
status: SessionStatus;
expiresAt: string;
}
// ---------------------------------------------------------------------------
// POST /admin/v1/sessions/:id/exchange — burn the token + claim the scan
// ---------------------------------------------------------------------------
export interface ExchangeCodeRequest {
/** One-time code handed to the phone by the /scan/:token redirect. */
code: string;
}
export interface ExchangeCodeResponse {
/** Metadata supplied at createSession time, unchanged. */
metadata: SessionMetadata;
/** ISO-8601 timestamp when the scan landed. */
scannedAt: string;
}
// ---------------------------------------------------------------------------
// Error envelope
// ---------------------------------------------------------------------------
/**
* Machine-readable error codes returned in the `error.code` field of any
* non-2xx response from the service.
*/
export type ErrorCode =
| 'unauthorized' // mTLS failed / unknown consumer
| 'forbidden' // consumer not allowed to touch this session
| 'invalid_request' // malformed body / bad callbackUrl
| 'callback_not_allowed' // callbackUrl not on consumer's allowlist
| 'session_not_found' // id unknown
| 'session_expired' // past TTL
| 'session_already_consumed' // exchange already succeeded
| 'wrong_code' // exchange code mismatch
| 'rate_limited' // global pending-token cap exceeded
| 'internal_error';
export interface ErrorBody {
error: {
code: ErrorCode;
message: string;
};
}
/**
* Admin API paths, centralised so the client + server agree. The `:id`
* placeholder is substituted at call time.
*/
export const ADMIN_PATHS = {
createSession: '/admin/v1/sessions',
getSession: (id: string) => `/admin/v1/sessions/${id}`,
exchangeCode: (id: string) => `/admin/v1/sessions/${id}/exchange`,
} as const;
/** Public scan landing path (phones hit this directly). */
export const SCAN_PATH = (token: string): string => `/scan/${token}`;