# quiXzoom — Passwordless Cross-Device Authentication > Arkitektur för att logga in på webb (quixzoom.se) utan lösenord, via en redan inloggad mobilapp. > Version: 1.0 | Datum: 2026-07-07 --- ## 1. Översikt | Aspekt | Beskrivning | |--------|-------------| | **Mål** | Användare med quiXzoom-appen ska kunna logga in på webb utan att ange lösenord | | **Flöde** | Webb visar QR-kod → App skannar/godkänner → Webb loggas in automatiskt | | **Backend** | Samma JWT-issuer (`api.quixzoom.com`) som appen redan använder | | **Säkerhetsmodell** | Kryptografiska signaturer, engångstokens, tidsbegränsning, device-binding | --- ## 2. Sekvensdiagram ``` ┌─────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │ Webb │ │ quixzoom.se │ │ quiXzoom App │ │ api.quixzoom.com │ │ Redis │ │ (Browser)│ │ (Frontend) │ │ (Mobile) │ │ (Backend) │ │ (Sessions) │ └────┬────┘ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ └──────┬───────┘ │ │ │ │ │ │ 1. Klickar "Logga in med app" │ │ │ │────────────────>│ │ │ │ │ │ 2. POST /auth/passwordless/initiate │ │ │ │──────────────────────────────────────────>│ │ │ │ │ │ 3. Genererar │ │ │ │ │ session_id │ │ │ │ │ + request_token │ │ │ │ │────────────────────>│ │ │ 4. Returnerar │ │ │ │ │ {session_id, │ │ │ │ │ qr_data, │ │ │ │ │ expires_at} │ │ │ │ │<──────────────────────────────────────────│ │ │ 5. Visar QR-kod│ │ │ │ │<────────────────│ │ │ │ │ │ │ 6. Skannar QR-kod │ │ │ │ │ (eller trycker deep link) │ │ │ │ │ │ │ │ │ 7. POST /auth/passwordless/approve │ │ │ │ Headers: Authorization: Bearer │ │ │ │ Body: {session_id, request_token, signature}│ │ │ │───────────────────────────────────────────>│ │ │ │ │ 8. Verifierar │ │ │ │ │ • JWT giltigt │ │ │ │ │ • request_token │ │ │ │ │ matchar Redis │ │ │ │ │ • signatur OK │ │ │ │ │ 9. Uppdaterar │ │ │ │ │ Redis: approved │ │ │ │ │────────────────────>│ │ │ │ 10. 200 OK │ │ │ │ │<───────────────────────────────────────────│ │ │ │ │ │ │ 11. Polling: │ │ │ │ │ GET /auth/passwordless/status?session_id=xxx │ │ │────────────────>│ │ │ │ │ │ 12. Proxy till backend │ │ │ │──────────────────────────────────────────>│ │ │ │ │ │ 13. Kollar Redis │ │ │ │ │────────────────────>│ │ │ │ │<────────────────────│ │ │ 14. 200 OK │ │ │ │ │ {status:"approved", │ │ │ │ access_token, refresh_token} │ │ │ │<──────────────────────────────────────────│ │ │ 15. Sätter │ │ │ │ │ cookies │ │ │ │ │<────────────────│ │ │ │ │ 16. Omdirigerar│ │ │ │ │ till /dashboard │ │ │ │<────────────────│ │ │ │ │ │ │ │ │ ``` --- ## 3. Komponentarkitektur ``` ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ │ TRUST ZONES │ ├─────────────────────────────┬─────────────────────────────┬─────────────────────────────────┤ │ UNTRUSTED │ SEMI-TRUSTED │ TRUSTED │ │ (User Browser) │ (quixzoom.se CDN) │ (api.quixzoom.com) │ │ │ │ │ │ ┌─────────────────────┐ │ ┌─────────────────────┐ │ ┌─────────────────────────┐ │ │ │ Webb-frontend │ │ │ Static assets │ │ │ Passwordless Service │ │ │ │ • QR-visning │ │ │ • QR-kod render │ │ │ • /initiate │ │ │ │ • Polling-loop │ │ │ • Polling proxy │ │ │ • /approve │ │ │ │ • Cookie-hantering │ │ │ │ │ │ • /status │ │ │ └─────────────────────┘ │ └─────────────────────┘ │ │ • /cancel │ │ │ │ │ └─────────────────────────┘ │ │ │ │ ┌─────────────────────────┐ │ │ │ │ │ JWT Auth Service │ │ │ │ │ │ (befintlig) │ │ │ │ │ └─────────────────────────┘ │ │ │ │ ┌─────────────────────────┐ │ │ │ │ │ Redis Cluster │ │ │ │ │ │ • Sessions │ │ │ │ │ │ • Rate limits │ │ │ │ │ └─────────────────────────┘ │ │ │ │ ┌─────────────────────────┐ │ │ │ │ │ PostgreSQL │ │ │ │ │ │ • Users │ │ │ │ │ │ • Devices │ │ │ │ │ │ • Audit logs │ │ │ │ │ └─────────────────────────┘ │ └─────────────────────────────┴─────────────────────────────┴─────────────────────────────────┘ ``` --- ## 4. API-specifikation ### 4.1 POST /auth/passwordless/initiate Initierar en ny passwordless-inloggningssession. **Request:** ```http POST https://api.quixzoom.com/v1/auth/passwordless/initiate Content-Type: application/json { "client_id": "web-dashboard", // Identifierar webbklienten "redirect_url": "https://quixzoom.se/dashboard", "device_info": { "user_agent": "Mozilla/5.0...", "ip": "203.0.113.42" } } ``` **Response (201 Created):** ```json { "session_id": "pls_2vPqN5L8xT9wR3mK", "request_token": "a1b2c3d4e5f6...", // 32 bytes, base64url "qr_data": "quixzoom://auth?sid=pls_2vPqN5L8xT9wR3mK&token=a1b2...", "expires_at": "2026-07-07T07:11:00Z", // 15 minuter "poll_interval_ms": 2000 } ``` **Fel:** - `429 Too Many Requests` — rate limit per IP - `400 Bad Request` — ogiltig redirect_url --- ### 4.2 POST /auth/passwordless/approve Appen godkänner inloggningen. Kräver giltig app-JWT. **Request:** ```http POST https://api.quixzoom.com/v1/auth/passwordless/approve Content-Type: application/json Authorization: Bearer { "session_id": "pls_2vPqN5L8xT9wR3mK", "request_token": "a1b2c3d4e5f6...", "signature": "base64url(SHA256(session_id + request_token + timestamp))", "timestamp": "2026-07-07T06:58:00Z", "approving_device_id": "dev_abc123" } ``` **Response (200 OK):** ```json { "status": "approved", "session_id": "pls_2vPqN5L8xT9wR3mK", "approved_at": "2026-07-07T06:58:00Z", "web_session": { "user_agent_hash": "sha256:...", "ip_hash": "sha256:..." } } ``` **Fel:** - `401 Unauthorized` — ogiltig eller utgången app-JWT - `403 Forbidden` — felaktig signatur eller request_token - `410 Gone` — sessionen har utgått eller redan använts - `409 Conflict` — sessionen redan godkänd/avbruten --- ### 4.3 GET /auth/passwordless/status Webbklienten pollar status på sessionen. **Request:** ```http GET https://api.quixzoom.com/v1/auth/passwordless/status?session_id=pls_2vPqN5L8xT9wR3mK ``` **Response (200 OK) — pending:** ```json { "session_id": "pls_2vPqN5L8xT9wR3mK", "status": "pending", // pending | approved | rejected | expired | cancelled "expires_at": "2026-07-07T07:11:00Z", "remaining_seconds": 780 } ``` **Response (200 OK) — approved:** ```json { "session_id": "pls_2vPqN5L8xT9wR3mK", "status": "approved", "approved_at": "2026-07-07T06:58:00Z", "tokens": { "access_token": "eyJhbG...", "refresh_token": "dGhpcyB...", "token_type": "Bearer", "expires_in": 3600 } } ``` **Fel:** - `404 Not Found` — okänd session_id - `410 Gone` — sessionen utgången --- ### 4.4 POST /auth/passwordless/cancel Avbryter en pågående session (t.ex. användaren stänger webbläsaren). **Request:** ```http POST https://api.quixzoom.com/v1/auth/passwordless/cancel Content-Type: application/json { "session_id": "pls_2vPqN5L8xT9wR3mK" } ``` **Response (200 OK):** ```json { "status": "cancelled" } ``` --- ### 4.5 POST /auth/passwordless/reject Appen aktivt avvisar inloggningsförsöket. **Request:** ```http POST https://api.quixzoom.com/v1/auth/passwordless/reject Content-Type: application/json Authorization: Bearer { "session_id": "pls_2vPqN5L8xT9wR3mK", "request_token": "a1b2c3d4e5f6...", "reason": "user_declined" // user_declined | suspicious | wrong_device } ``` --- ## 5. Databasschema ### 5.1 PostgreSQL — Persistent lagring ```sql -- Användartabell (befintlig, utökad) CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, -- ... befintliga kolumner ... passwordless_enabled BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Registrerade enheter CREATE TABLE user_devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, device_id VARCHAR(64) NOT NULL, -- unikt per app-installation device_type VARCHAR(20) NOT NULL, -- ios | android device_name VARCHAR(100), public_key TEXT NOT NULL, -- Ed25519 public key (PEM) push_token VARCHAR(255), -- FCM/APNs token is_trusted BOOLEAN DEFAULT false, -- krävs för passwordless last_used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), revoked_at TIMESTAMPTZ, -- NULL = aktiv UNIQUE(user_id, device_id) ); -- Passwordless-sessioner (audit + analytics) CREATE TABLE passwordless_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id VARCHAR(32) UNIQUE NOT NULL, -- "pls_" + nanoid user_id UUID REFERENCES users(id), -- NULL tills godkänd status VARCHAR(20) NOT NULL, -- pending | approved | rejected | expired | cancelled -- Initiering client_id VARCHAR(50) NOT NULL, -- web-dashboard | web-checkout redirect_url VARCHAR(500), web_ip_hash VARCHAR(64) NOT NULL, -- SHA256(ip) web_ua_hash VARCHAR(64) NOT NULL, -- SHA256(user_agent) -- Godkännande approving_device_id UUID REFERENCES user_devices(id), approved_at TIMESTAMPTZ, signature VARCHAR(128), -- base64url -- Tidsgränser initiated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- Avvisning/avbrytning rejected_reason VARCHAR(50), rejected_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Index för prestanda CREATE INDEX idx_passwordless_sessions_status ON passwordless_sessions(status, expires_at); CREATE INDEX idx_passwordless_sessions_user ON passwordless_sessions(user_id, created_at DESC); CREATE INDEX idx_user_devices_user ON user_devices(user_id, revoked_at); ``` ### 5.2 Redis — Temporär session-lagring ``` # Session under pågående inloggning (TTL: 15 min) KEY: passwordless:session:{session_id} TYPE: Hash VALUE: { "request_token": "a1b2c3d4...", "status": "pending", "client_id": "web-dashboard", "web_ip_hash": "sha256:...", "web_ua_hash": "sha256:...", "expires_at": "2026-07-07T07:11:00Z" } TTL: 900 # Rate limiting per IP KEY: passwordless:ratelimit:{ip_hash} TYPE: String (counter) TTL: 3600 # Rate limiting per användare (vid approve) KEY: passwordless:ratelimit:user:{user_id} TYPE: String (counter) TTL: 3600 # Blockerade sessioner (efter misslyckade försök) KEY: passwordless:blocked:{session_id} TYPE: String TTL: 300 ``` --- ## 6. Säkerhetsanalys ### 6.1 Hotmodell (STRIDE) | Hot | Beskrivning | Motåtgärd | |-----|-------------|-----------| | **S**poofing | Angripare förfalskar appens signatur | Ed25519-signatur verifieras mot registrerad public key | | **T**ampering | Manipulering av request_token | Token genereras kryptografiskt säkert (32 bytes, CSPRNG) | | **R**epudiation | Användare förnekar inloggning | Audit-logg i PostgreSQL med signatur och tidsstämplar | | **I**nformation Disclosure | Session ID avlyssnas | HTTPS överallt; session_id är engångs; kort TTL | | **D**enial of Service | Överbelastning av /initiate | Rate limiting per IP (10/min) och per användare (5/min) | | **E**levation of Privilege | Session kapas efter godkännande | Token binds till webbklientens IP/UA-hash; CSRF-skydd | ### 6.2 Kryptografiska primitiver | Komponent | Algoritm | Syfte | |-----------|----------|-------| | Signatur | Ed25519 | Appen signerar godkännande med sin privata nyckel | | Request token | 32 bytes CSPRNG | Engångs-token som appen måste presentera | | Hashing | SHA-256 | IP/UA-hashing för session-binding | | Session ID | Nanoid (21 chars, alfabete: A-Za-z0-9_-) | Unik identifierare, URL-safe | ### 6.3 Säkerhetskrav 1. **Transport:** Alla anrop över TLS 1.3 (minst 1.2). Certificate pinning i appen. 2. **Token-återanvändning:** `request_token` kan endast användas en gång. Efter approve/reject/expired är sessionen låst. 3. **Tidsfönster:** Max 15 minuter från initiering till godkännande. Webbklienten pollar max 5 minuter efter expiry. 4. **Device trust:** Endast `is_trusted = true` enheter får godkänna passwordless. Trust etableras vid första inloggningen med lösenord + 2FA. 5. **Session binding:** Access-token som returneras vid approved status är bunden till webbklientens IP/UA-hash. Vid avvikelse krävs omautentisering. 6. **QR-säkerhet:** QR-koden innehåller endast `session_id` och `request_token`, ingen känslig data. Deep link-format: `quixzoom://auth?sid=...&token=...` 7. **Push-notiser:** Vid initiering kan push-notis skickas till appen (kräver opt-in) för snabbare upptäckt. ### 6.4 Riskbedömning | Risk | Sannolikhet | Påverkan | Risknivå | Åtgärd | |------|-------------|----------|----------|--------| | MITM på publikt WiFi | Medel | Hög | **Hög** | TLS 1.3 + certificate pinning | | Stulen app-enhet | Låg | Hög | **Medel** | Biometrisk upplåsning i appen; möjlighet att återkalla enhet | | QR-kod fotograferas | Medel | Medel | **Medel** | Kort TTL; token kan bara användas en gång; session bound to webb-klient | | Brute-force av session_id | Låg | Medel | **Låg** | 21-char nanoid = ~130 bits entropi | | Replay-attack | Låg | Hög | **Låg** | Engångstoken + timestamp i signatur | | DoS på /initiate | Medel | Låg | **Låg** | Rate limiting + CAPTCHA vid upprepad överbelastning | --- ## 7. Implementeringschecklista ### Backend (api.quixzoom.com) - [ ] Nytt `PasswordlessService` i auth-modulen - [ ] Ed25519-nyckelhantering för enheter (`user_devices.public_key`) - [ ] Redis-integration för temporär session-lagring - [ ] Rate limiting middleware - [ ] Audit-loggning till PostgreSQL - [ ] JWT-utgivning med IP/UA-claims för webb-sessioner ### Webb (quixzoom.se) - [ ] QR-kod rendering (t.ex. `qrcode` npm-paket) - [ ] Polling-loop med exponential backoff - [ ] Hantering av alla statusar (approved, rejected, expired, cancelled) - [ ] Cookie-hantering för access/refresh tokens - [ ] Deep link-fallback för användare utan app ### App (quiXzoom) - [ ] QR-skanner integration - [ ] Deep link-hantering (`quixzoom://auth?sid=...&token=...`) - [ ] Biometrisk upplåsning innan signering - [ ] Ed25519-signering av godkännande - [ ] Push-notis-mottagning för passwordless-requests ### DevOps - [ ] Redis-cluster i produktion (HA) - [ ] PostgreSQL-backup av `passwordless_sessions` (audit) - [ ] Monitoring: alert vid onormalt hög andel rejected/Expired - [ ] Log-retention: 90 dagar för säkerhetsrelaterade händelser --- ## 8. Sekvens — Djup länk (utan QR) Alternativt flöde när användaren är på mobilen: ``` 1. Användare besöker quixzoom.se på mobil 2. Klickar "Logga in med app" 3. Webb detekterar mobil → visar knapp "Öppna i quiXzoom-appen" 4. Knapp = deep link: quixzoom://auth?sid=...&token=... 5. App öppnas direkt, hoppar över QR-skanner 6. Användare godkänner → samma approve-flöde 7. App redirectar tillbaka till webbläsaren med auth-kod 8. Webb byter auth-kod mot tokens ``` --- ## 9. Bilaga: JWT-claims för webb-session ```json { "sub": "user_uuid", "iss": "api.quixzoom.com", "aud": "quixzoom.se", "iat": 1720332000, "exp": 1720335600, "scope": "web:read web:write", "auth_method": "passwordless", "session_binding": { "ip_hash": "sha256:...", "ua_hash": "sha256:..." }, "device_id": "dev_abc123", "session_id": "pls_2vPqN5L8xT9wR3mK" } ``` --- *Dokumentet är levande — uppdatera vid implementation och säkerhetsgranskning.*