Add remote playback identity handoff (#360)
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
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 {
|
||||
@@ -28,6 +31,8 @@ type deviceStartResponse struct {
|
||||
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 {
|
||||
@@ -38,6 +43,8 @@ type deviceLookupResponse struct {
|
||||
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 {
|
||||
@@ -45,12 +52,16 @@ type devicePollRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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 {
|
||||
@@ -58,6 +69,18 @@ type deviceDecisionRequest struct {
|
||||
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")
|
||||
@@ -76,8 +99,14 @@ func (h *AuthHandler) HandleDeviceStart(w http.ResponseWriter, r *http.Request)
|
||||
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
|
||||
}
|
||||
@@ -93,6 +122,8 @@ func (h *AuthHandler) HandleDeviceStart(w http.ResponseWriter, r *http.Request)
|
||||
Interval: result.Interval,
|
||||
DeviceName: result.DeviceName,
|
||||
DevicePlatform: result.DevicePlatform,
|
||||
ClientPurpose: result.ClientPurpose,
|
||||
Temporary: result.Temporary,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +153,8 @@ func (h *AuthHandler) HandleDeviceLookup(w http.ResponseWriter, r *http.Request)
|
||||
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)
|
||||
@@ -165,6 +198,14 @@ func (h *AuthHandler) HandleDevicePoll(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
@@ -199,6 +240,36 @@ func (h *AuthHandler) HandleDeviceApprove(w http.ResponseWriter, r *http.Request
|
||||
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")
|
||||
@@ -241,6 +312,12 @@ func (h *AuthHandler) writeDeviceDecisionError(w http.ResponseWriter, err error)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceLoginCapabilityAdvertisesRemotePlaybackHandoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/device/capability", nil)
|
||||
new(AuthHandler).HandleDeviceCapability(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var response deviceLoginCapabilityResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode capability response: %v", err)
|
||||
}
|
||||
if !response.RemotePlaybackHandoff {
|
||||
t.Fatal("remote_playback_handoff = false, want true")
|
||||
}
|
||||
if len(response.ProtocolVersions) != 1 || response.ProtocolVersions[0] != 2 {
|
||||
t.Fatalf("protocol_versions = %v, want [2]", response.ProtocolVersions)
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -352,10 +352,17 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
for _, registration := range deps.AuthProviders {
|
||||
authService.RegisterProvider(registration.Info, registration.Provider)
|
||||
}
|
||||
deviceLoginService = auth.NewDeviceLoginService(deps.DB, userRepo, jwtService, sessionRepo)
|
||||
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
|
||||
deviceLoginService = auth.NewDeviceLoginService(
|
||||
deps.DB,
|
||||
userRepo,
|
||||
jwtService,
|
||||
sessionRepo,
|
||||
deps.UserStoreProvider,
|
||||
profileTokenService,
|
||||
)
|
||||
authHandler = handlers.NewAuthHandler(authService, jwtService, deviceLoginService)
|
||||
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo)
|
||||
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
|
||||
if deps.UserStoreProvider != nil {
|
||||
if deps.PolicySystem != nil {
|
||||
viewerResolver = policy.NewViewerResolver(userRepo, deps.UserStoreProvider, profileTokenService, deps.PolicySystem.PDP(), accessGroupStore)
|
||||
@@ -1637,6 +1644,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
authHandler.SetOAuthRoutesAvailable(oauthHandler != nil)
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/device/capability", authHandler.HandleDeviceCapability)
|
||||
if deps.RateLimitMW != nil {
|
||||
r.With(deps.RateLimitMW.AuthEndpointHandler("login")).Post("/login", authHandler.HandleLogin)
|
||||
r.With(deps.RateLimitMW.AuthEndpointHandler("setup")).Post("/setup", authHandler.HandleSetup)
|
||||
@@ -1682,6 +1690,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Post("/device/approve", authHandler.HandleDeviceApprove)
|
||||
r.Post("/device/deny", authHandler.HandleDeviceDeny)
|
||||
})
|
||||
if viewerAccessMiddleware != nil {
|
||||
r.With(
|
||||
authMiddleware.RequireAuth,
|
||||
viewerAccessMiddleware.RequireViewerAccess,
|
||||
).Post("/device/approve-handoff", authHandler.HandleDeviceApproveHandoff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+290
-41
@@ -15,7 +15,9 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -24,6 +26,10 @@ var (
|
||||
ErrDeviceLoginDenied = errors.New("device login request denied")
|
||||
ErrDeviceLoginConsumed = errors.New("device login request already consumed")
|
||||
ErrDeviceLoginUnapproved = errors.New("device login request not approved")
|
||||
ErrDeviceLoginBadPurpose = errors.New("invalid device login purpose")
|
||||
ErrDeviceLoginPurpose = errors.New("device login purpose mismatch")
|
||||
ErrDeviceLoginConflict = errors.New("device login already approved by another identity")
|
||||
ErrDeviceLoginNoProfile = errors.New("device login profile not found")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -31,15 +37,18 @@ const (
|
||||
DeviceLoginStatusApproved = "approved"
|
||||
DeviceLoginStatusDenied = "denied"
|
||||
DeviceLoginStatusConsumed = "consumed"
|
||||
DeviceLoginPurposeLogin = "device_login"
|
||||
DeviceLoginPurposeRemote = "remote_playback"
|
||||
|
||||
deviceLoginTTL = 10 * time.Minute
|
||||
deviceLoginPollInterval = 3 * time.Second
|
||||
deviceCodeBytes = 32
|
||||
browserCodeBytes = 32
|
||||
userCodeLength = 8
|
||||
maxDeviceNameLen = 120
|
||||
maxDevicePlatformLen = 80
|
||||
maxUserAgentLen = 256
|
||||
deviceLoginTTL = 10 * time.Minute
|
||||
deviceLoginPollInterval = 3 * time.Second
|
||||
remotePlaybackSessionTTL = 24 * time.Hour
|
||||
deviceCodeBytes = 32
|
||||
browserCodeBytes = 32
|
||||
userCodeLength = 8
|
||||
maxDeviceNameLen = 120
|
||||
maxDevicePlatformLen = 80
|
||||
maxUserAgentLen = 256
|
||||
)
|
||||
|
||||
var deviceCodeAlphabet = []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
|
||||
@@ -50,6 +59,8 @@ type DeviceLoginStartInput struct {
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
BaseURL string
|
||||
ClientPurpose string
|
||||
Temporary bool
|
||||
}
|
||||
|
||||
type DeviceLoginStartResult struct {
|
||||
@@ -63,6 +74,8 @@ type DeviceLoginStartResult struct {
|
||||
Interval int
|
||||
DeviceName string
|
||||
DevicePlatform string
|
||||
ClientPurpose string
|
||||
Temporary bool
|
||||
}
|
||||
|
||||
type DeviceLoginLookupInput struct {
|
||||
@@ -78,13 +91,19 @@ type DeviceLoginInfo struct {
|
||||
DevicePlatform string
|
||||
IPAddressHint string
|
||||
ExpiresAt time.Time
|
||||
ClientPurpose string
|
||||
Temporary bool
|
||||
}
|
||||
|
||||
type DeviceLoginPollResult struct {
|
||||
Status string
|
||||
PollAfter int
|
||||
TokenPair *TokenPair
|
||||
User *models.User
|
||||
Status string
|
||||
PollAfter int
|
||||
TokenPair *TokenPair
|
||||
User *models.User
|
||||
ProfileID string
|
||||
ProfileToken string
|
||||
Temporary bool
|
||||
SessionExpiresAt time.Time
|
||||
}
|
||||
|
||||
type deviceLoginRecord struct {
|
||||
@@ -99,7 +118,10 @@ type deviceLoginRecord struct {
|
||||
RequestedUserAgent string
|
||||
Status string
|
||||
ApprovedByUserID *int
|
||||
ApprovedProfileID *string
|
||||
AuthSessionID *string
|
||||
ClientPurpose string
|
||||
Temporary bool
|
||||
ExpiresAt time.Time
|
||||
ApprovedAt *time.Time
|
||||
DeniedAt *time.Time
|
||||
@@ -113,12 +135,15 @@ type DeviceLoginService struct {
|
||||
users *UserRepository
|
||||
jwt *JWTService
|
||||
sessions *SessionRepository
|
||||
stores userstore.UserStoreProvider
|
||||
profiles *access.ProfileTokenService
|
||||
}
|
||||
|
||||
const deviceLoginSelectColumns = `
|
||||
SELECT id, device_code_hash, browser_code_hash, user_code_hash, match_code,
|
||||
device_name, device_platform, host(ip_address) AS ip_address, requested_user_agent,
|
||||
status, approved_by_user_id, auth_session_id, expires_at, approved_at,
|
||||
status, approved_by_user_id, approved_profile_id, auth_session_id,
|
||||
client_purpose, temporary, expires_at, approved_at,
|
||||
denied_at, consumed_at, created_at, updated_at
|
||||
FROM device_login_requests
|
||||
`
|
||||
@@ -128,6 +153,8 @@ func NewDeviceLoginService(
|
||||
users *UserRepository,
|
||||
jwt *JWTService,
|
||||
sessions *SessionRepository,
|
||||
stores userstore.UserStoreProvider,
|
||||
profiles *access.ProfileTokenService,
|
||||
) *DeviceLoginService {
|
||||
if pool == nil || users == nil || jwt == nil || sessions == nil {
|
||||
return nil
|
||||
@@ -137,10 +164,16 @@ func NewDeviceLoginService(
|
||||
users: users,
|
||||
jwt: jwt,
|
||||
sessions: sessions,
|
||||
stores: stores,
|
||||
profiles: profiles,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeviceLoginService) Start(ctx context.Context, input DeviceLoginStartInput) (*DeviceLoginStartResult, error) {
|
||||
purpose, temporary, err := normalizeDeviceLoginPurpose(input.ClientPurpose, input.Temporary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceCode, err := randomToken(deviceCodeBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate device code: %w", err)
|
||||
@@ -170,6 +203,8 @@ func (s *DeviceLoginService) Start(ctx context.Context, input DeviceLoginStartIn
|
||||
IPAddress: trimDeviceField(input.IPAddress, 64),
|
||||
RequestedUserAgent: trimDeviceField(input.UserAgent, maxUserAgentLen),
|
||||
Status: DeviceLoginStatusPending,
|
||||
ClientPurpose: purpose,
|
||||
Temporary: temporary,
|
||||
ExpiresAt: now.Add(deviceLoginTTL),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -192,6 +227,8 @@ func (s *DeviceLoginService) Start(ctx context.Context, input DeviceLoginStartIn
|
||||
Interval: int(deviceLoginPollInterval.Seconds()),
|
||||
DeviceName: record.DeviceName,
|
||||
DevicePlatform: record.DevicePlatform,
|
||||
ClientPurpose: record.ClientPurpose,
|
||||
Temporary: record.Temporary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -210,6 +247,8 @@ func (s *DeviceLoginService) Lookup(ctx context.Context, input DeviceLoginLookup
|
||||
DevicePlatform: record.DevicePlatform,
|
||||
IPAddressHint: maskDeviceIPAddress(record.IPAddress),
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
ClientPurpose: record.ClientPurpose,
|
||||
Temporary: record.Temporary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -221,6 +260,8 @@ func (s *DeviceLoginService) Lookup(ctx context.Context, input DeviceLoginLookup
|
||||
DevicePlatform: record.DevicePlatform,
|
||||
IPAddressHint: maskDeviceIPAddress(record.IPAddress),
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
ClientPurpose: record.ClientPurpose,
|
||||
Temporary: record.Temporary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -229,28 +270,23 @@ func (s *DeviceLoginService) Approve(ctx context.Context, input DeviceLoginLooku
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isDeviceLoginExpired(record) {
|
||||
return ErrDeviceLoginExpired
|
||||
if record.ClientPurpose != DeviceLoginPurposeLogin || record.Temporary {
|
||||
return ErrDeviceLoginPurpose
|
||||
}
|
||||
if record.Status == DeviceLoginStatusConsumed {
|
||||
return ErrDeviceLoginConsumed
|
||||
}
|
||||
if record.Status == DeviceLoginStatusDenied {
|
||||
return ErrDeviceLoginDenied
|
||||
if err := validateDeviceLoginDecision(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, approverUserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load approving user: %w", err)
|
||||
}
|
||||
if !user.Enabled {
|
||||
return ErrUserDisabled
|
||||
if err := s.validateApprovingUser(ctx, approverUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.Status == DeviceLoginStatusApproved && record.ApprovedByUserID != nil && *record.ApprovedByUserID == approverUserID {
|
||||
return nil
|
||||
}
|
||||
if record.Status == DeviceLoginStatusApproved {
|
||||
return ErrDeviceLoginConflict
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
@@ -261,7 +297,9 @@ func (s *DeviceLoginService) Approve(ctx context.Context, input DeviceLoginLooku
|
||||
denied_at = NULL,
|
||||
updated_at = $4
|
||||
WHERE id = $1
|
||||
AND status IN ($5, $6)
|
||||
AND status = $5
|
||||
AND client_purpose = $6
|
||||
AND temporary = FALSE
|
||||
AND expires_at > NOW()
|
||||
`,
|
||||
record.ID,
|
||||
@@ -269,13 +307,79 @@ func (s *DeviceLoginService) Approve(ctx context.Context, input DeviceLoginLooku
|
||||
approverUserID,
|
||||
now,
|
||||
DeviceLoginStatusPending,
|
||||
DeviceLoginStatusApproved,
|
||||
DeviceLoginPurposeLogin,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("approve device login: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return s.reloadDecisionState(ctx, record.ID, approverUserID, true)
|
||||
return s.reloadApprovalState(ctx, record.ID, approverUserID, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveRemotePlayback approves a temporary device-login request for the
|
||||
// caller's already-resolved viewer profile. The handler must obtain profileID
|
||||
// from RequireViewerAccess rather than accepting an untrusted body field.
|
||||
func (s *DeviceLoginService) ApproveRemotePlayback(
|
||||
ctx context.Context,
|
||||
input DeviceLoginLookupInput,
|
||||
approverUserID int,
|
||||
profileID string,
|
||||
) error {
|
||||
profileID = strings.TrimSpace(profileID)
|
||||
if profileID == "" {
|
||||
return ErrDeviceLoginNoProfile
|
||||
}
|
||||
record, _, err := s.findByLookup(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ClientPurpose != DeviceLoginPurposeRemote || !record.Temporary {
|
||||
return ErrDeviceLoginPurpose
|
||||
}
|
||||
if err := validateDeviceLoginDecision(record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateApprovingProfile(ctx, approverUserID, profileID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.Status == DeviceLoginStatusApproved {
|
||||
if sameApprovedIdentity(record, approverUserID, profileID) {
|
||||
return nil
|
||||
}
|
||||
return ErrDeviceLoginConflict
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE device_login_requests
|
||||
SET status = $2,
|
||||
approved_by_user_id = $3,
|
||||
approved_profile_id = $4,
|
||||
approved_at = $5,
|
||||
denied_at = NULL,
|
||||
updated_at = $5
|
||||
WHERE id = $1
|
||||
AND status = $6
|
||||
AND client_purpose = $7
|
||||
AND temporary = TRUE
|
||||
AND expires_at > NOW()
|
||||
`,
|
||||
record.ID,
|
||||
DeviceLoginStatusApproved,
|
||||
approverUserID,
|
||||
profileID,
|
||||
now,
|
||||
DeviceLoginStatusPending,
|
||||
DeviceLoginPurposeRemote,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("approve remote playback login: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return s.reloadApprovalState(ctx, record.ID, approverUserID, profileID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -300,6 +404,7 @@ func (s *DeviceLoginService) Deny(ctx context.Context, input DeviceLoginLookupIn
|
||||
UPDATE device_login_requests
|
||||
SET status = $2,
|
||||
approved_by_user_id = NULL,
|
||||
approved_profile_id = NULL,
|
||||
approved_at = NULL,
|
||||
denied_at = $3,
|
||||
updated_at = $3
|
||||
@@ -317,7 +422,7 @@ func (s *DeviceLoginService) Deny(ctx context.Context, input DeviceLoginLookupIn
|
||||
return fmt.Errorf("deny device login: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return s.reloadDecisionState(ctx, record.ID, 0, false)
|
||||
return s.reloadDenyState(ctx, record.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -374,14 +479,32 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
|
||||
return nil, ErrUserDisabled
|
||||
}
|
||||
|
||||
profileID := ""
|
||||
if record.ClientPurpose == DeviceLoginPurposeRemote {
|
||||
if !record.Temporary || record.ApprovedProfileID == nil || strings.TrimSpace(*record.ApprovedProfileID) == "" {
|
||||
return nil, ErrDeviceLoginNoProfile
|
||||
}
|
||||
profileID = strings.TrimSpace(*record.ApprovedProfileID)
|
||||
if err := s.validateProfileOwnership(ctx, user.ID, profileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
sessionID := uuid.New().String()
|
||||
sessionExpiresAt := now.Add(s.jwt.RefreshExpiry())
|
||||
if record.Temporary {
|
||||
remoteExpiry := now.Add(remotePlaybackSessionTTL)
|
||||
if remoteExpiry.Before(sessionExpiresAt) {
|
||||
sessionExpiresAt = remoteExpiry
|
||||
}
|
||||
}
|
||||
session := models.AuthSession{
|
||||
ID: sessionID,
|
||||
UserID: user.ID,
|
||||
DeviceName: record.DeviceName,
|
||||
IPAddress: record.IPAddress,
|
||||
ExpiresAt: now.Add(s.jwt.RefreshExpiry()),
|
||||
ExpiresAt: sessionExpiresAt,
|
||||
}
|
||||
if err := s.sessions.createWithQuerier(ctx, tx, session); err != nil {
|
||||
return nil, err
|
||||
@@ -396,6 +519,22 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profileToken := ""
|
||||
if record.ClientPurpose == DeviceLoginPurposeRemote {
|
||||
if s.profiles == nil {
|
||||
return nil, errors.New("profile token service is not configured")
|
||||
}
|
||||
profileToken, _, err = s.profiles.Mint(access.ProfileTokenClaims{
|
||||
UserID: user.ID,
|
||||
SessionID: sessionID,
|
||||
ProfileID: profileID,
|
||||
PolicyRevision: user.AccessPolicyRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mint remote playback profile token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_login_requests
|
||||
SET status = $2,
|
||||
@@ -417,10 +556,14 @@ func (s *DeviceLoginService) Poll(ctx context.Context, deviceCode string) (*Devi
|
||||
}
|
||||
|
||||
return &DeviceLoginPollResult{
|
||||
Status: "approved",
|
||||
PollAfter: int(deviceLoginPollInterval.Seconds()),
|
||||
TokenPair: pair,
|
||||
User: user,
|
||||
Status: "approved",
|
||||
PollAfter: int(deviceLoginPollInterval.Seconds()),
|
||||
TokenPair: pair,
|
||||
User: user,
|
||||
ProfileID: profileID,
|
||||
ProfileToken: profileToken,
|
||||
Temporary: record.Temporary,
|
||||
SessionExpiresAt: sessionExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -445,9 +588,9 @@ func (s *DeviceLoginService) create(ctx context.Context, record deviceLoginRecor
|
||||
INSERT INTO device_login_requests (
|
||||
id, device_code_hash, browser_code_hash, user_code_hash, match_code,
|
||||
device_name, device_platform, ip_address, requested_user_agent,
|
||||
status, expires_at, created_at, updated_at
|
||||
status, client_purpose, temporary, expires_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::inet, $9, $10, $11, $12, $13)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::inet, $9, $10, $11, $12, $13, $14, $15)
|
||||
`,
|
||||
record.ID,
|
||||
record.DeviceCodeHash,
|
||||
@@ -459,6 +602,8 @@ func (s *DeviceLoginService) create(ctx context.Context, record deviceLoginRecor
|
||||
record.IPAddress,
|
||||
record.RequestedUserAgent,
|
||||
record.Status,
|
||||
record.ClientPurpose,
|
||||
record.Temporary,
|
||||
record.ExpiresAt,
|
||||
record.CreatedAt,
|
||||
record.UpdatedAt,
|
||||
@@ -522,11 +667,11 @@ func (s *DeviceLoginService) getByDeviceCodeTx(ctx context.Context, tx pgx.Tx, d
|
||||
return scanDeviceLogin(row)
|
||||
}
|
||||
|
||||
func (s *DeviceLoginService) reloadDecisionState(
|
||||
func (s *DeviceLoginService) reloadApprovalState(
|
||||
ctx context.Context,
|
||||
recordID string,
|
||||
approverUserID int,
|
||||
approving bool,
|
||||
profileID string,
|
||||
) error {
|
||||
record, err := s.getByID(ctx, recordID)
|
||||
if err != nil {
|
||||
@@ -541,12 +686,113 @@ func (s *DeviceLoginService) reloadDecisionState(
|
||||
case DeviceLoginStatusDenied:
|
||||
return ErrDeviceLoginDenied
|
||||
case DeviceLoginStatusApproved:
|
||||
if approving && record.ApprovedByUserID != nil && *record.ApprovedByUserID == approverUserID {
|
||||
if sameApprovedIdentity(record, approverUserID, profileID) {
|
||||
return nil
|
||||
}
|
||||
return ErrDeviceLoginConflict
|
||||
default:
|
||||
return ErrDeviceLoginConflict
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeviceLoginService) reloadDenyState(ctx context.Context, recordID string) error {
|
||||
record, err := s.getByID(ctx, recordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isDeviceLoginExpired(record) {
|
||||
return ErrDeviceLoginExpired
|
||||
}
|
||||
switch record.Status {
|
||||
case DeviceLoginStatusConsumed:
|
||||
return ErrDeviceLoginConsumed
|
||||
case DeviceLoginStatusDenied:
|
||||
return nil
|
||||
default:
|
||||
return ErrDeviceLoginConflict
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeviceLoginService) validateApprovingUser(ctx context.Context, userID int) error {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load approving user: %w", err)
|
||||
}
|
||||
if !user.Enabled {
|
||||
return ErrUserDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeviceLoginService) validateApprovingProfile(ctx context.Context, userID int, profileID string) error {
|
||||
if err := s.validateApprovingUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.validateProfileOwnership(ctx, userID, profileID)
|
||||
}
|
||||
|
||||
func (s *DeviceLoginService) validateProfileOwnership(ctx context.Context, userID int, profileID string) error {
|
||||
if s.stores == nil {
|
||||
return errors.New("user store provider is not configured")
|
||||
}
|
||||
store, err := s.stores.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load approving user store: %w", err)
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load approving profile: %w", err)
|
||||
}
|
||||
if profile == nil {
|
||||
return ErrDeviceLoginNoProfile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeviceLoginDecision(record *deviceLoginRecord) error {
|
||||
if isDeviceLoginExpired(record) {
|
||||
return ErrDeviceLoginExpired
|
||||
}
|
||||
switch record.Status {
|
||||
case DeviceLoginStatusConsumed:
|
||||
return ErrDeviceLoginConsumed
|
||||
case DeviceLoginStatusDenied:
|
||||
return ErrDeviceLoginDenied
|
||||
case DeviceLoginStatusPending, DeviceLoginStatusApproved:
|
||||
return nil
|
||||
default:
|
||||
return ErrDeviceLoginConflict
|
||||
}
|
||||
}
|
||||
|
||||
func sameApprovedIdentity(record *deviceLoginRecord, userID int, profileID string) bool {
|
||||
if record.ApprovedByUserID == nil || *record.ApprovedByUserID != userID {
|
||||
return false
|
||||
}
|
||||
if profileID == "" {
|
||||
return record.ApprovedProfileID == nil || strings.TrimSpace(*record.ApprovedProfileID) == ""
|
||||
}
|
||||
return record.ApprovedProfileID != nil && strings.TrimSpace(*record.ApprovedProfileID) == profileID
|
||||
}
|
||||
|
||||
func normalizeDeviceLoginPurpose(value string, temporary bool) (string, bool, error) {
|
||||
purpose := strings.TrimSpace(value)
|
||||
if purpose == "" {
|
||||
purpose = DeviceLoginPurposeLogin
|
||||
}
|
||||
switch purpose {
|
||||
case DeviceLoginPurposeLogin:
|
||||
if temporary {
|
||||
return "", false, ErrDeviceLoginBadPurpose
|
||||
}
|
||||
return purpose, false, nil
|
||||
case DeviceLoginPurposeRemote:
|
||||
if !temporary {
|
||||
return "", false, ErrDeviceLoginBadPurpose
|
||||
}
|
||||
return purpose, true, nil
|
||||
default:
|
||||
return "", false, ErrDeviceLoginBadPurpose
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,7 +810,10 @@ func scanDeviceLogin(row pgx.Row) (*deviceLoginRecord, error) {
|
||||
&record.RequestedUserAgent,
|
||||
&record.Status,
|
||||
&record.ApprovedByUserID,
|
||||
&record.ApprovedProfileID,
|
||||
&record.AuthSessionID,
|
||||
&record.ClientPurpose,
|
||||
&record.Temporary,
|
||||
&record.ExpiresAt,
|
||||
&record.ApprovedAt,
|
||||
&record.DeniedAt,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeDeviceLoginPurpose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
purpose string
|
||||
temporary bool
|
||||
want string
|
||||
wantTemp bool
|
||||
wantErr error
|
||||
}{
|
||||
{name: "legacy default", want: DeviceLoginPurposeLogin},
|
||||
{name: "explicit login", purpose: DeviceLoginPurposeLogin, want: DeviceLoginPurposeLogin},
|
||||
{name: "remote playback", purpose: DeviceLoginPurposeRemote, temporary: true, want: DeviceLoginPurposeRemote, wantTemp: true},
|
||||
{name: "temporary generic rejected", temporary: true, wantErr: ErrDeviceLoginBadPurpose},
|
||||
{name: "non-temporary remote rejected", purpose: DeviceLoginPurposeRemote, wantErr: ErrDeviceLoginBadPurpose},
|
||||
{name: "unknown rejected", purpose: "other", wantErr: ErrDeviceLoginBadPurpose},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, gotTemp, err := normalizeDeviceLoginPurpose(tt.purpose, tt.temporary)
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want || gotTemp != tt.wantTemp {
|
||||
t.Fatalf("purpose = (%q, %v), want (%q, %v)", got, gotTemp, tt.want, tt.wantTemp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameApprovedIdentityIncludesProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userID := 7
|
||||
profileID := "profile-a"
|
||||
record := &deviceLoginRecord{
|
||||
ApprovedByUserID: &userID,
|
||||
ApprovedProfileID: &profileID,
|
||||
}
|
||||
if !sameApprovedIdentity(record, userID, profileID) {
|
||||
t.Fatal("matching user/profile was not idempotent")
|
||||
}
|
||||
if sameApprovedIdentity(record, userID, "profile-b") {
|
||||
t.Fatal("different profile was treated as the same identity")
|
||||
}
|
||||
if sameApprovedIdentity(record, 8, profileID) {
|
||||
t.Fatal("different user was treated as the same identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeviceLoginDecisionTerminalStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
future := time.Now().Add(time.Minute)
|
||||
tests := []struct {
|
||||
status string
|
||||
want error
|
||||
}{
|
||||
{status: DeviceLoginStatusPending},
|
||||
{status: DeviceLoginStatusApproved},
|
||||
{status: DeviceLoginStatusDenied, want: ErrDeviceLoginDenied},
|
||||
{status: DeviceLoginStatusConsumed, want: ErrDeviceLoginConsumed},
|
||||
{status: "unexpected", want: ErrDeviceLoginConflict},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
record := &deviceLoginRecord{Status: tt.status, ExpiresAt: future}
|
||||
if err := validateDeviceLoginDecision(record); !errors.Is(err, tt.want) {
|
||||
t.Fatalf("status %q error = %v, want %v", tt.status, err, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE device_login_requests
|
||||
ADD COLUMN client_purpose TEXT NOT NULL DEFAULT 'device_login',
|
||||
ADD COLUMN temporary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN approved_profile_id TEXT;
|
||||
|
||||
ALTER TABLE device_login_requests
|
||||
ADD CONSTRAINT device_login_requests_client_purpose_check
|
||||
CHECK (client_purpose IN ('device_login', 'remote_playback')),
|
||||
ADD CONSTRAINT device_login_requests_temporary_purpose_check
|
||||
CHECK (temporary = (client_purpose = 'remote_playback')),
|
||||
ADD CONSTRAINT device_login_requests_approved_profile_fkey
|
||||
FOREIGN KEY (approved_by_user_id, approved_profile_id)
|
||||
REFERENCES user_profiles(user_id, id)
|
||||
ON DELETE SET NULL;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE device_login_requests
|
||||
DROP CONSTRAINT IF EXISTS device_login_requests_approved_profile_fkey,
|
||||
DROP CONSTRAINT IF EXISTS device_login_requests_temporary_purpose_check,
|
||||
DROP CONSTRAINT IF EXISTS device_login_requests_client_purpose_check,
|
||||
DROP COLUMN IF EXISTS approved_profile_id,
|
||||
DROP COLUMN IF EXISTS temporary,
|
||||
DROP COLUMN IF EXISTS client_purpose;
|
||||
-- +goose StatementEnd
|
||||
Reference in New Issue
Block a user