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

325 lines
11 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/Silo-Server/silo-server/internal/access"
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"`
ClientPurpose string `json:"client_purpose,omitempty"`
Temporary bool `json:"temporary,omitempty"`
}
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"`
ClientPurpose string `json:"client_purpose"`
Temporary bool `json:"temporary"`
}
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"`
ClientPurpose string `json:"client_purpose,omitempty"`
Temporary bool `json:"temporary,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"`
ProfileID string `json:"profile_id,omitempty"`
ProfileToken string `json:"profile_token,omitempty"`
Temporary bool `json:"temporary,omitempty"`
SessionExpiresAt string `json:"session_expires_at,omitempty"`
}
type deviceDecisionRequest struct {
Token string `json:"token,omitempty"`
Code string `json:"code,omitempty"`
}
type deviceLoginCapabilityResponse struct {
RemotePlaybackHandoff bool `json:"remote_playback_handoff"`
ProtocolVersions []int `json:"protocol_versions"`
}
func (h *AuthHandler) HandleDeviceCapability(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, deviceLoginCapabilityResponse{
RemotePlaybackHandoff: true,
ProtocolVersions: []int{2},
})
}
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),
ClientPurpose: req.ClientPurpose,
Temporary: req.Temporary,
})
if err != nil {
if errors.Is(err, auth.ErrDeviceLoginBadPurpose) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid device login purpose")
return
}
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,
ClientPurpose: result.ClientPurpose,
Temporary: result.Temporary,
})
}
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,
ClientPurpose: info.ClientPurpose,
Temporary: info.Temporary,
}
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
if result.Temporary {
resp.ProfileID = result.ProfileID
resp.ProfileToken = result.ProfileToken
resp.Temporary = true
if !result.SessionExpiresAt.IsZero() {
resp.SessionExpiresAt = result.SessionExpiresAt.UTC().Format(time.RFC3339)
}
}
}
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) HandleDeviceApproveHandoff(w http.ResponseWriter, r *http.Request) {
if h.device == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Device login is not configured")
return
}
scope, ok := access.GetScope(r.Context())
if !ok || scope.UserID == 0 || scope.ProfileID == "" {
writeError(w, http.StatusForbidden, "profile_required", "An active verified profile is 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.ApproveRemotePlayback(r.Context(), auth.DeviceLoginLookupInput{
BrowserCode: req.Token,
UserCode: req.Code,
}, scope.UserID, scope.ProfileID)
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")
case errors.Is(err, auth.ErrDeviceLoginPurpose):
writeError(w, http.StatusConflict, "purpose_mismatch", "Device login purpose does not match this approval route")
case errors.Is(err, auth.ErrDeviceLoginConflict):
writeError(w, http.StatusConflict, "approval_conflict", "Device login request was approved by another identity")
case errors.Is(err, auth.ErrDeviceLoginNoProfile):
writeError(w, http.StatusNotFound, "profile_not_found", "Profile not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Device login request failed")
}
}