ruze 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.test +3 -0
- data/.github/workflows/main.yml +1 -1
- data/.rubocop.yml +6 -1
- data/Gemfile +3 -0
- data/LICENSE.txt +1 -1
- data/README.md +79 -14
- data/bin/console +16 -6
- data/lib/ruze/car.rb +46 -6
- data/lib/ruze/device.rb +30 -0
- data/lib/ruze/errors.rb +12 -0
- data/lib/ruze/gigya.rb +216 -33
- data/lib/ruze/kamereon.rb +16 -4
- data/lib/ruze/version.rb +1 -1
- data/lib/ruze.rb +1 -4
- data/ruze.gemspec +1 -1
- metadata +6 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 328fbd59ffa961117d42c42029132b42d2109858f2a6f365d067019430d20dba
|
|
4
|
+
data.tar.gz: eb41527948e38b6d26a713e33afc1a7cf54c8616f1f0360e1e9394eb1e8cba39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 30b962007b6fb90f95c50ced022dbc381f23f10a015627c0550521e01366a80b74f53b8aa18d79635590b77b0f57f307bc5af838b44209d3dff8b8e1aa72a4ed
|
|
7
|
+
data.tar.gz: 433d7097c27ea567c3a19387e8d04f6d0f167bac0be43d52ccaf3363ad7df7a1fb6cb76bfbfcf01178f392e55783ef4d8ac447a09f4d80e1da1ab5b8ab891803
|
data/.env.test
CHANGED
data/.github/workflows/main.yml
CHANGED
data/.rubocop.yml
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
AllCops:
|
|
2
|
-
TargetRubyVersion:
|
|
2
|
+
TargetRubyVersion: 4.0
|
|
3
3
|
NewCops: enable
|
|
4
4
|
SuggestExtensions: false
|
|
5
5
|
|
|
6
|
+
Metrics/ClassLength:
|
|
7
|
+
Max: 180
|
|
8
|
+
|
|
6
9
|
Metrics/MethodLength:
|
|
7
10
|
Max: 20
|
|
8
11
|
|
|
9
12
|
Metrics/BlockLength:
|
|
10
13
|
Max: 90
|
|
14
|
+
Exclude:
|
|
15
|
+
- 'spec/**/*'
|
|
11
16
|
|
|
12
17
|
Metrics/AbcSize:
|
|
13
18
|
Max: 18
|
data/Gemfile
CHANGED
data/LICENSE.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2021-
|
|
3
|
+
Copyright (c) 2021-2026 Georg Ledermann
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
|
@@ -38,27 +38,25 @@ export KAMEREON_API_KEY=...
|
|
|
38
38
|
## Usage
|
|
39
39
|
|
|
40
40
|
```ruby
|
|
41
|
-
car = Ruze::Car.new('
|
|
41
|
+
car = Ruze::Car.new('john@example.com', 'my-password')
|
|
42
42
|
|
|
43
43
|
car.battery
|
|
44
44
|
# {
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# "chargingStatus" => 0.0,
|
|
53
|
-
# "chargingRemainingTime" => 55,
|
|
54
|
-
# "chargingInstantaneousPower" => 0.0
|
|
45
|
+
# "timestamp" => "2026-03-13T09:59:12Z",
|
|
46
|
+
# "batteryLevel" => 66,
|
|
47
|
+
# "batteryAutonomy" => 194,
|
|
48
|
+
# "plugStatus" => 0,
|
|
49
|
+
# "chargingStatus" => 0.0,
|
|
50
|
+
# "chargingRemainingTime" => 55,
|
|
51
|
+
# "chargingRemainingTimeLastUpdateDateTime" => "2026-03-13T09:30:00Z"
|
|
55
52
|
# }
|
|
56
53
|
|
|
57
54
|
car.cockpit
|
|
58
55
|
# {
|
|
59
56
|
# "fuelAutonomy" => 0.0,
|
|
60
57
|
# "fuelQuantity" => 0.0,
|
|
61
|
-
# "totalMileage" => 12345.67
|
|
58
|
+
# "totalMileage" => 12345.67,
|
|
59
|
+
# "timestamp" => "2026-03-13T09:59:12Z"
|
|
62
60
|
# }
|
|
63
61
|
|
|
64
62
|
car.location
|
|
@@ -66,10 +64,77 @@ car.location
|
|
|
66
64
|
# "gpsDirection" => nil,
|
|
67
65
|
# "gpsLatitude" => 50.12345678,
|
|
68
66
|
# "gpsLongitude" => 6.12345678,
|
|
69
|
-
# "lastUpdateTime" => "
|
|
67
|
+
# "lastUpdateTime" => "2026-03-12T11:43:18Z"
|
|
70
68
|
# }
|
|
71
69
|
```
|
|
72
70
|
|
|
71
|
+
### Repeated queries
|
|
72
|
+
|
|
73
|
+
A `Ruze::Car` keeps the data it fetched. To get new data, call `reload`
|
|
74
|
+
and query the car again:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
loop do
|
|
78
|
+
car.reload
|
|
79
|
+
puts car.battery['batteryLevel']
|
|
80
|
+
sleep 600
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Use one car for all queries. The car logs in once and keeps the login. It
|
|
85
|
+
gets a new token from Renault when the old token expires (after 15 minutes).
|
|
86
|
+
If Renault rejects the login later (HTTP 401 or 403), the car logs in again
|
|
87
|
+
and repeats the query once. Other errors, like a failing server, raise
|
|
88
|
+
`Ruze::Error` without a new login. A new `Ruze::Car` for each query makes 4 more requests, because
|
|
89
|
+
it logs in again and looks up the account and the VIN.
|
|
90
|
+
|
|
91
|
+
To log each login and each new token, give a logger. Any object with an
|
|
92
|
+
`info` method works:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
car = Ruze::Car.new(email, password, logger: Logger.new($stdout))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
## Two-factor authentication
|
|
100
|
+
|
|
101
|
+
Renault enforces two-factor authentication (2FA) on Gigya accounts, so a
|
|
102
|
+
password alone is no longer enough to log in. RuZE handles this by establishing
|
|
103
|
+
a *trusted device* once: after a single email verification, Renault remembers
|
|
104
|
+
the device for about 30 days, during which logins succeed without a prompt.
|
|
105
|
+
|
|
106
|
+
### One-time setup
|
|
107
|
+
|
|
108
|
+
Trigger a verification code, confirm it with the 6-digit code Renault emails to
|
|
109
|
+
your account, then read the resulting trusted-device pair:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
gigya = Ruze::Gigya.new('john@example.com', 'my-password')
|
|
113
|
+
|
|
114
|
+
gigya.request_verification_code # emails a 6-digit code (returns nil if none is needed)
|
|
115
|
+
|
|
116
|
+
gigya.verify_code('123456')
|
|
117
|
+
|
|
118
|
+
gmid = gigya.device.gmid
|
|
119
|
+
ucid = gigya.device.ucid
|
|
120
|
+
# Store this gmid/ucid pair somewhere durable (env vars, a file, a database).
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`Ruze::Device` keeps the pair in memory only, so persisting it is up to you. The
|
|
124
|
+
pair stays valid across renewals, so you store it once.
|
|
125
|
+
|
|
126
|
+
### Normal usage
|
|
127
|
+
|
|
128
|
+
Seed a device with your stored pair and `Ruze::Car` logs in without prompting:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
device = Ruze::Device.new(gmid: gmid, ucid: ucid)
|
|
132
|
+
car = Ruze::Car.new('john@example.com', 'my-password', device: device)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
When the device is missing or has expired, login raises
|
|
136
|
+
`Ruze::TwoFactorRequired` — repeat the one-time setup to renew it.
|
|
137
|
+
|
|
73
138
|
|
|
74
139
|
## Background
|
|
75
140
|
|
|
@@ -106,7 +171,7 @@ This project is not affiliated with, endorsed by, or connected to Renault. I acc
|
|
|
106
171
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
107
172
|
|
|
108
173
|
|
|
109
|
-
Copyright (c) 2021-
|
|
174
|
+
Copyright (c) 2021-2026 Georg Ledermann, released under the AGPL-3.0 License
|
|
110
175
|
|
|
111
176
|
## Code of Conduct
|
|
112
177
|
|
data/bin/console
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
3
|
require 'bundler/setup'
|
|
4
|
+
require 'dotenv/load'
|
|
4
5
|
require 'ruze'
|
|
5
6
|
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# Convenience for experimenting against the real API, using the credentials and
|
|
8
|
+
# trusted device from your .env (RENAULT_EMAIL, RENAULT_PASSWORD, GIGYA_API_KEY,
|
|
9
|
+
# KAMEREON_API_KEY, RUZE_GMID, RUZE_UCID):
|
|
10
|
+
#
|
|
11
|
+
# car.battery
|
|
12
|
+
# car.cockpit
|
|
13
|
+
# car.location
|
|
14
|
+
#
|
|
15
|
+
def car
|
|
16
|
+
Ruze::Car.new(
|
|
17
|
+
ENV.fetch('RENAULT_EMAIL'),
|
|
18
|
+
ENV.fetch('RENAULT_PASSWORD'),
|
|
19
|
+
device: Ruze::Device.new(gmid: ENV.fetch('RUZE_GMID', nil), ucid: ENV.fetch('RUZE_UCID', nil))
|
|
20
|
+
)
|
|
21
|
+
end
|
|
12
22
|
|
|
13
23
|
require 'irb'
|
|
14
24
|
IRB.start(__FILE__)
|
data/lib/ruze/car.rb
CHANGED
|
@@ -2,22 +2,62 @@ require_relative 'gigya'
|
|
|
2
2
|
require_relative 'kamereon'
|
|
3
3
|
|
|
4
4
|
module Ruze
|
|
5
|
+
# One Car can be queried repeatedly: it keeps the login and renews the JWT
|
|
6
|
+
# when needed. Call #reload to fetch fresh vehicle data.
|
|
7
|
+
#
|
|
8
|
+
# logger is any object with #info. It gets a message for each login and
|
|
9
|
+
# each new token.
|
|
5
10
|
class Car
|
|
6
|
-
def initialize(email, password, vin = nil)
|
|
7
|
-
|
|
8
|
-
@
|
|
11
|
+
def initialize(email, password, vin = nil, device: Ruze::Device.new, logger: nil)
|
|
12
|
+
@email = email
|
|
13
|
+
@password = password
|
|
14
|
+
@vin = vin
|
|
15
|
+
@device = device
|
|
16
|
+
@logger = logger
|
|
17
|
+
@gigya = new_gigya
|
|
9
18
|
end
|
|
10
19
|
|
|
11
20
|
def battery
|
|
12
|
-
|
|
21
|
+
with_session { kamereon.battery }
|
|
13
22
|
end
|
|
14
23
|
|
|
15
24
|
def cockpit
|
|
16
|
-
|
|
25
|
+
with_session { kamereon.cockpit }
|
|
17
26
|
end
|
|
18
27
|
|
|
19
28
|
def location
|
|
20
|
-
|
|
29
|
+
with_session { kamereon.location }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Forgets the vehicle data, but keeps the login
|
|
33
|
+
def reload
|
|
34
|
+
@kamereon&.reload
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def kamereon
|
|
41
|
+
@kamereon ||= Ruze::Kamereon.new(@gigya.person_id, -> { @gigya.jwt }, @vin)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def new_gigya
|
|
45
|
+
Ruze::Gigya.new(@email, @password, device: @device, logger: @logger)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A reused login can expire. If Renault rejects it, log in again and retry
|
|
49
|
+
# once. A fresh login gets no retry, because it would fail the same way.
|
|
50
|
+
# Other errors (like a failing server) get no new login.
|
|
51
|
+
def with_session
|
|
52
|
+
reused = !@kamereon.nil?
|
|
53
|
+
yield
|
|
54
|
+
rescue AuthenticationError => e
|
|
55
|
+
raise unless reused
|
|
56
|
+
|
|
57
|
+
@logger&.info("Login expired (#{e.message}), logging in again")
|
|
58
|
+
@gigya = new_gigya
|
|
59
|
+
@kamereon = nil
|
|
60
|
+
yield
|
|
21
61
|
end
|
|
22
62
|
end
|
|
23
63
|
end
|
data/lib/ruze/device.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Ruze
|
|
2
|
+
# Holds the Gigya trusted-device identity (gmid/ucid) that lets us skip the
|
|
3
|
+
# two-factor prompt on subsequent logins.
|
|
4
|
+
#
|
|
5
|
+
# This default implementation keeps the pair in memory only. For headless
|
|
6
|
+
# operation across process restarts, seed it from durable storage and persist
|
|
7
|
+
# the pair (see #gmid/#ucid) after a successful bootstrap:
|
|
8
|
+
#
|
|
9
|
+
# device = Ruze::Device.new(gmid: stored_gmid, ucid: stored_ucid)
|
|
10
|
+
# Ruze::Car.new(email, password, device: device)
|
|
11
|
+
class Device
|
|
12
|
+
def initialize(gmid: nil, ucid: nil)
|
|
13
|
+
@gmid = gmid
|
|
14
|
+
@ucid = ucid
|
|
15
|
+
end
|
|
16
|
+
attr_reader :gmid, :ucid
|
|
17
|
+
|
|
18
|
+
# Returns { gmid:, ucid: } or nil when no (complete) device is available.
|
|
19
|
+
def load
|
|
20
|
+
return nil if gmid.to_s.empty? || ucid.to_s.empty?
|
|
21
|
+
|
|
22
|
+
{ gmid: gmid, ucid: ucid }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def save(gmid:, ucid:)
|
|
26
|
+
@gmid = gmid
|
|
27
|
+
@ucid = ucid
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/ruze/errors.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Ruze
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# Raised when Renault rejects the login or the token (HTTP 401 or 403).
|
|
5
|
+
# A new login can help.
|
|
6
|
+
class AuthenticationError < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised when Renault/Gigya requires two-factor authentication and no trusted
|
|
9
|
+
# device is available. Run the bootstrap flow (request_verification_code +
|
|
10
|
+
# verify_code) to establish a trusted device.
|
|
11
|
+
class TwoFactorRequired < Error; end
|
|
12
|
+
end
|
data/lib/ruze/gigya.rb
CHANGED
|
@@ -1,68 +1,251 @@
|
|
|
1
1
|
require 'net/http'
|
|
2
2
|
require 'json'
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'device'
|
|
3
5
|
|
|
4
6
|
module Ruze
|
|
5
7
|
class Gigya
|
|
6
|
-
BASE_URL
|
|
8
|
+
BASE_URL = 'https://accounts.eu1.gigya.com'.freeze
|
|
9
|
+
SOCIALIZE_URL = 'https://socialize.eu1.gigya.com'.freeze
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
# Lifetime of a JWT in seconds. A new one is fetched shortly before the
|
|
12
|
+
# old one expires.
|
|
13
|
+
JWT_LIFETIME = 900
|
|
14
|
+
JWT_MARGIN = 60
|
|
15
|
+
|
|
16
|
+
# logger is any object with #info
|
|
17
|
+
def initialize(email, password, device: Device.new, logger: nil)
|
|
9
18
|
raise ArgumentError unless email.is_a?(String) && password.is_a?(String)
|
|
10
19
|
|
|
11
20
|
@email = email
|
|
12
21
|
@password = password
|
|
22
|
+
@device = device
|
|
23
|
+
@logger = logger
|
|
13
24
|
end
|
|
14
|
-
attr_reader :email, :password
|
|
25
|
+
attr_reader :email, :password, :device
|
|
15
26
|
|
|
16
27
|
def person_id
|
|
17
|
-
@person_id ||=
|
|
18
|
-
|
|
19
|
-
'
|
|
20
|
-
|
|
21
|
-
), keys: %w[data personId]
|
|
28
|
+
@person_id ||= dig_from post(
|
|
29
|
+
"#{BASE_URL}/accounts.getAccountInfo",
|
|
30
|
+
{ 'apiKey' => api_key, 'login_token' => session_cookie_value }
|
|
31
|
+
), label: 'person_id', keys: %w[data personId]
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def jwt
|
|
25
|
-
@jwt
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
return @jwt if @jwt && now < @jwt_expires_at
|
|
36
|
+
|
|
37
|
+
login_token = session_cookie_value
|
|
38
|
+
@logger&.info('Fetching new token')
|
|
39
|
+
fetched_at = now
|
|
40
|
+
@jwt = dig_from post(
|
|
41
|
+
"#{BASE_URL}/accounts.getJWT",
|
|
42
|
+
{ 'apiKey' => api_key,
|
|
43
|
+
'login_token' => login_token,
|
|
44
|
+
'fields' => 'data.personId,data.gigyaDataCenter',
|
|
45
|
+
'expiration' => JWT_LIFETIME }
|
|
46
|
+
), label: 'jwt', keys: %w[id_token]
|
|
47
|
+
@jwt_expires_at = fetched_at + JWT_LIFETIME - JWT_MARGIN
|
|
48
|
+
@jwt
|
|
32
49
|
end
|
|
33
50
|
|
|
51
|
+
# Logs in using the trusted device and returns the session cookie value.
|
|
52
|
+
# Raises TwoFactorRequired when Renault demands a fresh 2FA verification.
|
|
34
53
|
def session_cookie_value
|
|
35
|
-
@session_cookie_value ||=
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
@session_cookie_value ||= login
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- Two-factor bootstrap ------------------------------------------------
|
|
58
|
+
#
|
|
59
|
+
# The Gigya 2FA trusted-device flow below (initTFA -> getEmails ->
|
|
60
|
+
# sendVerificationCode -> completeVerification -> finalizeTFA -> re-login)
|
|
61
|
+
# was reverse-engineered by the renault-api community:
|
|
62
|
+
# https://github.com/hacf-fr/renault-api/issues/2132
|
|
63
|
+
#
|
|
64
|
+
# Run once interactively to establish a trusted device:
|
|
65
|
+
#
|
|
66
|
+
# gigya = Ruze::Gigya.new(email, password)
|
|
67
|
+
# puts "Code sent to #{gigya.request_verification_code}"
|
|
68
|
+
# gigya.verify_code(gets.strip)
|
|
69
|
+
#
|
|
70
|
+
# After that, session_cookie_value works headlessly for ~30 days.
|
|
71
|
+
|
|
72
|
+
# Triggers the email verification code and returns the obfuscated address it
|
|
73
|
+
# was sent to. Returns nil when no verification is needed -- either the
|
|
74
|
+
# account has no 2FA or this device is already trusted.
|
|
75
|
+
def request_verification_code
|
|
76
|
+
ids = device_ids
|
|
77
|
+
reg_token = login_reg_token(ids)
|
|
78
|
+
return nil unless reg_token
|
|
79
|
+
|
|
80
|
+
assertion = init_tfa(reg_token, ids)
|
|
81
|
+
mail = first_email(assertion, ids)
|
|
82
|
+
phv_token = send_verification_code(mail['id'], assertion, ids)
|
|
83
|
+
|
|
84
|
+
@bootstrap = { ids: ids, reg_token: reg_token, assertion: assertion, phv_token: phv_token }
|
|
85
|
+
mail['obfuscated']
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Completes the 2FA flow with the emailed code, finalizes the device as
|
|
89
|
+
# trusted (remembered for ~30 days) and persists it.
|
|
90
|
+
def verify_code(code)
|
|
91
|
+
raise Error, 'Call request_verification_code before verify_code' unless @bootstrap
|
|
92
|
+
|
|
93
|
+
ids = @bootstrap[:ids]
|
|
94
|
+
provider_assertion = complete_verification(code, ids)
|
|
95
|
+
finalize_tfa(provider_assertion, ids)
|
|
96
|
+
|
|
97
|
+
@session_cookie_value = login(ids)
|
|
98
|
+
device.save(gmid: ids[:gmid], ucid: ids[:ucid])
|
|
99
|
+
@session_cookie_value
|
|
41
100
|
end
|
|
42
101
|
|
|
43
102
|
private
|
|
44
103
|
|
|
104
|
+
def login(ids = device_ids)
|
|
105
|
+
@logger&.info('Logging in')
|
|
106
|
+
json = parse post(
|
|
107
|
+
"#{BASE_URL}/accounts.login",
|
|
108
|
+
login_params(ids), cookie: cookie(ids)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
case json['errorCode']
|
|
112
|
+
when 0
|
|
113
|
+
json.dig('sessionInfo', 'cookieValue')
|
|
114
|
+
when 403_101
|
|
115
|
+
raise TwoFactorRequired,
|
|
116
|
+
'Two-factor authentication required. Run the bootstrap flow to trust this device.'
|
|
117
|
+
else
|
|
118
|
+
raise Error, "Error in session_cookie_value: #{error_detail(json)}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Like #login but returns the regToken from a pending-2FA response instead of
|
|
123
|
+
# raising. Returns nil when the account is already logged in without 2FA.
|
|
124
|
+
def login_reg_token(ids)
|
|
125
|
+
json = parse post(
|
|
126
|
+
"#{BASE_URL}/accounts.login",
|
|
127
|
+
login_params(ids), cookie: cookie(ids)
|
|
128
|
+
)
|
|
129
|
+
return json['regToken'] if json['errorCode'] == 403_101
|
|
130
|
+
return nil if json['errorCode']&.zero?
|
|
131
|
+
|
|
132
|
+
raise Error, "Error in session_cookie_value: #{error_detail(json)}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def login_params(ids)
|
|
136
|
+
{
|
|
137
|
+
'apiKey' => api_key,
|
|
138
|
+
'loginID' => email,
|
|
139
|
+
'password' => password,
|
|
140
|
+
'gmid' => ids[:gmid],
|
|
141
|
+
'ucid' => ids[:ucid]
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def init_tfa(reg_token, ids)
|
|
146
|
+
dig_from post(
|
|
147
|
+
"#{BASE_URL}/accounts.tfa.initTFA",
|
|
148
|
+
{ 'apiKey' => api_key, 'regToken' => reg_token, 'provider' => 'gigyaEmail',
|
|
149
|
+
'mode' => 'verify', 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
|
|
150
|
+
cookie: cookie(ids)
|
|
151
|
+
), label: 'init_tfa', keys: %w[gigyaAssertion]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def first_email(assertion, ids)
|
|
155
|
+
emails = dig_from post(
|
|
156
|
+
"#{BASE_URL}/accounts.tfa.email.getEmails",
|
|
157
|
+
{ 'apiKey' => api_key, 'gigyaAssertion' => assertion, 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
|
|
158
|
+
cookie: cookie(ids)
|
|
159
|
+
), label: 'get_emails', keys: %w[emails]
|
|
160
|
+
raise Error, 'No verified email address available for 2FA' if emails.nil? || emails.empty?
|
|
161
|
+
|
|
162
|
+
emails.first
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def send_verification_code(email_id, assertion, ids)
|
|
166
|
+
dig_from post(
|
|
167
|
+
"#{BASE_URL}/accounts.tfa.email.sendVerificationCode",
|
|
168
|
+
{ 'apiKey' => api_key, 'emailID' => email_id, 'gigyaAssertion' => assertion,
|
|
169
|
+
'lang' => 'en', 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
|
|
170
|
+
cookie: cookie(ids)
|
|
171
|
+
), label: 'send_verification_code', keys: %w[phvToken]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def complete_verification(code, ids)
|
|
175
|
+
dig_from post(
|
|
176
|
+
"#{BASE_URL}/accounts.tfa.email.completeVerification",
|
|
177
|
+
{ 'apiKey' => api_key, 'gigyaAssertion' => @bootstrap[:assertion],
|
|
178
|
+
'phvToken' => @bootstrap[:phv_token], 'code' => code,
|
|
179
|
+
'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
|
|
180
|
+
cookie: cookie(ids)
|
|
181
|
+
), label: 'complete_verification', keys: %w[providerAssertion]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# tempDevice=false marks the device as remembered (no 2FA for ~30 days).
|
|
185
|
+
def finalize_tfa(provider_assertion, ids)
|
|
186
|
+
json = parse post(
|
|
187
|
+
"#{BASE_URL}/accounts.tfa.finalizeTFA",
|
|
188
|
+
{ 'apiKey' => api_key, 'gigyaAssertion' => @bootstrap[:assertion],
|
|
189
|
+
'providerAssertion' => provider_assertion, 'tempDevice' => 'false',
|
|
190
|
+
'regToken' => @bootstrap[:reg_token], 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
|
|
191
|
+
cookie: cookie(ids)
|
|
192
|
+
)
|
|
193
|
+
raise Error, "Error in finalize_tfa: #{error_detail(json)}" unless json['errorCode']&.zero?
|
|
194
|
+
|
|
195
|
+
json
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def device_ids
|
|
199
|
+
@device_ids ||= device.load || fetch_ids
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def fetch_ids
|
|
203
|
+
json = parse post("#{SOCIALIZE_URL}/socialize.getIDs", { 'apiKey' => api_key })
|
|
204
|
+
raise Error, "Error in fetch_ids: #{error_detail(json)}" unless json['errorCode']&.zero?
|
|
205
|
+
|
|
206
|
+
{ gmid: json['gmid'], ucid: json['ucid'] }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def now
|
|
210
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def cookie(ids)
|
|
214
|
+
"gmid=#{ids[:gmid]}; ucid=#{ids[:ucid]}"
|
|
215
|
+
end
|
|
216
|
+
|
|
45
217
|
def api_key
|
|
46
218
|
ENV.fetch('GIGYA_API_KEY')
|
|
47
219
|
end
|
|
48
220
|
|
|
49
|
-
def
|
|
50
|
-
URI(
|
|
221
|
+
def post(url, params, cookie: nil)
|
|
222
|
+
uri = URI(url)
|
|
223
|
+
request = Net::HTTP::Post.new(uri)
|
|
224
|
+
request.set_form_data(params.merge('format' => 'json'))
|
|
225
|
+
request['Cookie'] = cookie if cookie
|
|
226
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
227
|
+
http.request(request)
|
|
228
|
+
end
|
|
51
229
|
end
|
|
52
230
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
raise Error, "Error in #{caller}: #{response.message} (#{response.code})"
|
|
57
|
-
end
|
|
231
|
+
def parse(response)
|
|
232
|
+
JSON.parse(response.body)
|
|
233
|
+
end
|
|
58
234
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
raise Error, "Error in #{caller}: #{json['errorDetails']}"
|
|
63
|
-
end
|
|
235
|
+
def dig_from(response, label:, keys:)
|
|
236
|
+
json = parse(response)
|
|
237
|
+
raise error_class(json), "Error in #{label}: #{error_detail(json)}" unless json['errorCode']&.zero?
|
|
64
238
|
|
|
65
239
|
json.dig(*keys)
|
|
66
240
|
end
|
|
241
|
+
|
|
242
|
+
# The first three digits of a Gigya error code are the HTTP status
|
|
243
|
+
def error_class(json)
|
|
244
|
+
json['errorCode'].to_s.start_with?('401', '403') ? AuthenticationError : Error
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def error_detail(json)
|
|
248
|
+
json['errorDetails'] || json['errorMessage'] || "errorCode #{json['errorCode']}"
|
|
249
|
+
end
|
|
67
250
|
end
|
|
68
251
|
end
|
data/lib/ruze/kamereon.rb
CHANGED
|
@@ -6,14 +6,19 @@ module Ruze
|
|
|
6
6
|
BASE_URL = 'https://api-wired-prod-1-euw1.wrd-aws.com/commerce/v1'.freeze
|
|
7
7
|
COUNTRY = 'DE'.freeze
|
|
8
8
|
|
|
9
|
+
# gigya_token is a String or a callable that returns a valid token
|
|
9
10
|
def initialize(person_id, gigya_token, vin = nil)
|
|
10
|
-
raise ArgumentError unless person_id.is_a?(String) && gigya_token.is_a?(String)
|
|
11
|
+
raise ArgumentError unless person_id.is_a?(String) && (gigya_token.is_a?(String) || gigya_token.respond_to?(:call))
|
|
11
12
|
|
|
12
13
|
@person_id = person_id
|
|
13
14
|
@gigya_token = gigya_token
|
|
14
15
|
@vin = vin
|
|
15
16
|
end
|
|
16
|
-
attr_reader :person_id
|
|
17
|
+
attr_reader :person_id
|
|
18
|
+
|
|
19
|
+
def gigya_token
|
|
20
|
+
@gigya_token.respond_to?(:call) ? @gigya_token.call : @gigya_token
|
|
21
|
+
end
|
|
17
22
|
|
|
18
23
|
def account_id
|
|
19
24
|
accounts.first['accountId']
|
|
@@ -62,6 +67,12 @@ module Ruze
|
|
|
62
67
|
{}
|
|
63
68
|
end
|
|
64
69
|
|
|
70
|
+
# Forgets the vehicle data, but keeps account and VIN
|
|
71
|
+
def reload
|
|
72
|
+
@battery = @cockpit = @location = nil
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
private
|
|
66
77
|
|
|
67
78
|
def api_key
|
|
@@ -89,8 +100,9 @@ module Ruze
|
|
|
89
100
|
|
|
90
101
|
def return_from(response, keys:)
|
|
91
102
|
unless response.is_a?(Net::HTTPOK)
|
|
92
|
-
caller = caller_locations(1, 1)[0].
|
|
93
|
-
|
|
103
|
+
caller = caller_locations(1, 1)[0].base_label
|
|
104
|
+
error = %w[401 403].include?(response.code) ? AuthenticationError : Error
|
|
105
|
+
raise error, "Error in #{caller}: #{response.message} (#{response.code})"
|
|
94
106
|
end
|
|
95
107
|
|
|
96
108
|
json = JSON.parse(response.body)
|
data/lib/ruze/version.rb
CHANGED
data/lib/ruze.rb
CHANGED
data/ruze.gemspec
CHANGED
|
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
|
|
|
10
10
|
spec.description = 'Queries vehicle data like mileage, charging state and GPS location'
|
|
11
11
|
spec.homepage = 'https://github.com/solectrus/ruze'
|
|
12
12
|
spec.license = 'MIT'
|
|
13
|
-
spec.required_ruby_version = Gem::Requirement.new('>=
|
|
13
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 4.0.0')
|
|
14
14
|
|
|
15
15
|
spec.metadata['homepage_uri'] = spec.homepage
|
|
16
16
|
spec.metadata['source_code_uri'] = 'https://github.com/solectrus/ruze'
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruze
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Georg Ledermann
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
12
|
description: Queries vehicle data like mileage, charging state and GPS location
|
|
14
13
|
email:
|
|
@@ -31,6 +30,8 @@ files:
|
|
|
31
30
|
- bin/setup
|
|
32
31
|
- lib/ruze.rb
|
|
33
32
|
- lib/ruze/car.rb
|
|
33
|
+
- lib/ruze/device.rb
|
|
34
|
+
- lib/ruze/errors.rb
|
|
34
35
|
- lib/ruze/gigya.rb
|
|
35
36
|
- lib/ruze/kamereon.rb
|
|
36
37
|
- lib/ruze/version.rb
|
|
@@ -43,7 +44,6 @@ metadata:
|
|
|
43
44
|
source_code_uri: https://github.com/solectrus/ruze
|
|
44
45
|
changelog_uri: https://github.com/solectrus/ruze/releases
|
|
45
46
|
rubygems_mfa_required: 'true'
|
|
46
|
-
post_install_message:
|
|
47
47
|
rdoc_options: []
|
|
48
48
|
require_paths:
|
|
49
49
|
- lib
|
|
@@ -51,15 +51,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
51
51
|
requirements:
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version:
|
|
54
|
+
version: 4.0.0
|
|
55
55
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
56
|
requirements:
|
|
57
57
|
- - ">="
|
|
58
58
|
- !ruby/object:Gem::Version
|
|
59
59
|
version: '0'
|
|
60
60
|
requirements: []
|
|
61
|
-
rubygems_version:
|
|
62
|
-
signing_key:
|
|
61
|
+
rubygems_version: 4.0.21
|
|
63
62
|
specification_version: 4
|
|
64
63
|
summary: Unofficial Ruby client for the Renault ZE API
|
|
65
64
|
test_files: []
|