package httpapi import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "log/slog" "net" "net/http" "net/url" "strings" "time" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // Scan-login (QR), AUTH.md §4. A new device shows a QR; an already-logged-in // phone scans and approves it into the user's account as a restricted guest // session. Three secrets are kept apart so the QR — which can be photographed — // only lets someone *request* approval, never collect the session: // // - request_id : public handle, in the QR. // - approval_code : in the QR; binds the authed approver to this request. // - poll_secret : returned ONCE to the new device, never in the QR; only its // SHA-256 is stored. The session is delivered solely on the connection that // proves the plaintext, so a QR photographer (no poll_secret, not logged in) // fails twice. // // The session row is created when the new device collects it (handleQRStatus), // not at approve time, so the cookie secret is generated on and returned to the // new device's own TLS connection and never travels through the phone. // qrStatusPollWindow caps how long one status long-poll blocks before returning // "pending" for the client to re-poll. var (not const) so tests can shrink it. var qrStatusPollWindow = 25 * time.Second type qrStartReq struct { DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` } type qrStartResp struct { RequestID string `json:"request_id"` PollSecret string `json:"poll_secret"` QRPayload string `json:"qr_payload"` ExpiresAt int64 `json:"expires_at"` } type qrStatusResp struct { Status string `json:"status"` // pending | approved | denied | expired AccessToken string `json:"access_token,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` User *userResp `json:"user,omitempty"` DeviceName string `json:"device_name,omitempty"` } type qrRequestResp struct { DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` RequestIP string `json:"request_ip"` ExpiresAt int64 `json:"expires_at"` Status string `json:"status"` // StepUp tells the approver UI a fresh re-auth is required before approving. StepUp bool `json:"step_up"` } type qrApproveReq struct { RequestID string `json:"request_id"` ApprovalCode string `json:"approval_code"` Scope string `json:"scope"` // full | guest (this milestone always guest) Persist string `json:"persist"` // once | persist // Step-up (when CDROP_STEP_UP_ENABLED): a fresh prompt=login PKCE code+verifier // the backend exchanges and checks for a recent auth_time (AUTH.md §6). StepUpCode string `json:"step_up_code"` StepUpVerifier string `json:"step_up_verifier"` } type qrDenyReq struct { RequestID string `json:"request_id"` ApprovalCode string `json:"approval_code"` } // handleQRStart (public, rate-limited) opens a scan-login request for a new // device and returns the QR payload plus the device-private poll secret. func (s *Server) handleQRStart(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } var req qrStartReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } deviceName := jwtauth.SanitizeDeviceName(req.DeviceName) if deviceName == "" { deviceName = "New device" } requestID, err1 := randB64(16) approvalCode, err2 := randB64(9) pollSecret, err3 := randB64(32) if err := errors.Join(err1, err2, err3); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } now := time.Now() expires := now.Add(time.Duration(s.cfg.QRRequestTTLSeconds) * time.Second) if err := s.queries.CreateLoginRequest(r.Context(), db.CreateLoginRequestParams{ ID: requestID, PollSecret: sessionID(pollSecret), // store only the hash ApprovalCode: approvalCode, NewDeviceName: deviceName, NewDeviceType: qrDeviceType(req.DeviceType), UserAgent: truncate(r.UserAgent(), 256), RequestIp: clientIP(r), CreatedAt: now.Unix(), ExpiresAt: expires.Unix(), }); err != nil { slog.Error("create login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } writeJSON(w, http.StatusOK, qrStartResp{ RequestID: requestID, PollSecret: pollSecret, QRPayload: s.qrPayload(r, requestID, approvalCode), ExpiresAt: expires.Unix(), }) } // handleQRStatus (public, rate-limited) is the new device's long-poll. It proves // the poll_secret, then reports pending until the request is approved/denied/ // expired. On approval it creates the session, sets the cookie on THIS response, // mints the first access token, and marks the request consumed (single use). func (s *Server) handleQRStatus(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } requestID := r.URL.Query().Get("request_id") pollSecret := r.Header.Get("X-Poll-Secret") if requestID == "" || pollSecret == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or poll secret"}) return } pollHash := sessionID(pollSecret) deadline := time.Now().Add(qrStatusPollWindow) for { req, err := s.queries.GetLoginRequest(r.Context(), requestID) if err != nil { // Unknown / reaped → treat as expired without leaking existence. writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return } if subtle.ConstantTimeCompare([]byte(req.PollSecret), []byte(pollHash)) != 1 { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad poll secret"}) return } if req.ExpiresAt < time.Now().Unix() { writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return } switch req.Status { case "approved": s.collectQRSession(w, r, req) return case "denied": writeJSON(w, http.StatusOK, qrStatusResp{Status: "denied"}) return case "consumed": writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return default: // pending if time.Now().After(deadline) { writeJSON(w, http.StatusOK, qrStatusResp{Status: "pending"}) return } select { case <-r.Context().Done(): return case <-time.After(time.Second): } } } } // collectQRSession turns an approved request into a live session for the new // device. ConsumeLoginRequest is the single-use guard: only the poll that flips // approved→consumed proceeds, so a duplicated/raced poll can't mint a second // session. func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db.LoginRequest) { n, err := s.queries.ConsumeLoginRequest(r.Context(), req.ID) if err != nil { slog.Error("consume login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { // Lost the race to another poll; the winner already has the session. writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return } raw, id, err := newSessionToken() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } scope := req.GrantScope if scope != "full" { scope = "guest" } // Persistent borrows slide their window (kind self); one-time borrows are kind // guest with a fixed short expiry that refresh never extends (AUTH.md §3.1). now := time.Now() kind := "guest" exp := now.Add(time.Duration(s.cfg.QRGuestTTLSeconds) * time.Second) if req.GrantPersist == "persist" { kind = "self" exp = now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour) } if err := s.queries.CreateSelfSession(r.Context(), db.CreateSelfSessionParams{ ID: id, UserID: req.ApproverUserID, DeviceName: req.NewDeviceName, UserAgent: req.UserAgent, Kind: kind, Scope: scope, GrantedBy: "qr", CreatedAt: now.Unix(), LastUsedAt: now.Unix(), ExpiresAt: exp.Unix(), }); err != nil { slog.Error("create self session failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } token, expiresIn, err := s.mintSessionToken(req.ApproverUserID, id, scope) if err != nil { slog.Error("mint session token failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"}) return } setSessionCookie(w, raw) user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID} if acct, err := s.queries.GetAccount(r.Context(), req.ApproverUserID); err == nil { if acct.DisplayName != "" { user.Name = acct.DisplayName } user.Avatar = acct.AvatarUrl } writeJSON(w, http.StatusOK, qrStatusResp{ Status: "approved", AccessToken: token, ExpiresIn: expiresIn, User: &user, DeviceName: req.NewDeviceName, }) } // handleQRRequest (full session) lets the approver see what they are about to // authorise. The approval_code (from the QR) gates the lookup, so only someone // who scanned the code can read the request's device info. func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } req, ok := s.lookupQRRequest(w, r, r.URL.Query().Get("request_id"), r.URL.Query().Get("code")) if !ok { return } writeJSON(w, http.StatusOK, qrRequestResp{ DeviceName: req.NewDeviceName, DeviceType: req.NewDeviceType, RequestIP: req.RequestIp, ExpiresAt: req.ExpiresAt, Status: req.Status, StepUp: s.cfg.StepUpEnabled, }) } // handleQRApprove (full session) binds the request to the caller and records the // granted scope/persistence. The new device's poll then collects the session. func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } var body qrApproveReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode) if !ok { return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) // Step-up: approving a new device into your account is sensitive. When enabled, // require a fresh prompt=login re-auth (the provider's 2FA, if configured, is // enforced during that exchange) within the freshness window (AUTH.md §6). if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) return } scope := "guest" // this milestone grants restricted guest sessions only if body.Scope == "full" { scope = "full" } persist := "once" if body.Persist == "persist" { persist = "persist" } now := time.Now() n, err := s.queries.ApproveLoginRequest(r.Context(), db.ApproveLoginRequestParams{ ApproverUserID: claims.UserID, GrantScope: scope, GrantPersist: persist, ApprovedAt: ptrInt64(now.Unix()), ID: req.ID, }) if err != nil { slog.Error("approve login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { // Raced to denied/consumed/expired between lookup and update. writeJSON(w, http.StatusConflict, map[string]string{"error": "request no longer pending"}) return } w.WriteHeader(http.StatusNoContent) } // handleQRDeny (full session) rejects a pending request. func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } var body qrDenyReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode) if !ok { return } if _, err := s.queries.DenyLoginRequest(r.Context(), req.ID); err != nil { slog.Error("deny login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } w.WriteHeader(http.StatusNoContent) } // verifyStepUp exchanges a fresh prompt=login authorization code and confirms it // proves a recent interactive re-authentication by the same user: the id_token's // signature validates (JWKS), its sub matches the caller, and its auth_time is // within StepUpMaxAgeSeconds. Any failure → false (the caller answers 403). The // code is single-use at the IdP, so it can't be replayed (AUTH.md §6). func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bool { if code == "" || verifier == "" || s.cfg.OIDCTokenURL == "" { return false } form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("code_verifier", verifier) form.Set("client_id", s.cfg.OIDCClientID) form.Set("redirect_uri", s.cfg.OIDCRedirectURI) if s.cfg.OIDCClientSecret != "" { form.Set("client_secret", s.cfg.OIDCClientSecret) } tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) if err != nil { slog.Warn("step-up exchange failed", "err", err) return false } if tr.IDToken == "" { return false } sub, authTime, err := s.auth.VerifyIDToken(r.Context(), tr.IDToken) if err != nil { slog.Warn("step-up id_token invalid", "err", err) return false } if sub != userID { return false } maxAge := int64(s.cfg.StepUpMaxAgeSeconds) if maxAge <= 0 { maxAge = 300 } return time.Now().Unix()-authTime <= maxAge } // lookupQRRequest fetches a request and verifies the approval_code in constant // time. It writes the error response itself and returns ok=false on any failure // (missing args, unknown request, bad code, expired), so callers just early-return. func (s *Server) lookupQRRequest(w http.ResponseWriter, r *http.Request, requestID, code string) (db.LoginRequest, bool) { if requestID == "" || code == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or code"}) return db.LoginRequest{}, false } req, err := s.queries.GetLoginRequest(r.Context(), requestID) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "request not found"}) return db.LoginRequest{}, false } if subtle.ConstantTimeCompare([]byte(req.ApprovalCode), []byte(code)) != 1 { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad approval code"}) return db.LoginRequest{}, false } if req.ExpiresAt < time.Now().Unix() { writeJSON(w, http.StatusGone, map[string]string{"error": "request expired"}) return db.LoginRequest{}, false } return req, true } // qrPayload builds the URL encoded into the QR: the approver opens it on their // phone. Prefers the configured site origin; falls back to the request's own // scheme/host for dev. func (s *Server) qrPayload(r *http.Request, requestID, approvalCode string) string { origin := s.siteOrigin if origin == "" { scheme := "https" if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { scheme = "http" } origin = scheme + "://" + r.Host } return origin + "/link?r=" + requestID + "&c=" + approvalCode } func qrDeviceType(raw string) string { switch t := strings.ToLower(strings.TrimSpace(raw)); t { case "macos", "windows", "linux", "ios", "browser": return t default: return "browser" } } func clientIP(r *http.Request) string { if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host } return r.RemoteAddr } func ptrInt64(v int64) *int64 { return &v } func randB64(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil }