Add remote playback identity handoff (#360)

This commit is contained in:
Quick
2026-07-09 23:08:04 -04:00
committed by GitHub
parent b7292a9473
commit 9cfd01e9c2
6 changed files with 529 additions and 49 deletions
+83 -6
View File
@@ -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")
}
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+82
View File
@@ -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