rueda 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eb546a4621da61ec3d034e31f02bf92707e0f255876d55b19998fede94d857da
4
+ data.tar.gz: 82371c1594ba2a17140c370f0804b80f3cec82c05935db3274fd21c2edb0cd07
5
+ SHA512:
6
+ metadata.gz: e3c2d941a4c3fce7ebee89868cb0ebb1701a88f0a3af7c3f5723dc21afe11f09e39829965aec7aeb3a8d69dab6da9b188ae91571759a39743b7d73a5582f8922
7
+ data.tar.gz: 896de09eb7cd26ad7a426b6f4935bb676c156f0ebf57d62d0671c8d929738e055961707646ddd5771a9fa9e7b18315f6aa61ecb632cf0365aae3accd574cd4d8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ariel Rzezak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # rueda
2
+
3
+ A read-only command-line client for the Garmin Connect Personal API. It fetches your activities and prints them as a table, JSON, NDJSON, or CSV.
4
+
5
+ ```
6
+ $ rueda activities --since 2025-09-30
7
+ DATE TYPE NAME KM MOVING KM/H ELEV HR CAD
8
+ 2025-10-05 road_biking Activity 1 26.0 1:05:05 23.9 155 146 86
9
+ 2025-10-03 road_biking Activity 2 29.3 1:11:32 24.6 160 120 74
10
+ 2025-10-02 road_biking Activity 3 35.7 1:32:40 23.1 195 136 73
11
+ 2025-09-30 road_biking Activity 4 35.7 1:29:24 23.9 202 133 76
12
+ ```
13
+
14
+ `SPEC.md` is the full specification.
15
+
16
+ ## Install
17
+
18
+ rueda needs Ruby 3.4 or later.
19
+
20
+ ```bash
21
+ gem install rueda
22
+ ```
23
+
24
+ To run it from a clone instead, run `bundle install` and use `exe/rueda`.
25
+
26
+ ## Set up your API key
27
+
28
+ 1. In Garmin Connect, go to **Settings → Personal API** and create a key. Choose how long it lasts (30, 90, 180, or 365 days). Garmin shows it only once, so copy it right away.
29
+ 2. With the key copied to the clipboard, save it where rueda looks for it:
30
+
31
+ ```bash
32
+ mkdir -p ~/.config/rueda && (umask 077 && pbpaste > ~/.config/rueda/api_key)
33
+ ```
34
+
35
+ The file holds only the key, with `0600` permissions. If `~/.config` is in a dotfiles repo or synced, exclude `rueda/`. You can also set `GARMIN_API_KEY` instead, which takes precedence over the file. rueda never accepts the key as a command-line argument, so it stays out of your shell history and the process list.
36
+
37
+ 3. Check it works:
38
+
39
+ ```
40
+ $ rueda auth check
41
+ Token OK, valid until 2026-11-11 11:01 -03.
42
+ ```
43
+
44
+ When the key expires, create a new one and replace the old one in the same place.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ rueda activities # the last 30 days
50
+ rueda activities --since 2025-09-01 --until 2025-09-30
51
+ rueda activities --type road_biking --limit 5 # --type takes Garmin's type key and can repeat
52
+ rueda activity 900000001 # one activity
53
+ rueda activity 900000001 --fit # save the original file as activity-900000001.zip
54
+ ```
55
+
56
+ In a terminal, rueda prints a table. When its output goes to a pipe or a file, it prints JSON, so scripts and agents get stable data by default:
57
+
58
+ ```
59
+ $ rueda activities --limit 1 | jq
60
+ {
61
+ "schema_version": 1,
62
+ "query": {
63
+ "since": "2025-09-06",
64
+ "until": "2025-10-06",
65
+ "type": []
66
+ },
67
+ "count": 1,
68
+ "activities": [
69
+ {
70
+ "garmin_id": 900000001,
71
+ "name": "Activity 1",
72
+ "type": "road_biking",
73
+ "started_at": "2025-10-05T17:55:26Z",
74
+ "local_date": "2025-10-05",
75
+ "distance_km": 26.0,
76
+ "moving_time_s": 3905,
77
+ "elapsed_time_s": 3916,
78
+ "avg_speed_kmh": 23.9,
79
+ "max_speed_kmh": 35.0,
80
+ "elevation_gain_m": 155,
81
+ "elevation_loss_m": 147,
82
+ "avg_hr_bpm": 146,
83
+ "max_hr_bpm": 170,
84
+ "avg_power_w": null,
85
+ "calories": 728,
86
+ "avg_cadence_rpm": 86,
87
+ "max_cadence_rpm": 125
88
+ }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ Units are in the field names, and missing data is `null`, never `0`.
94
+
95
+ Other options:
96
+
97
+ - `--format table|json|ndjson|csv` picks the format. NDJSON prints one activity per line; CSV prints every field, in schema order.
98
+ - `--raw` prints Garmin's JSON as it arrives, for fields rueda doesn't map yet. It works only with `json` and `ndjson`, and its shape is not guaranteed.
99
+ - `--quiet` hides warnings. `--timeout SECONDS` changes the network timeout (30 by default).
100
+
101
+ Data goes to stdout, and warnings and errors go to stderr. Each kind of error has its own exit code, such as 3 for no API key and 4 when Garmin rejects the key; `SPEC.md` lists them all.
102
+
103
+ ## License
104
+
105
+ MIT. See `LICENSE`.
data/SPEC.md ADDED
@@ -0,0 +1,228 @@
1
+ # rueda — a Garmin training data client
2
+
3
+ > The name comes from the Spanish cycling term _ir a rueda_ ("riding on a wheel", i.e. drafting): it sits behind Garmin and lets Garmin do the work.
4
+
5
+ ## What it is
6
+
7
+ A read-only command-line client for the **Garmin Connect Personal API**. It fetches data on demand, normalizes it into its own stable schema, and prints it in the requested format.
8
+
9
+ It serves two kinds of consumers equally:
10
+
11
+ - **Humans** at a terminal: readable tables, and pipes into other tools.
12
+ - **Agents** (Claude Code and similar) via the shell: stable JSON, predictable errors and exit codes.
13
+
14
+ ## What it is not
15
+
16
+ - It does not sync or store data. Garmin is the source of truth; what happens to the output is up to the caller.
17
+ - It does not write to Garmin. The Personal API is read-only.
18
+ - It has no GUI and no server.
19
+ - It is not multi-user: one account, the one that owns the key.
20
+
21
+ The only thing persisted locally is the cached access token.
22
+
23
+ ## v1 scope
24
+
25
+ - Authentication with a personal API key, plus token caching.
26
+ - Listing activities with filters.
27
+ - Activity detail.
28
+ - Downloading an activity's original file (a ZIP containing the .FIT).
29
+ - Output as `table`, `json`, `ndjson`, and `csv`.
30
+
31
+ ## Architecture
32
+
33
+ ```
34
+ lib/rueda/
35
+ auth/ # key → token, caching, renewal
36
+ garmin/ # HTTP client and endpoints (the only code that knows Garmin)
37
+ model/ # Activity and other normalized types
38
+ normalize/ # Garmin JSON → model
39
+ output/ # formatters: table, json, ndjson, csv
40
+ cli/ # commands; a thin layer over the above
41
+ ```
42
+
43
+ ## Stack
44
+
45
+ - Ruby ≥ 3.4, distributed as a gem.
46
+ - CLI: `OptionParser` from the stdlib. Four commands don't need a framework.
47
+ - Tests: Minitest with WebMock. Linter: Standard (no rule configuration).
48
+
49
+ ## Authentication
50
+
51
+ Flow per the official Personal API FAQ:
52
+
53
+ 1. The user creates a key in Garmin Connect → Settings → Personal API. It is shown only once and expires after the chosen period (30, 90, 180, or 365 days).
54
+ 2. The client exchanges the key for a token: `POST https://personal.connectapi.garmin.com/personalapitoken-service/personal-api/personal-api-key/token` with the header `Authorization: ApiKey <key>`.
55
+ 3. The response body is the bare JWT, not JSON (see `test/fixtures/token.txt`). The token is valid for 24 h (or until the key expires). There are no refresh tokens: when it expires, request a new one with the key.
56
+ 4. Data requests use `Authorization: Bearer <token>`.
57
+
58
+ ### Key sources (in order of precedence)
59
+
60
+ 1. `--api-key-file <path>`
61
+ 2. The `GARMIN_API_KEY` environment variable
62
+ 3. The default file `~/.config/rueda/api_key`
63
+
64
+ The key is **not** accepted as a direct argument: it would end up in shell history and the process list.
65
+
66
+ ### Token cache
67
+
68
+ - File `~/.config/rueda/token.json`, created with `0600` permissions.
69
+ - Reused if it was issued for the current key (store a hash of the key, never the key itself) and has more than one hour left before the JWT `exp`.
70
+ - On a `401`, discard the cache, request a new token, and retry **once**.
71
+ - Warn on stderr if the key file or token file has permissions looser than `0600`.
72
+
73
+ ## Garmin endpoints
74
+
75
+ | Purpose | Request | Status |
76
+ | --------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
77
+ | Token | `POST /personalapitoken-service/personal-api/personal-api-key/token` | Confirmed (official FAQ and fixture; not in the OpenAPI) |
78
+ | List activities | `GET /activitylist-service/activities/search/activities?start&limit&startDate&endDate` | Confirmed (OpenAPI and fixture) |
79
+ | Detail | `GET /activity-service/activity/{id}` | Confirmed (OpenAPI; `404` if the activity doesn't exist) |
80
+ | Original file | `GET /download-service/files/activity/{id}` → ZIP | Confirmed (OpenAPI) |
81
+
82
+ Host: `https://personal.connectapi.garmin.com`, from the OpenAPI `servers` field (which also lists `https://personal.connectapi.garmin.cn`).
83
+
84
+ List response fields match the OpenAPI: `activityId`, `activityName`, `startTimeLocal` and `startTimeGMT` (`yyyy-MM-dd HH:mm:ss`, no offset), `activityType.typeKey`, `distance` in meters, `movingDuration` and `elapsedDuration` in seconds (floats), `elevationGain` and `elevationLoss` in meters, `averageHR`, `maxHR`, `averageSpeed` and `maxSpeed` in m/s, `avgPower` in watts, `calories` in kcal. Fields can be absent or `null` (for example `averageHR`).
85
+
86
+ Rate limits: neither the FAQ nor the OpenAPI documents `429` or a limit. Handle `429` anyway, without assuming a number.
87
+
88
+ Pagination: `start` is a zero-based offset; `limit` defaults to 99, and rueda requests pages of 99. Advance by the number of records received (not by `limit`, since Garmin may return fewer) and stop on an empty page.
89
+
90
+ Detail response: the type is in `activityTypeDTO.typeKey` and the measurements are in `summaryDTO`, with the same names as the list except `averagePower` (list: `avgPower`) and `averageBikeCadence`/`maxBikeCadence` (list: `averageBikingCadenceInRevPerMinute`/`maxBikingCadenceInRevPerMinute`). Start times look like `2025-10-05T14:55:26.0` instead of `2025-10-05 14:55:26`. Confirmed by `test/fixtures/activity_detail.json`.
91
+
92
+ Type filter: Garmin's `activityType` parameter takes a single parent type key (such as `cycling`), not a `typeKey` like `road_biking`. rueda filters by `--type` itself after fetching and does not send `activityType`.
93
+
94
+ ## Commands
95
+
96
+ ```
97
+ rueda auth check
98
+ rueda activities [--since DATE] [--until DATE] [--type TYPE] [--limit N]
99
+ rueda activity <id>
100
+ rueda activity <id> --fit [-o FILE]
101
+ rueda version
102
+ ```
103
+
104
+ Global options: `--format table|json|ndjson|csv`, `--raw`, `--api-key-file PATH`, `--timeout SECONDS`, `--quiet`.
105
+
106
+ ### `rueda auth check`
107
+
108
+ Checks that a key is available and that Garmin accepts it: it always requests a new token, skipping the cache (so a revoked key fails here), then caches it. Prints when that token expires. Never shows credentials.
109
+
110
+ ### `rueda activities`
111
+
112
+ - `--since` / `--until`: `YYYY-MM-DD` dates, in local time. Defaults to the last 30 days: `--until` defaults to today and `--since` to 30 days before `--until`. Both ends are inclusive.
113
+ - `--type`: filter by Garmin's `typeKey` (see [Activity types](#activity-types)). Repeatable.
114
+ - `--limit`: cap on results. No cap by default within the date range.
115
+ - Order: most recent first.
116
+
117
+ ### `rueda activity <id>`
118
+
119
+ Detail for one activity, using the same schema as the list plus any optional fields the detail provides (none yet). `<id>` must be a positive integer. JSON output is `{"schema_version": 1, "activity": {...}}`; `ndjson`, `csv` and `table` print one activity as they would in a list. `--raw` prints Garmin's detail object.
120
+
121
+ With `--fit`, downloads the original ZIP. By default it saves `activity-<id>.zip` in the current directory and reports the path on stderr. It does not overwrite an existing file unless `--force` is given (exit 2 otherwise). With `-o -` it writes to stdout, and refuses if stdout is a terminal. A response that isn't a ZIP exits 8 and writes nothing.
122
+
123
+ ## Output
124
+
125
+ ### Formats
126
+
127
+ | Format | Use |
128
+ | -------- | -------------------------------------------------------------------------- |
129
+ | `table` | Default when stdout is a terminal. For reading, not parsing. |
130
+ | `json` | Default when stdout is not a terminal. An object with metadata and a list. |
131
+ | `ndjson` | One activity per line. For streams and `jq`. |
132
+ | `csv` | Header in the first row; columns in schema order. |
133
+
134
+ `--raw` returns Garmin's JSON unnormalized (only with `json`/`ndjson`): a plain array of Garmin's activity objects with `json`, one per line with `ndjson`, and no envelope. `--type` and `--limit` still apply. With `--raw` and no `--format`, the output is `json` even in a terminal. Useful for debugging and for reading fields not yet mapped. Its shape is not guaranteed.
135
+
136
+ The `table` columns for activities are `DATE` (`local_date`), `TYPE`, `NAME`, `KM`, `MOVING` (moving time as `h:mm:ss`), `KM/H` (average), `ELEV` (gain), `HR` (average) and `CAD` (average biking cadence). Missing values show as `—`, and control characters (such as terminal escape codes) are removed. `ndjson` prints nothing for an empty result; `table` and `csv` print just the header.
137
+
138
+ ### JSON envelope
139
+
140
+ ```json
141
+ {
142
+ "schema_version": 1,
143
+ "query": { "since": "2026-10-01", "until": "2026-11-10", "type": ["road_biking"] },
144
+ "count": 1,
145
+ "activities": [{ "...": "see schema" }]
146
+ }
147
+ ```
148
+
149
+ ### Activity schema (v1)
150
+
151
+ | Field | Type | Notes |
152
+ | ------------------ | --------------- | ---------------------------------------------------------------------------------------------- |
153
+ | `garmin_id` | integer | Stable Garmin identifier. |
154
+ | `name` | string \| null | Activity name. |
155
+ | `type` | string | Garmin's `typeKey`, unchanged. |
156
+ | `started_at` | string | ISO 8601, UTC. |
157
+ | `local_date` | string | `YYYY-MM-DD` in the activity's local time. |
158
+ | `distance_km` | number \| null | One decimal place. |
159
+ | `moving_time_s` | integer \| null | From `movingDuration`, rounded. |
160
+ | `elapsed_time_s` | integer \| null | From `elapsedDuration` (not `duration`), rounded. |
161
+ | `avg_speed_kmh` | number \| null | Computed from distance and moving time, one decimal place. Falls back to elapsed time if moving time is missing. |
162
+ | `max_speed_kmh` | number \| null | One decimal place. |
163
+ | `elevation_gain_m` | integer \| null | |
164
+ | `elevation_loss_m` | integer \| null | |
165
+ | `avg_hr_bpm` | integer \| null | |
166
+ | `max_hr_bpm` | integer \| null | |
167
+ | `avg_power_w` | integer \| null | |
168
+ | `calories` | integer \| null | |
169
+ | `avg_cadence_rpm` | integer \| null | Biking cadence, revolutions per minute. `null` when Garmin has none. |
170
+ | `max_cadence_rpm` | integer \| null | Biking cadence, revolutions per minute. `null` when Garmin has none. |
171
+
172
+ Schema rules:
173
+
174
+ - Units go in the field name.
175
+ - Missing data is `null`, never `0`.
176
+ - New fields are added without bumping `schema_version`. Renaming, removing, or changing a field's type does bump it.
177
+ - Until v1 is first released, the schema can still change without a bump.
178
+
179
+ ### Activity types
180
+
181
+ `type` is Garmin's `typeKey` as-is, with no mapping (for example `road_biking`). Seen in fixtures: `road_biking`. The OpenAPI's examples use `road_cycling`, but the fixtures show `road_biking`; the fixtures win.
182
+
183
+ ## Errors and exit codes
184
+
185
+ Data goes to stdout; logs, warnings, and errors go to stderr. With `--format json`, errors are also emitted as JSON on stderr:
186
+
187
+ ```json
188
+ { "error": { "code": "auth_failed", "message": "The key has expired or been revoked." } }
189
+ ```
190
+
191
+ | Exit code | `error.code` | Case |
192
+ | --------- | -------------- | ------------------------------------------------------------ |
193
+ | 0 | — | OK (including an empty result). |
194
+ | 1 | `unexpected` | Unexpected error (a bug in rueda). |
195
+ | 2 | `usage` | Invalid arguments. |
196
+ | 3 | `no_api_key` | No key found. |
197
+ | 4 | `auth_failed` | Garmin rejected the key or token (`401` or `403`). |
198
+ | 5 | `not_found` | Garmin returned `404` (for example, no such activity). |
199
+ | 6 | `rate_limited` | Garmin returned `429`. |
200
+ | 7 | `network` | Timeout or connection failure. |
201
+ | 8 | `bad_response` | Response had an unexpected shape or was malformed. |
202
+ | 9 | `garmin_error` | Garmin returned any other error status (for example `5xx`). |
203
+
204
+ ## Tests
205
+
206
+ - Unit tests for `normalize/` against real (anonymized) JSON fixtures.
207
+ - CLI integration tests with WebMock: output formats, exit codes, and key precedence.
208
+ - A separate command or script, outside CI, to record fixtures with a real key.
209
+
210
+ ## Roadmap
211
+
212
+ Out of scope for v1, in order:
213
+
214
+ 1. **Health data:** sleep, HRV, Body Battery, resting heart rate. Same pattern: endpoint → normalizer → schema.
215
+ 2. **MCP server:** an adapter over the same core, for clients without a shell.
216
+
217
+ ## Open decisions
218
+
219
+ - **Aggregates (`rueda stats`).** Out for now: the client fetches data and consumers do the math. Reconsider if agents keep repeating the same calculations.
220
+ - **Distribution:** an installable gem, or also a standalone binary.
221
+ - **Confirm `rueda` is available** on RubyGems and GitHub.
222
+
223
+ ## References
224
+
225
+ - [Garmin Connect Personal API](https://developer.garmin.com/gc-developer-program/personal/personal-api/)
226
+ - [Personal API FAQ](https://developer.garmin.com/gc-developer-program/personal/personal-faq/)
227
+ - [Personal API Swagger](https://developer.garmin.com/gc-developer-program/personal/swagger-docs/)
228
+ - [Official Python client](https://github.com/garmin/connect-personal-api-python)
data/exe/rueda ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/rueda"
4
+
5
+ Rueda::CLI.new.run(ARGV)
@@ -0,0 +1,33 @@
1
+ require_relative "normalize/activity"
2
+
3
+ module Rueda
4
+ class Activities
5
+ def initialize(client:, authenticator:)
6
+ @client = client
7
+ @authenticator = authenticator
8
+ end
9
+
10
+ def list(from:, to:, types:, limit:, raw: false)
11
+ @authenticator.with_token do |token|
12
+ matching = @client.each_activity(token, from:, to:).lazy.select { of_type?(it, types) }
13
+ matching = matching.map { Normalize::Activity.call(it) } unless raw
14
+ limit ? matching.first(limit) : matching.to_a
15
+ end
16
+ end
17
+
18
+ def find(id, raw: false)
19
+ detail = @authenticator.with_token { @client.activity(it, id) }
20
+ raw ? detail : Normalize::ActivityDetail.call(detail)
21
+ end
22
+
23
+ def fit_file(id)
24
+ @authenticator.with_token { @client.activity_file(it, id) }
25
+ end
26
+
27
+ private
28
+
29
+ def of_type?(raw, types)
30
+ types.empty? || types.include?(Normalize::Activity.type(raw))
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ require_relative "../errors"
2
+ require_relative "secret_file"
3
+
4
+ module Rueda
5
+ module Auth
6
+ class ApiKey
7
+ ENV_VAR = "GARMIN_API_KEY".freeze
8
+
9
+ def initialize(file:, env:, default_path:, warn:)
10
+ @file = file
11
+ @env = env
12
+ @default_path = default_path
13
+ @warn = warn
14
+ end
15
+
16
+ def value
17
+ from_env || from_file || raise(NoApiKey, missing_message)
18
+ end
19
+
20
+ private
21
+
22
+ def from_env
23
+ presence(@env[ENV_VAR]) unless @file
24
+ end
25
+
26
+ def from_file
27
+ presence(SecretFile.new(@file || @default_path, warn: @warn).read)
28
+ end
29
+
30
+ def presence(text)
31
+ text = text.to_s.strip
32
+ text unless text.empty?
33
+ end
34
+
35
+ def missing_message
36
+ if @file
37
+ "No key in the file passed with --api-key-file."
38
+ else
39
+ "No API key: set #{ENV_VAR} or create #{@default_path}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ require_relative "../errors"
2
+
3
+ module Rueda
4
+ module Auth
5
+ class Authenticator
6
+ def initialize(api_key:, client:, cache:)
7
+ @api_key = api_key
8
+ @client = client
9
+ @cache = cache
10
+ end
11
+
12
+ def token
13
+ @cache.fetch(@api_key) || fresh_token
14
+ end
15
+
16
+ def fresh_token
17
+ @client.token(@api_key).tap { @cache.store(@api_key, it) }
18
+ end
19
+
20
+ def with_token
21
+ current = token
22
+ begin
23
+ yield current
24
+ rescue AuthFailed
25
+ yield fresh_token
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,43 @@
1
+ require "fileutils"
2
+
3
+ module Rueda
4
+ module Auth
5
+ class SecretFile
6
+ PRIVATE = 0o600
7
+
8
+ def initialize(path, warn:)
9
+ @path = path
10
+ @warn = warn
11
+ end
12
+
13
+ def read
14
+ return unless File.file?(@path)
15
+
16
+ warn_if_loose
17
+ File.read(@path)
18
+ end
19
+
20
+ def write(content)
21
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
22
+ File.open(@path, "w", PRIVATE) do |file|
23
+ file.chmod(PRIVATE)
24
+ file.write(content)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def warn_if_loose
31
+ @warn.call("#{@path} has permissions #{format("%04o", mode)}; it should be 0600") if loose?
32
+ end
33
+
34
+ def loose?
35
+ mode.anybits?(0o077)
36
+ end
37
+
38
+ def mode
39
+ File.stat(@path).mode & 0o777
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ require "json"
2
+ require_relative "../errors"
3
+
4
+ module Rueda
5
+ module Auth
6
+ class Token
7
+ FORMAT = /\A[\w-]+\.[\w-]+\.[\w-]*\z/
8
+
9
+ attr_reader :expires_at
10
+
11
+ def initialize(jwt)
12
+ raise BadResponse, "The token is not a JWT." unless jwt.match?(FORMAT)
13
+
14
+ @jwt = jwt
15
+ @expires_at = Time.at(Integer(claims.fetch("exp"))).utc
16
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError
17
+ raise BadResponse, "The token has no readable expiry."
18
+ end
19
+
20
+ def expires_within?(seconds)
21
+ expires_at - Time.now < seconds
22
+ end
23
+
24
+ def to_s = @jwt
25
+
26
+ def inspect = "#<#{self.class} expires_at=#{expires_at}>"
27
+
28
+ private
29
+
30
+ def claims
31
+ JSON.parse(@jwt.split(".")[1].tr("-_", "+/").unpack1("m"))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ require "digest"
2
+ require "json"
3
+ require_relative "../errors"
4
+ require_relative "secret_file"
5
+ require_relative "token"
6
+
7
+ module Rueda
8
+ module Auth
9
+ class TokenCache
10
+ MIN_REMAINING = 3600
11
+
12
+ def initialize(path:, warn:)
13
+ @file = SecretFile.new(path, warn:)
14
+ @warn = warn
15
+ end
16
+
17
+ def fetch(key)
18
+ content = @file.read
19
+ return unless content
20
+
21
+ data = JSON.parse(content)
22
+ return unless data.is_a?(Hash) && data["key_sha256"] == digest(key)
23
+
24
+ token = Token.new(data["token"].to_s)
25
+ token unless token.expires_within?(MIN_REMAINING)
26
+ rescue JSON::ParserError, SystemCallError, BadResponse
27
+ nil
28
+ end
29
+
30
+ def store(key, token)
31
+ @file.write(JSON.generate(key_sha256: digest(key), token: token.to_s))
32
+ rescue SystemCallError => e
33
+ @warn.call("could not cache the token (#{e.class})")
34
+ end
35
+
36
+ private
37
+
38
+ def digest(key) = Digest::SHA256.hexdigest(key)
39
+ end
40
+ end
41
+ end