package auth import ( "context" "crypto/rand" "database/sql" "errors" "fmt" "math/big" "strings" "time" ) const ( DeviceCodeTTL = 15 * time.Minute DeviceCodeInterval = 5 // seconds the client should wait between polls ) // Status values for a device authorization grant. const ( DeviceStatusPending = "pending" DeviceStatusApproved = "approved" DeviceStatusDenied = "denied" ) var ErrDeviceCodeNotFound = errors.New("device code not found") // userCodeAlphabet avoids characters that are easy to confuse (0/O, 1/I/L). const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ23456789" type DeviceCode struct { Code string UserCode string ClientID string UserID *int64 Status string ExpiresAt time.Time } func generateUserCode() (string, error) { letters := make([]byte, 8) for i := range letters { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(userCodeAlphabet)))) if err != nil { return "", err } letters[i] = userCodeAlphabet[n.Int64()] } return string(letters[:4]) + "-" + string(letters[4:]), nil } // NormalizeUserCode uppercases the entered code and restores the XXXX-XXXX shape. func NormalizeUserCode(code string) string { code = strings.ToUpper(strings.TrimSpace(code)) code = strings.ReplaceAll(code, " ", "") code = strings.ReplaceAll(code, "-", "") if len(code) != 8 { return code } return code[:4] + "-" + code[4:] } // CreateDeviceCode starts a device authorization grant (RFC 8628). func (s *Service) CreateDeviceCode(ctx context.Context, clientID string) (*DeviceCode, error) { clientID = strings.TrimSpace(clientID) if len(clientID) > 120 { clientID = clientID[:120] } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() for attempt := 0; attempt < 5; attempt++ { deviceCode, err := randomHex(24) if err != nil { return nil, fmt.Errorf("generate device code: %w", err) } userCode, err := generateUserCode() if err != nil { return nil, fmt.Errorf("generate user code: %w", err) } expiresAt := time.Now().UTC().Add(DeviceCodeTTL) _, err = s.db.ExecContext(cctx, ` INSERT INTO device_codes (device_code, user_code, client_id, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) `, deviceCode, userCode, clientID, DeviceStatusPending, time.Now().UTC(), expiresAt) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { continue } return nil, fmt.Errorf("insert device code: %w", err) } return &DeviceCode{Code: deviceCode, UserCode: userCode, ClientID: clientID, Status: DeviceStatusPending, ExpiresAt: expiresAt}, nil } return nil, errors.New("could not allocate a unique user code") } // GetDeviceCodeByUserCode looks up a grant by the human-friendly code. func (s *Service) GetDeviceCodeByUserCode(ctx context.Context, userCode string) (*DeviceCode, error) { userCode = NormalizeUserCode(userCode) if userCode == "" { return nil, ErrDeviceCodeNotFound } cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var dc DeviceCode var userID sql.NullInt64 var expiresAt time.Time err := s.db.QueryRowContext(cctx, ` SELECT device_code, user_code, client_id, user_id, status, expires_at FROM device_codes WHERE user_code = ? `, userCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrDeviceCodeNotFound } return nil, fmt.Errorf("get device code: %w", err) } if userID.Valid { dc.UserID = &userID.Int64 } dc.ExpiresAt = expiresAt return &dc, nil } // SetDeviceCodeStatus approves or denies a pending grant. func (s *Service) SetDeviceCodeStatus(ctx context.Context, userCode string, userID int64, status string) error { if status != DeviceStatusApproved && status != DeviceStatusDenied { return errors.New("invalid device code status") } cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() res, err := s.db.ExecContext(cctx, ` UPDATE device_codes SET status = ?, user_id = ? WHERE user_code = ? AND status = ? AND expires_at > ? `, status, userID, NormalizeUserCode(userCode), DeviceStatusPending, time.Now().UTC()) if err != nil { return fmt.Errorf("update device code: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrDeviceCodeNotFound } return nil } // ConsumeDeviceCode marks an approved grant as exchanged so the device code // cannot be traded for a second token. func (s *Service) ConsumeDeviceCode(ctx context.Context, deviceCode string) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET status = 'exchanged' WHERE device_code = ?`, deviceCode) } // PollDeviceCode returns the current grant status and marks the poll time. func (s *Service) PollDeviceCode(ctx context.Context, deviceCode string) (*DeviceCode, error) { deviceCode = strings.TrimSpace(deviceCode) if deviceCode == "" { return nil, ErrDeviceCodeNotFound } cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var dc DeviceCode var userID sql.NullInt64 var expiresAt time.Time err := s.db.QueryRowContext(cctx, ` SELECT device_code, user_code, client_id, user_id, status, expires_at FROM device_codes WHERE device_code = ? `, deviceCode).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &userID, &dc.Status, &expiresAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrDeviceCodeNotFound } return nil, fmt.Errorf("poll device code: %w", err) } if userID.Valid { dc.UserID = &userID.Int64 } dc.ExpiresAt = expiresAt _, _ = s.db.ExecContext(cctx, `UPDATE device_codes SET last_polled_at = ? WHERE device_code = ?`, time.Now().UTC(), deviceCode) if time.Now().UTC().After(expiresAt) && dc.Status == DeviceStatusPending { dc.Status = "expired" } return &dc, nil }