diff --git a/qr-device-login/client/src/client.ts b/qr-device-login/client/src/client.ts new file mode 100644 index 0000000..0b23664 --- /dev/null +++ b/qr-device-login/client/src/client.ts @@ -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; + pollSession(id: string): Promise; + exchangeCode(id: string, code: string): Promise; +} + +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( + path: string, + init: { method: 'GET' | 'POST'; body?: unknown }, + ): Promise { + 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 { + 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(ADMIN_PATHS.createSession, { method: 'POST', body }); + }, + + async pollSession(id: string): Promise { + if (!id) throw new QrLoginError('invalid_request', 'id is required', 400); + return send(ADMIN_PATHS.getSession(id), { method: 'GET' }); + }, + + async exchangeCode(id: string, code: string): Promise { + 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(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; +} diff --git a/qr-device-login/client/src/errors.ts b/qr-device-login/client/src/errors.ts new file mode 100644 index 0000000..6cbded7 --- /dev/null +++ b/qr-device-login/client/src/errors.ts @@ -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); + } +} diff --git a/qr-device-login/client/src/index.ts b/qr-device-login/client/src/index.ts new file mode 100644 index 0000000..8bf6bb2 --- /dev/null +++ b/qr-device-login/client/src/index.ts @@ -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'; diff --git a/qr-device-login/react/src/DeviceLoginQR.tsx b/qr-device-login/react/src/DeviceLoginQR.tsx new file mode 100644 index 0000000..7583424 --- /dev/null +++ b/qr-device-login/react/src/DeviceLoginQR.tsx @@ -0,0 +1,109 @@ +/** + * — 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 ( + + {heading ? {heading} : null} + {state.kind === 'idle' && ( + { void start(); }}> + {ctaLabel} + + )} + {state.kind === 'starting' && Generating QR code…} + {state.kind === 'active' && ( + <> + + + Scan with your phone — expires in {state.secondsRemaining}s + + + )} + {state.kind === 'scanned' && Scanned — logging you in…} + {state.kind === 'expired' && ( + <> + QR code expired. + { void start(); }}> + Generate new code + + + )} + {state.kind === 'error' && ( + <> + {state.message} + + Try again + + + )} + + ); +} diff --git a/qr-device-login/react/src/index.ts b/qr-device-login/react/src/index.ts new file mode 100644 index 0000000..5b51c35 --- /dev/null +++ b/qr-device-login/react/src/index.ts @@ -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'; diff --git a/qr-device-login/react/src/useDeviceLogin.ts b/qr-device-login/react/src/useDeviceLogin.ts new file mode 100644 index 0000000..86f41b6 --- /dev/null +++ b/qr-device-login/react/src/useDeviceLogin.ts @@ -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; + reset: () => void; +} + +export function useDeviceLogin(options: UseDeviceLoginOptions): UseDeviceLoginResult { + const { startUrl, pollUrl, pollIntervalMs = 3_000, onSuccess, onError } = options; + + const [state, setState] = useState({ kind: 'idle' }); + const pollRef = useRef | null>(null); + const countdownRef = useRef | null>(null); + const mountedRef = useRef(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 => { + 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 => { + 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]); +} diff --git a/qr-device-login/shared/src/index.ts b/qr-device-login/shared/src/index.ts new file mode 100644 index 0000000..ed6ab24 --- /dev/null +++ b/qr-device-login/shared/src/index.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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}`;