diff --git a/qr-device-login/README.md b/qr-device-login/README.md new file mode 100644 index 0000000..6b048b6 --- /dev/null +++ b/qr-device-login/README.md @@ -0,0 +1,97 @@ +# @lilith/qr-device-login + +Reusable QR-based cross-device session transfer. Enterprise-grade: mTLS admin +API, public scan landing, in-memory token store with TTL + DoS cap. + +Follows the RFC 8628 device-authorization-grant shape adapted for same-user +session transfer between devices of an already-logged-in account. + +## Why a service, not a library? + +The previous implementation was a self-contained route module vendored into two +feature backends (`@features/my`, `@features/admin`). It worked, but any new +consumer had to own the code — which meant `qrcode` as a dep, in-memory state +per process, and drift between forks. + +This app owns **only** token lifecycle, QR encoding, and scan landing. +Session minting stays with the consuming app — each consumer mints its own +cookie after exchanging a one-time code over mTLS. + +## Architecture + +``` +Desktop (logged in) qr-auth.lilith.live Consuming app + │ │ │ + │ 1. POST /api/device-link/ │ │ + │ start ─────────────────────────────────────────────▶ createSession + │ │ ◀── mTLS ──────────────── (client SDK) + │ │ │ + │ ◀── {id, qrDataUrl} ────────────────────────────────────── │ + │ render QR + poll │ │ + │ │ │ + │ │ │ + Phone scans QR ─▶ GET /scan/:token │ │ + │ mark scanned, mint code │ + │ 302 callbackUrl?id=&code= │ + │ ─────────────────────────▶ callback handler + │ │ ◀── exchangeCode + │ │ ── mTLS ──▶ + │ burn token, return metadata │ + │ ─────────────────────────▶ createToken() + │ │ setSessionCookie() + │ │ 302 / +``` + +**Invariant**: the service never sets a cookie for any consuming domain. +It only confirms "this scan was real" and hands back opaque metadata. + +## Packages + +| Package | Purpose | Published | +|---------|---------|-----------| +| `server/` | Bun HTTP service (private) | No | +| `shared/` → `@lilith/qr-device-login-protocol` | Wire types shared by server + client | Yes | +| `client/` → `@lilith/qr-device-login-client` | Server-side SDK for consuming apps (mTLS via undici) | Yes | +| `react/` → `@lilith/qr-device-login-react` | `` + `useDeviceLogin` hook | Yes | + +## Consumer integration (3 thin proxy routes) + +```ts +// backend: 3 thin proxy routes +import { createQrLoginClient } from '@lilith/qr-device-login-client'; + +const qr = createQrLoginClient({ + baseUrl: process.env.QR_AUTH_BASE_URL!, + cert: readFileSync(process.env.QR_AUTH_CERT!), + key: readFileSync(process.env.QR_AUTH_KEY!), + ca: readFileSync(process.env.QR_AUTH_CA!), +}); + +// POST /api/device-link/start (authenticated) +const session = await qr.createSession({ + callbackUrl: `${PUBLIC_BASE}/auth/device-link/callback`, +}); + +// GET /api/device-link/poll/:id (authenticated) +const status = await qr.pollSession(id); + +// GET /auth/device-link/callback?id=&code= (public) +const { metadata } = await qr.exchangeCode(id, code); +// → createToken() + setSessionCookie() → 302 '/' +``` + +```tsx +// frontend +import { DeviceLoginQR } from '@lilith/qr-device-login-react'; + + location.reload()} +/> +``` + +## Deploy + +See `deploy/` — systemd unit + nginx site config for `qr-auth.lilith.live` on +vps-0. Consumer certs live under `deploy/pki/` (private keys gitignored). diff --git a/qr-device-login/app.manifest.yaml b/qr-device-login/app.manifest.yaml new file mode 100644 index 0000000..ec9f074 --- /dev/null +++ b/qr-device-login/app.manifest.yaml @@ -0,0 +1,22 @@ +name: qr-device-login +description: | + Reusable QR-based cross-device session-transfer service. + Consumers wrap via published SDK packages; session issuance stays with + each consuming app. mTLS-authenticated admin API + public scan landing. +category: auth +host: vps-0 +services: + - name: qr-device-login + kind: systemd + unit: qr-device-login.service + workdir: /opt/qr-device-login + ports: + - { port: 8787, role: loopback, description: "Bun service, fronted by nginx" } +nginx: + sites: + - qr-auth.lilith.live +packages: + publish: + - shared # @lilith/qr-device-login-protocol + - client # @lilith/qr-device-login-client + - react # @lilith/qr-device-login-react diff --git a/qr-device-login/tsconfig.base.json b/qr-device-login/tsconfig.base.json new file mode 100644 index 0000000..38c1e7c --- /dev/null +++ b/qr-device-login/tsconfig.base.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "exclude": ["node_modules", "dist", "build"] +}