Files
silo-server/internal/api/handlers/auth_device.go
T

248 lines
7.9 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/clientip"
)
type deviceStartRequest struct {
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
}
type deviceStartResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
MatchCode string `json:"match_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresAt string `json:"expires_at"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
}
type deviceLookupResponse struct {
Status string `json:"status"`
UserCode string `json:"user_code,omitempty"`
MatchCode string `json:"match_code,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DevicePlatform string `json:"device_platform,omitempty"`
IPAddressHint string `json:"ip_address_hint,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type devicePollRequest struct {
DeviceCode string `json:"device_code"`
}
type devicePollResponse struct {
Status string `json:"status"`
PollAfter int `json:"poll_after"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
User *userResponse `json:"user,omitempty"`
}
type deviceDecisionRequest struct {
Token string `json:"token,omitempty"`
Code string `json:"code,omitempty"`
}
func (h *AuthHandler) HandleDeviceStart(w http.ResponseWriter, r *http.Request) {
if h.device == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured")
return
}
var req deviceStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
result, err := h.device.Start(r.Context(), auth.DeviceLoginStartInput{
DeviceName: req.DeviceName,
DevicePlatform: req.DevicePlatform,
IPAddress: clientip.FromContext(r.Context()),
UserAgent: r.UserAgent(),
BaseURL: requestBaseURL(r),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start device login")
return
}
writeJSON(w, http.StatusCreated, deviceStartResponse{
DeviceCode: result.DeviceCode,
UserCode: result.UserCode,
MatchCode: result.MatchCode,
VerificationURI: result.VerificationURI,
VerificationURIComplete: result.VerificationURIComplete,
ExpiresAt: result.ExpiresAt.UTC().Format(time.RFC3339),
ExpiresIn: result.ExpiresIn,
Interval: result.Interval,
DeviceName: result.DeviceName,
DevicePlatform: result.DevicePlatform,
})
}
func (h *AuthHandler) HandleDeviceLookup(w http.ResponseWriter, r *http.Request) {
if h.device == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured")
return
}
info, err := h.device.Lookup(r.Context(), auth.DeviceLoginLookupInput{
BrowserCode: r.URL.Query().Get("token"),
UserCode: r.URL.Query().Get("code"),
})
if err != nil {
if errors.Is(err, auth.ErrDeviceLoginNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Device login request not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device login")
return
}
response := deviceLookupResponse{
Status: info.Status,
UserCode: info.UserCode,
MatchCode: info.MatchCode,
DeviceName: info.DeviceName,
DevicePlatform: info.DevicePlatform,
IPAddressHint: info.IPAddressHint,
}
if !info.ExpiresAt.IsZero() {
response.ExpiresAt = info.ExpiresAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, response)
}
func (h *AuthHandler) HandleDevicePoll(w http.ResponseWriter, r *http.Request) {
if h.device == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured")
return
}
var req devicePollRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.DeviceCode == "" {
writeError(w, http.StatusBadRequest, "bad_request", "device_code is required")
return
}
result, err := h.device.Poll(r.Context(), req.DeviceCode)
if err != nil {
if errors.Is(err, auth.ErrDeviceLoginNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Device login request not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to poll device login")
return
}
resp := devicePollResponse{
Status: result.Status,
PollAfter: result.PollAfter,
}
if result.TokenPair != nil && result.User != nil {
resp.AccessToken = result.TokenPair.AccessToken
resp.RefreshToken = result.TokenPair.RefreshToken
resp.ExpiresIn = result.TokenPair.ExpiresIn
user := buildUserResponse(result.User, nil, nil)
resp.User = &user
}
writeJSON(w, http.StatusOK, resp)
}
func (h *AuthHandler) HandleDeviceApprove(w http.ResponseWriter, r *http.Request) {
if h.device == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var req deviceDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
err := h.device.Approve(r.Context(), auth.DeviceLoginLookupInput{
BrowserCode: req.Token,
UserCode: req.Code,
}, userID)
if err != nil {
h.writeDeviceDecisionError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "approved"})
}
func (h *AuthHandler) HandleDeviceDeny(w http.ResponseWriter, r *http.Request) {
if h.device == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var req deviceDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
err := h.device.Deny(r.Context(), auth.DeviceLoginLookupInput{
BrowserCode: req.Token,
UserCode: req.Code,
})
if err != nil {
h.writeDeviceDecisionError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "denied"})
}
func (h *AuthHandler) writeDeviceDecisionError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, auth.ErrDeviceLoginNotFound):
writeError(w, http.StatusNotFound, "not_found", "Device login request not found")
case errors.Is(err, auth.ErrDeviceLoginExpired):
writeError(w, http.StatusGone, "expired", "Device login request has expired")
case errors.Is(err, auth.ErrDeviceLoginConsumed):
writeError(w, http.StatusConflict, "consumed", "Device login request has already been used")
case errors.Is(err, auth.ErrDeviceLoginDenied):
writeError(w, http.StatusConflict, "denied", "Device login request has already been denied")
case errors.Is(err, auth.ErrUserDisabled):
writeError(w, http.StatusForbidden, "user_disabled", "User account is disabled")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Device login request failed")
}
}