rodauth-api_keys 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: ad5a7297a151e1a68b312507d366ab66f522a20281e79c03f1de348910efb449
4
+ data.tar.gz: f855c8e5d9460b3e8a1a872ea8498a4442407f0893224f485d5408d2b4a65c22
5
+ SHA512:
6
+ metadata.gz: 0d859b69ae9c618bfed8158ac04c1a47190726d90fd8a34c4677b0e0e752bed5724a617f16c35a0fa8b1d3c4713789ef7b29234a58fb529b6938a0980f410fdc
7
+ data.tar.gz: 4fdf738230d2cfd3716b933abd85b55d9a44f842bef2d21b576149a119b58e5ce019e83c7c42f368932f31fa367f0d5525095effc11deba5113de2965e04248b
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-03)
4
+
5
+ First version.
6
+
7
+ - Add the `api_keys` feature for Rodauth.
8
+ - Add the `create-api-key`, `api-keys`, and `revoke-api-key` routes, with HTML templates and JSON responses.
9
+ - Authenticate a request with an API key in the `Authorization` header. The request does not use the cookie session.
10
+ - Keep only the key digest and the key hint in the database. Show the full API key one time only.
11
+ - Add scopes, an optional expiration date, and a limit of active API keys for each account.
12
+ - Record the last use of each API key.
13
+ - Send the expiration time of the API key in the `api-key-expiration` response header.
14
+ - Revoke all API keys of an account when the account closes. Optionally revoke them when the password changes.
15
+ - Add internal request methods: `create_api_key`, `api_keys`, and `revoke_api_key`.
16
+ - Register an `api_keys` association for rodauth-model.
17
+ - Support the `json`, `jwt`, `active_sessions`, `single_session`, `two_factor_base`, `audit_logging`, and `internal_request` features.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Pavel Dušánek
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,378 @@
1
+ # rodauth-api_keys
2
+
3
+ The `api_keys` feature for [Rodauth](https://github.com/jeremyevans/rodauth) lets an account create, list, and revoke API keys.
4
+ A client sends an API key in the `Authorization` header. Rodauth then authenticates the request as the account that owns the API key.
5
+
6
+ ```
7
+ Authorization: Bearer rak_Qb4JPgNpNhcsdekspshofQVvGUcpchLRstT6HAu7EQS
8
+ ```
9
+
10
+ ## Contents
11
+
12
+ - [Features](#features)
13
+ - [Installation](#installation)
14
+ - [Database migration](#database-migration)
15
+ - [Configuration](#configuration)
16
+ - [Authenticate API requests](#authenticate-api-requests)
17
+ - [Management pages](#management-pages)
18
+ - [JSON API](#json-api)
19
+ - [Internal requests](#internal-requests)
20
+ - [Model association](#model-association)
21
+ - [Configuration reference](#configuration-reference)
22
+ - [Security](#security)
23
+ - [Development](#development)
24
+
25
+ ## Features
26
+
27
+ - An API key has a configurable prefix, for example `myapp_`. Secret scanners, such as GitHub secret scanning, can find a leaked API key by its prefix.
28
+ - The database keeps only an HMAC digest and a short hint of each API key. The account sees the full API key one time only.
29
+ - An API key request does not use the cookie session and does not set a cookie.
30
+ - An API key can have scopes and an expiration date.
31
+ - The account can revoke an API key. Rodauth keeps the row and sets `revoked_at`.
32
+ - Each account can have a maximum number of active API keys.
33
+ - HTML pages and JSON responses for the management routes.
34
+ - Internal request methods for create, list, and revoke.
35
+ - The feature works with the `json`, `jwt`, `active_sessions`, `single_session`, `two_factor_base`, `close_account`, `change_password`, `reset_password`, `audit_logging`, and `internal_request` features.
36
+
37
+ The feature uses only Rodauth and the Ruby standard library.
38
+
39
+ ## Installation
40
+
41
+ Add the gem to the `Gemfile` of the application:
42
+
43
+ ```ruby
44
+ gem "rodauth-api_keys"
45
+ ```
46
+
47
+ Requirements:
48
+
49
+ - Ruby 3.3 or later
50
+ - Rodauth 2.48 or later
51
+
52
+ ## Database migration
53
+
54
+ Add a table for the API keys. This example uses Sequel:
55
+
56
+ ```ruby
57
+ Sequel.migration do
58
+ change do
59
+ create_table(:account_api_keys) do
60
+ primary_key :id, type: :Bignum
61
+ foreign_key :account_id, :accounts, type: :Bignum, null: false
62
+ String :name, null: false
63
+ String :digest, null: false, unique: true
64
+ String :hint, null: false
65
+ String :scopes
66
+ DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
67
+ DateTime :last_use
68
+ DateTime :expires_at
69
+ DateTime :revoked_at
70
+ index [:account_id, :revoked_at]
71
+ end
72
+ end
73
+ end
74
+ ```
75
+
76
+ If the `accounts` table uses UUID primary keys, change the type of `account_id` to `:uuid`.
77
+ You can change the table name and each column name with the configuration methods (see [Configuration reference](#configuration-reference)).
78
+
79
+ ## Configuration
80
+
81
+ Enable the feature and set `hmac_secret`. The feature calculates the key digest with `hmac_secret`.
82
+
83
+ ```ruby
84
+ plugin :rodauth do
85
+ enable :login, :logout, :api_keys
86
+ hmac_secret ENV.fetch("RODAUTH_HMAC_SECRET")
87
+
88
+ api_key_prefix "myapp"
89
+ api_key_scopes %w[projects:read projects:write]
90
+ api_key_max_lifetime 86400 * 365
91
+ end
92
+ ```
93
+
94
+ Feature order: enable `api_keys` after `jwt`, `active_sessions`, `single_session`, `two_factor_base`, and the features that use `two_factor_base` (`otp`, `sms_codes`, `webauthn`, `recovery_codes`).
95
+ If the order is wrong, Rodauth raises `Rodauth::ConfigurationError` when the application starts.
96
+
97
+ ```ruby
98
+ enable :login, :logout, :jwt, :otp, :recovery_codes, :api_keys
99
+ ```
100
+
101
+ ## Authenticate API requests
102
+
103
+ When a request has an `Authorization: Bearer <prefix>_...` header, the feature finds the API key in the database.
104
+ If the API key is active and the account is open, these methods work as in a cookie session:
105
+
106
+ - `rodauth.require_authentication`, `rodauth.require_account`
107
+ - `rodauth.logged_in?`, `rodauth.session_value`, `rodauth.account_from_session`
108
+
109
+ If the API key is not valid, the response is `401 Unauthorized` with `WWW-Authenticate: Bearer realm="api", error="invalid_token"`.
110
+ The feature does not use the cookie session as a fallback.
111
+
112
+ Example Roda routes:
113
+
114
+ ```ruby
115
+ route do |r|
116
+ r.rodauth
117
+
118
+ r.on "api" do
119
+ # Accept only API keys.
120
+ rodauth.require_api_key_authentication
121
+
122
+ r.get "projects" do
123
+ rodauth.require_api_key_scope("projects:read")
124
+ # rodauth.session_value is the ID of the account that owns the API key.
125
+ # ...
126
+ end
127
+
128
+ r.post "projects" do
129
+ rodauth.require_api_key_scope("projects:write")
130
+ # ...
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ Example client request:
137
+
138
+ ```sh
139
+ curl -H "Authorization: Bearer myapp_Qb4JPgNpNhcsdekspshofQVvGUcpchLRstT6HAu7EQS" https://example.com/api/projects
140
+ ```
141
+
142
+ Methods for the application:
143
+
144
+ | Method | Description |
145
+ |---|---|
146
+ | `rodauth.api_key_authenticated?` | True if an API key authenticated the request. |
147
+ | `rodauth.require_api_key_authentication` | Send `401` if no valid API key authenticated the request. |
148
+ | `rodauth.current_api_key_id` | The ID of the API key of the request, or `nil`. |
149
+ | `rodauth.current_api_key_scopes` | The scopes of the API key of the request, or `nil`. |
150
+ | `rodauth.api_key_scope?(scope)` | True if the request has the scope. A cookie session has all scopes. |
151
+ | `rodauth.require_api_key_scope(*scopes)` | Require authentication. Then send `403` with `error="insufficient_scope"` if the API key does not have all the scopes. A cookie session has all scopes. |
152
+
153
+ ### Two-factor authentication
154
+
155
+ An API key counts as full authentication. The account used all its authentication factors when it created the API key.
156
+
157
+ ### Last use
158
+
159
+ The feature records the time of use in `last_use`. It updates the column at most one time in `api_key_last_use_update_interval` seconds (default 60).
160
+ Set the value to `nil` to update the column on each request.
161
+
162
+ ### Expiration header
163
+
164
+ When an API key has an expiration time, each response to a request with that API key contains it in the `api-key-expiration` header.
165
+ The value is an HTTP date:
166
+
167
+ ```
168
+ api-key-expiration: Thu, 31 Dec 2026 22:59:59 GMT
169
+ ```
170
+
171
+ A client can use the header to replace the API key before it expires. An API key without an expiration time gets no header.
172
+ No standard header exists for this value. GitHub uses a similar header, `GitHub-Authentication-Token-Expiration`.
173
+ The name does not start with `X-`, because [RFC 6648](https://www.rfc-editor.org/rfc/rfc6648) recommends against that prefix.
174
+
175
+ ```ruby
176
+ api_key_expiration_header "myapp-key-expiration" # Change the header name.
177
+ api_key_expiration_header nil # Remove the header.
178
+ api_key_expiration_header_value do |expires_at| # Change the format of the value.
179
+ expires_at.utc.iso8601
180
+ end
181
+ ```
182
+
183
+ ## Management pages
184
+
185
+ | Route | Description |
186
+ |---|---|
187
+ | `/api-keys` | The list of API keys of the account: name, hint, scopes, created, last use, expiration date, and status. |
188
+ | `/create-api-key` | A form to create an API key. The next page shows the full API key one time. |
189
+ | `/revoke-api-key` | A form to revoke an active API key. |
190
+
191
+ Rules for these routes:
192
+
193
+ - The account must be logged in.
194
+ - A request that an API key authenticated gets `403`. An API key cannot create or revoke API keys. See [Security](#security).
195
+ - If the account has a password, the form asks for it.
196
+ - The page that shows the new API key sends `Cache-Control: no-store`.
197
+
198
+ The expiration date field accepts a date (`2026-12-31`) or a full ISO 8601 time (`2026-12-31T12:00:00Z`).
199
+ A date without a time is the last second of that day (23:59:59), in the time zone of the application.
200
+ The database calculates `expires_at` with its own clock, as Rodauth does for its deadlines.
201
+
202
+ To change a page, put a template with the same name in the views directory of the application:
203
+ `api-keys.str`, `create-api-key.str`, `api-key-created.str`, or `revoke-api-key.str`.
204
+
205
+ ## JSON API
206
+
207
+ With the `json` feature, the management routes accept and return JSON. All requests use `POST`.
208
+
209
+ Create an API key:
210
+
211
+ ```sh
212
+ curl -X POST https://example.com/create-api-key \
213
+ -H "Content-Type: application/json" -H "Authorization: <JWT>" \
214
+ -d '{"api_key_name": "CI server", "password": "...", "api_key_scopes": ["projects:read"], "api_key_expires_at": "2026-12-31"}'
215
+ ```
216
+
217
+ ```json
218
+ {
219
+ "api_key": "myapp_Qb4JPgNpNhcsdekspshofQVvGUcpchLRstT6HAu7EQS",
220
+ "id": 1,
221
+ "name": "CI server",
222
+ "hint": "myapp_Qb4J",
223
+ "scopes": ["projects:read"],
224
+ "created_at": "2026-10-02T12:00:00+00:00",
225
+ "last_use": null,
226
+ "expires_at": "2026-12-31T23:59:59+00:00",
227
+ "revoked_at": null,
228
+ "status": "active",
229
+ "success": "Your API key is ready. Copy it now. You cannot see it again."
230
+ }
231
+ ```
232
+
233
+ - `POST /api-keys` returns `{"api_keys": [...]}`. Each item has the same fields, but no `api_key`.
234
+ - `POST /revoke-api-key` with `{"api_key_id": 1, "password": "..."}` returns `{"success": "The API key is revoked"}`.
235
+ - An error returns `{"error": "...", "field-error": ["<parameter>", "<message>"]}`.
236
+
237
+ The `api_key_scopes` parameter can also be a string with scopes separated by spaces.
238
+
239
+ ## Internal requests
240
+
241
+ With the `internal_request` feature:
242
+
243
+ ```ruby
244
+ result = App.rodauth.create_api_key(account_login: "user@example.com", api_key_name: "CI server", api_key_scopes: ["projects:read"])
245
+ result[:api_key] # => "myapp_..."
246
+
247
+ App.rodauth.api_keys(account_login: "user@example.com")
248
+ # => [{id: 1, name: "CI server", hint: "myapp_Qb4J", scopes: ["projects:read"], status: :active, ...}]
249
+
250
+ App.rodauth.revoke_api_key(account_login: "user@example.com", api_key_id: result[:id])
251
+ ```
252
+
253
+ Internal requests do not ask for the password. An error raises `Rodauth::InternalRequestError`.
254
+
255
+ ## Model association
256
+
257
+ With [rodauth-model](https://github.com/janko/rodauth-model), the account model gets an `api_keys` association.
258
+ Require `rodauth/model` before you enable `api_keys`. The feature registers the association only when `Rodauth::Model` is defined.
259
+
260
+ ```ruby
261
+ class Account < Sequel::Model
262
+ include Rodauth::Model(RodauthApp.rodauth)
263
+ end
264
+
265
+ account.api_keys # => [#<Account::ApiKey @values={id: 1, name: "CI server", ...}>]
266
+ account.api_keys_dataset.where(revoked_at: nil)
267
+ ```
268
+
269
+ The rows contain the key digest and the key hint. They do not contain the API key.
270
+ When you remove the account with `destroy`, the model also removes its API key rows.
271
+
272
+ ## Configuration reference
273
+
274
+ ### Values
275
+
276
+ | Method | Default | Description |
277
+ |---|---|---|
278
+ | `api_key_prefix` | `"rak"` | The prefix of each API key. Letters and digits, with single underscores between them. |
279
+ | `api_key_secret_length` | `43` | The number of random letters and digits after the prefix (approximately 256 bits). |
280
+ | `api_key_hint_length` | `4` | The number of secret characters in the hint. |
281
+ | `api_key_realm` | `"api"` | The realm in the `WWW-Authenticate` header. |
282
+ | `api_key_authorization_regexp` | `/\ABearer\s+(<prefix>_[A-Za-z0-9]+)\s*\z/` | Finds the API key in the `Authorization` header. The first capture group must contain the API key. |
283
+ | `api_keys_limit` | `10` | The maximum number of active API keys for each account. `nil` removes the limit. |
284
+ | `api_key_name_max_length` | `100` | The maximum length of the name. |
285
+ | `api_key_scopes` | `[]` | The permitted scope names. If the list is empty, the feature does not use scopes. |
286
+ | `api_key_max_lifetime` | `nil` | The maximum lifetime in seconds. A value makes the expiration date necessary. |
287
+ | `api_key_last_use_update_interval` | `60` | The minimum number of seconds between two updates of `last_use`. |
288
+ | `api_key_expiration_header` | `"api-key-expiration"` | The response header with the expiration time of the API key. `nil` removes the header. |
289
+ | `revoke_api_keys_on_password_change?` | `false` | Revoke all API keys after a password change or a password reset. |
290
+ | `api_keys_table` | `:account_api_keys` | The table name. |
291
+ | `api_keys_*_column` | see migration | One method for each column: `id`, `account_id`, `name`, `digest`, `hint`, `scopes`, `created_at`, `last_use`, `expires_at`, `revoked_at`. |
292
+ | `api_key_name_param`, `api_key_expires_at_param`, `api_key_scopes_param`, `api_key_id_param` | `"api_key_name"`, ... | Parameter names. |
293
+ | `api_keys_route`, `create_api_key_route`, `revoke_api_key_route` | `"api-keys"`, ... | Route names. |
294
+ | `insufficient_api_key_scope_error_status` | `403` | The status for a missing scope. |
295
+ | `api_key_management_not_permitted_error_status` | `403` | The status for a request to a Rodauth route with an API key. |
296
+
297
+ To accept the `Token` scheme too:
298
+
299
+ ```ruby
300
+ api_key_authorization_regexp(/\A(?:Bearer|Token)\s+(myapp_[A-Za-z0-9]+)\s*\z/)
301
+ ```
302
+
303
+ ### Messages and labels
304
+
305
+ You can change each text with its configuration method, for example `invalid_api_key_message "API key not valid"`.
306
+ The texts:
307
+
308
+ - Messages: `invalid_api_key_message`, `api_key_required_message`, `insufficient_api_key_scope_message`, `api_key_management_not_permitted_message`, `invalid_api_key_name_message`, `invalid_api_key_expires_at_message`, `api_key_expires_at_required_message`, `api_key_expires_at_past_message`, `api_key_expires_at_too_late_message`, `invalid_api_key_scopes_message`, `api_key_scopes_required_message`, `api_keys_limit_message`, `invalid_api_key_id_message`, `no_active_api_keys_message`, `api_keys_empty_message`.
309
+ - Flash messages: `create_api_key_notice_flash`, `create_api_key_error_flash`, `revoke_api_key_notice_flash`, `revoke_api_key_error_flash`.
310
+ - Labels and buttons: the `*_label`, `*_link_text`, `*_button`, and `*_page_title` methods.
311
+
312
+ ### Hooks
313
+
314
+ `before_api_keys_route`, `before_create_api_key_route`, `before_create_api_key`, `after_create_api_key`, `before_revoke_api_key_route`, `before_revoke_api_key`, `after_revoke_api_key`.
315
+
316
+ With the `audit_logging` feature, Rodauth logs the `create_api_key` and `revoke_api_key` actions.
317
+
318
+ ### Methods that you can override
319
+
320
+ `generate_api_key`, `api_key_digest`, `api_key_digests`, `api_key_hint`, `api_key_from_request`, `api_key_insert_hash`, `create_api_key`, `revoke_api_key`, `revoke_all_api_keys`, `account_api_keys`, `valid_api_key_name?`, `valid_api_key_scopes?`, `parse_api_key_expires_at`, `update_api_key_last_use`, `api_key_expiration_header_value`, `api_key_created_response`, `api_key_authenticated?`, `api_key_scope?`, `require_api_key_authentication`, `require_api_key_scope`.
321
+
322
+ To add a column to each new row, override `api_key_insert_hash`:
323
+
324
+ ```ruby
325
+ api_key_insert_hash do |*args|
326
+ super(*args).merge(created_ip: request.ip)
327
+ end
328
+ ```
329
+
330
+ ## Security
331
+
332
+ - The database keeps the HMAC-SHA256 digest of the API key, not the API key. A copy of the database does not give the API keys without `hmac_secret`.
333
+ - During a rotation of `hmac_secret`, set `hmac_old_secret`. The feature accepts the old digest and writes the new digest at the next use of the API key.
334
+ - The feature does not write the API key to logs or to error messages.
335
+ - An unverified account cannot use API keys. This is also true in the grace period of `verify_account_grace_period`.
336
+ - A closed account cannot use its API keys. `close_account` revokes them. If `close_account` calls `delete_account`, the feature removes the API key rows first.
337
+ - With the `jwt` feature, a response to an API key request does not contain a JWT. Such a JWT would authenticate the account without the API key.
338
+ - An API key cannot use the Rodauth routes. For example, it cannot change the login, set a remember cookie, or create API keys. The response is `403`.
339
+ The feature prepends a check to `before_rodauth`. A `before_rodauth` block in your configuration does not remove the check.
340
+
341
+ ### Remove old rows
342
+
343
+ The feature does not remove revoked or expired rows. To remove rows that are older than 90 days, use a command like this one:
344
+
345
+ ```ruby
346
+ DB.extension :date_arithmetic
347
+ cutoff = Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, days: 90)
348
+ DB[:account_api_keys].where { (revoked_at < cutoff) | (expires_at < cutoff) }.delete
349
+ ```
350
+
351
+ ## Development
352
+
353
+ After you clone the repository, run `bin/setup` to install the dependencies.
354
+
355
+ - Run the tests and the linter: `bundle exec rake`
356
+ - Run the tests only: `bundle exec rake test`
357
+ - Run the linter only: `bundle exec rake standard`
358
+ - Start a console: `bin/console`
359
+
360
+ To release a new version:
361
+
362
+ 1. Change the version number in `rodauth-api_keys.gemspec`.
363
+ 2. Add the version and the date to `CHANGELOG.md`.
364
+ 3. Commit the change and push it to `main`.
365
+ 4. On GitHub, start the "Release" workflow (`.github/workflows/release.yml`) on `main`.
366
+
367
+ The workflow runs the tests and the linter. Then it runs `bundle exec rake release` with the [Release Gem](https://github.com/rubygems/release-gem) action.
368
+ This command makes a git tag for the version, pushes the tag, and pushes the `.gem` file to [rubygems.org](https://rubygems.org).
369
+ If the tag exists, the command does not make it again.
370
+ The workflow uses [trusted publishing](https://guides.rubygems.org/trusted-publishing/). It does not need an API key of rubygems.org.
371
+
372
+ ## Contributing
373
+
374
+ Send bug reports and pull requests on GitHub at https://github.com/dush/rodauth-api_keys.
375
+
376
+ ## License
377
+
378
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,699 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Rodauth
7
+ # Rodauth calls before_rodauth at the start of each route.
8
+ # A before_rodauth block in the configuration replaces the method of a feature.
9
+ # Thus the api_keys feature prepends this module to the Rodauth class. The block cannot remove the check.
10
+ module ApiKeysRouteCheck
11
+ private
12
+
13
+ def before_rodauth
14
+ require_api_key_management_session
15
+ super
16
+ end
17
+ end
18
+
19
+ Feature.define(:api_keys, :ApiKeys) do
20
+ depends :require_hmac_secret
21
+
22
+ # Page to create an API key.
23
+ notice_flash "Your API key is ready. Copy it now. You cannot see it again.", "create_api_key"
24
+ error_flash "Unable to create the API key", "create_api_key"
25
+ loaded_templates %w[api-keys create-api-key api-key-created revoke-api-key password-field]
26
+ view "create-api-key", "Create API Key", "create_api_key"
27
+ view "api-key-created", "API Key Created", "api_key_created"
28
+ additional_form_tags "create_api_key"
29
+ button "Create API Key", "create_api_key"
30
+ before "create_api_key"
31
+ after "create_api_key"
32
+
33
+ translatable_method :api_key_label, "API key"
34
+ translatable_method :api_key_name_label, "Name"
35
+ translatable_method :api_key_expires_at_label, "Expiration date"
36
+ translatable_method :api_key_scopes_label, "Scopes"
37
+ translatable_method :invalid_api_key_name_message, "invalid name"
38
+ translatable_method :invalid_api_key_expires_at_message, "invalid expiration date"
39
+ translatable_method :api_key_expires_at_required_message, "expiration date is necessary"
40
+ translatable_method :api_key_expires_at_past_message, "expiration date must be in the future"
41
+ translatable_method :api_key_expires_at_too_late_message, "expiration date is too late"
42
+ translatable_method :invalid_api_key_scopes_message, "invalid scope"
43
+ translatable_method :api_key_scopes_required_message, "select one or more scopes"
44
+ translatable_method :api_keys_limit_message, "maximum number of active API keys"
45
+ translatable_method :api_key_management_not_permitted_message, "an API key cannot use this route"
46
+ auth_value_method :api_key_management_not_permitted_error_status, 403
47
+
48
+ # Page with the list of API keys.
49
+ view "api-keys", "API Keys", "api_keys"
50
+ translatable_method :api_key_created_at_label, "Created"
51
+ translatable_method :api_key_last_use_label, "Last use"
52
+ translatable_method :api_key_status_label, "Status"
53
+ translatable_method :api_key_active_label, "Active"
54
+ translatable_method :api_key_expired_label, "Expired"
55
+ translatable_method :api_key_revoked_label, "Revoked"
56
+ translatable_method :api_key_never_label, "Never"
57
+ translatable_method :api_keys_empty_message, "There are no API keys."
58
+ translatable_method :create_api_key_link_text, "Create API Key"
59
+ translatable_method :revoke_api_key_link_text, "Revoke API Key"
60
+
61
+ # Page to revoke an API key.
62
+ notice_flash "The API key is revoked", "revoke_api_key"
63
+ error_flash "Unable to revoke the API key", "revoke_api_key"
64
+ view "revoke-api-key", "Revoke API Key", "revoke_api_key"
65
+ additional_form_tags "revoke_api_key"
66
+ button "Revoke API Key", "revoke_api_key"
67
+ before "revoke_api_key"
68
+ after "revoke_api_key"
69
+ redirect(:revoke_api_key) { api_keys_path }
70
+ response "revoke_api_key"
71
+ translatable_method :invalid_api_key_id_message, "select an active API key"
72
+ translatable_method :no_active_api_keys_message, "There are no active API keys."
73
+
74
+ # Format of an API key: "<api_key_prefix>_<secret>".
75
+ auth_value_method :api_key_prefix, "rak"
76
+ auth_value_method :api_key_secret_length, 43
77
+ auth_value_method :api_key_hint_length, 4
78
+ auth_value_method :api_key_realm, "api"
79
+
80
+ # Database table and columns.
81
+ auth_value_method :api_keys_table, :account_api_keys
82
+ auth_value_method :api_keys_id_column, :id
83
+ auth_value_method :api_keys_account_id_column, :account_id
84
+ auth_value_method :api_keys_name_column, :name
85
+ auth_value_method :api_keys_digest_column, :digest
86
+ auth_value_method :api_keys_hint_column, :hint
87
+ auth_value_method :api_keys_scopes_column, :scopes
88
+ auth_value_method :api_keys_created_at_column, :created_at
89
+ auth_value_method :api_keys_last_use_column, :last_use
90
+ auth_value_method :api_keys_expires_at_column, :expires_at
91
+ auth_value_method :api_keys_revoked_at_column, :revoked_at
92
+
93
+ # Limits and policies.
94
+ auth_value_method :api_keys_limit, 10
95
+ auth_value_method :api_key_name_max_length, 100
96
+ auth_value_method :api_key_scopes, [].freeze
97
+ auth_value_method :api_key_max_lifetime, nil
98
+ auth_value_method :api_key_last_use_update_interval, 60
99
+ # A response to an API key request contains the expiration time of the API key in this header. nil removes the header.
100
+ auth_value_method :api_key_expiration_header, "api-key-expiration"
101
+ auth_value_method :revoke_api_keys_on_password_change?, false
102
+
103
+ # Request parameters.
104
+ auth_value_method :api_key_name_param, "api_key_name"
105
+ auth_value_method :api_key_expires_at_param, "api_key_expires_at"
106
+ auth_value_method :api_key_scopes_param, "api_key_scopes"
107
+ auth_value_method :api_key_id_param, "api_key_id"
108
+
109
+ # Responses for API key authentication.
110
+ session_key :api_key_id_session_key, :api_key_id
111
+ translatable_method :invalid_api_key_message, "invalid API key"
112
+ translatable_method :api_key_required_message, "API key required"
113
+ translatable_method :insufficient_api_key_scope_message, "API key does not have the necessary scope"
114
+ auth_value_method :insufficient_api_key_scope_error_status, 403
115
+
116
+ auth_value_methods :api_key_authorization_regexp
117
+
118
+ internal_request_method :create_api_key
119
+ internal_request_method :api_keys
120
+ internal_request_method :revoke_api_key
121
+
122
+ auth_methods(
123
+ :account_api_keys,
124
+ :revoke_all_api_keys,
125
+ :revoke_api_key,
126
+ :api_key_created_response,
127
+ :parse_api_key_expires_at,
128
+ :valid_api_key_name?,
129
+ :valid_api_key_scopes?,
130
+ :api_key_authenticated?,
131
+ :api_key_digest,
132
+ :api_key_digests,
133
+ :api_key_from_request,
134
+ :api_key_hint,
135
+ :api_key_insert_hash,
136
+ :api_key_scope?,
137
+ :create_api_key,
138
+ :generate_api_key,
139
+ :require_api_key_authentication,
140
+ :require_api_key_scope,
141
+ :update_api_key_last_use,
142
+ :api_key_expiration_header_value
143
+ )
144
+
145
+ uses_instance_variables(:@created_api_key_id, :@created_api_key, :@session, :@api_key_row)
146
+
147
+ # The ID of the row that the last call to create_api_key added.
148
+ attr_reader :created_api_key_id
149
+
150
+ # The API key that the create-api-key route added in this request. The created page shows it.
151
+ attr_reader :created_api_key
152
+
153
+ route(:api_keys) do |r|
154
+ require_account
155
+ before_api_keys_route
156
+ _return_from_internal_request(account_api_keys) if internal_request?
157
+
158
+ if respond_to?(:use_json?) && use_json?
159
+ json_response["api_keys"] = account_api_keys.map { |api_key| api_key_json(api_key) }
160
+ end
161
+
162
+ r.get do
163
+ api_keys_view
164
+ end
165
+
166
+ r.post do
167
+ api_keys_view
168
+ end
169
+ end
170
+
171
+ route(:revoke_api_key) do |r|
172
+ require_account
173
+ before_revoke_api_key_route
174
+
175
+ r.get do
176
+ revoke_api_key_view
177
+ end
178
+
179
+ r.post do
180
+ catch_error do
181
+ unless (id = param_or_nil(api_key_id_param))
182
+ throw_error_reason(:invalid_api_key_id, invalid_field_error_status, api_key_id_param, invalid_api_key_id_message)
183
+ end
184
+
185
+ if modifications_require_password? && !password_match?(param(password_param))
186
+ throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message)
187
+ end
188
+
189
+ transaction do
190
+ before_revoke_api_key
191
+ unless revoke_api_key(id)
192
+ throw_error_reason(:invalid_api_key_id, invalid_field_error_status, api_key_id_param, invalid_api_key_id_message)
193
+ end
194
+ after_revoke_api_key
195
+ end
196
+
197
+ revoke_api_key_response
198
+ end
199
+
200
+ set_error_flash revoke_api_key_error_flash
201
+ revoke_api_key_view
202
+ end
203
+ end
204
+
205
+ route(:create_api_key) do |r|
206
+ require_account
207
+ before_create_api_key_route
208
+
209
+ r.get do
210
+ create_api_key_view
211
+ end
212
+
213
+ r.post do
214
+ catch_error do
215
+ if modifications_require_password? && !password_match?(param(password_param))
216
+ throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message)
217
+ end
218
+
219
+ name = api_key_name_param_value
220
+ scopes = api_key_scopes_param_value
221
+ expires_in = api_key_expires_in_param_value
222
+
223
+ transaction do
224
+ before_create_api_key
225
+ if api_keys_limit && active_api_keys_ds.count >= api_keys_limit
226
+ throw_error_reason(:api_keys_limit, invalid_field_error_status, api_key_name_param, api_keys_limit_message)
227
+ end
228
+ @created_api_key = create_api_key(name, scopes: scopes, expires_in: expires_in)
229
+ after_create_api_key
230
+ end
231
+
232
+ api_key_created_response
233
+ end
234
+
235
+ set_error_flash create_api_key_error_flash
236
+ create_api_key_view
237
+ end
238
+ end
239
+
240
+ # The first capture group must contain the API key.
241
+ def api_key_authorization_regexp
242
+ /\ABearer\s+(#{Regexp.escape(api_key_prefix)}_[A-Za-z0-9]+)\s*\z/
243
+ end
244
+
245
+ def post_configure
246
+ super
247
+
248
+ unless api_key_prefix.is_a?(String) && api_key_prefix.match?(/\A[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*\z/)
249
+ raise ConfigurationError, "api_key_prefix must contain only letters, digits, and single underscores between them: #{api_key_prefix.inspect}"
250
+ end
251
+
252
+ # This feature overrides methods of these features. Thus it must come before them in the method lookup.
253
+ ancestors = self.class.ancestors
254
+ [:two_factor_base, :jwt, :active_sessions, :single_session].each do |feature_name|
255
+ next unless (feature = FEATURES[feature_name]) && ancestors.include?(feature)
256
+ if ancestors.index(feature) < ancestors.index(FEATURES[:api_keys])
257
+ raise ConfigurationError, "enable :api_keys after :#{feature_name} and the features that use it"
258
+ end
259
+ end
260
+
261
+ # An API key must not use the Rodauth routes, for example to change the login or to set a remember cookie.
262
+ self.class.prepend(ApiKeysRouteCheck) unless self.class < ApiKeysRouteCheck
263
+
264
+ api_key_scopes.each do |scope|
265
+ unless scope.is_a?(String) && scope.match?(/\A[!#-\[\]-~]+\z/)
266
+ raise ConfigurationError, "api_key_scopes must contain only strings of printable ASCII characters without spaces, quotes, or backslashes: #{scope.inspect}"
267
+ end
268
+ end
269
+ end
270
+
271
+ # When the request contains an API key, return a session hash for this request only.
272
+ # Rodauth does not write this hash to the session cookie.
273
+ # When the API key is not valid, send a 401 response. Do not use the cookie session.
274
+ def session
275
+ return @session if @session
276
+ return super unless (api_key = api_key_from_request)
277
+
278
+ @session = api_key_session(api_key)
279
+ end
280
+
281
+ # Return the API key from the Authorization header, or nil.
282
+ def api_key_from_request
283
+ (value = request.env["HTTP_AUTHORIZATION"]) && value[api_key_authorization_regexp, 1]
284
+ end
285
+
286
+ # Return true if an API key authenticated the request.
287
+ def api_key_authenticated?
288
+ session
289
+ !@api_key_row.nil?
290
+ end
291
+
292
+ # Return the ID of the API key that authenticated the request, or nil.
293
+ def current_api_key_id
294
+ @api_key_row[api_keys_id_column] if api_key_authenticated?
295
+ end
296
+
297
+ # Return the scopes of the API key that authenticated the request, or nil.
298
+ def current_api_key_scopes
299
+ @api_key_row[api_keys_scopes_column].to_s.split(" ") if api_key_authenticated?
300
+ end
301
+
302
+ # Send a 401 response if no valid API key authenticated the request.
303
+ def require_api_key_authentication
304
+ return if api_key_authenticated?
305
+
306
+ set_response_error_reason_status(:api_key_required, login_required_error_status)
307
+ set_response_header("www-authenticate", "Bearer realm=\"#{api_key_realm}\"")
308
+ return_api_key_error_response(api_key_required_message)
309
+ end
310
+
311
+ # Return true if the request has permission for the scope.
312
+ # A request that the cookie session authenticated has all scopes.
313
+ def api_key_scope?(scope)
314
+ if api_key_authenticated?
315
+ current_api_key_scopes.include?(scope.to_s)
316
+ else
317
+ !!authenticated?
318
+ end
319
+ end
320
+
321
+ # Require authentication. Then send a 403 response if the API key does not have all the scopes.
322
+ def require_api_key_scope(*scopes)
323
+ require_authentication
324
+ return if scopes.all? { |scope| api_key_scope?(scope) }
325
+
326
+ set_response_error_reason_status(:insufficient_api_key_scope, insufficient_api_key_scope_error_status)
327
+ set_response_header("www-authenticate", "Bearer realm=\"#{api_key_realm}\", error=\"insufficient_scope\", scope=\"#{scopes.join(" ")}\"")
328
+ return_api_key_error_response(insufficient_api_key_scope_message)
329
+ end
330
+
331
+ # The account used all its authentication factors when it created the API key.
332
+ def two_factor_authenticated?
333
+ api_key_authenticated? || super
334
+ end
335
+
336
+ # A request that an API key authenticated has no session ID and no single session key.
337
+ # The active_sessions and single_session features must not end such a request.
338
+ def currently_active_session?
339
+ api_key_authenticated? || super
340
+ end
341
+
342
+ # Return all digests that can match the API key. With hmac_old_secret, there are two digests.
343
+ def api_key_digests(api_key)
344
+ compute_hmacs(api_key)
345
+ end
346
+
347
+ # Return the value of the expiration header: an HTTP date, for example "Thu, 31 Dec 2026 12:00:00 GMT".
348
+ def api_key_expiration_header_value(expires_at)
349
+ expires_at.httpdate
350
+ end
351
+
352
+ # Record the time of use. Skip the update if the last update is more recent than api_key_last_use_update_interval.
353
+ def update_api_key_last_use
354
+ ds = api_keys_table_ds.where(api_keys_id_column => @api_key_row[api_keys_id_column])
355
+ if (interval = api_key_last_use_update_interval)
356
+ last_use = Sequel[api_keys_last_use_column]
357
+ ds = ds.where(Sequel.|({last_use => nil}, last_use < Sequel.date_sub(Sequel::CURRENT_TIMESTAMP, seconds: interval)))
358
+ end
359
+ ds.update(api_keys_last_use_column => Sequel::CURRENT_TIMESTAMP)
360
+ end
361
+
362
+ # Return true if the name is not empty and not too long.
363
+ def valid_api_key_name?(name)
364
+ !name.empty? && name.length <= api_key_name_max_length
365
+ end
366
+
367
+ # Return true if api_key_scopes contains each scope.
368
+ def valid_api_key_scopes?(scopes)
369
+ scopes.all? { |scope| scope.is_a?(String) && api_key_scopes.include?(scope) }
370
+ end
371
+
372
+ # Return the expiration time for the value of the form field, or nil if the value is not valid.
373
+ # A date without a time ("2026-12-31") is the last second of that day in the time zone of the application.
374
+ # A full ISO 8601 time ("2026-12-31T12:00:00Z") is also valid.
375
+ def parse_api_key_expires_at(value)
376
+ if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
377
+ date = Date.iso8601(value)
378
+ Time.new(date.year, date.month, date.day, 23, 59, 59)
379
+ else
380
+ Time.iso8601(value)
381
+ end
382
+ rescue ArgumentError
383
+ nil
384
+ end
385
+
386
+ # Return the API keys of the account, the newest first. Each item is a hash with these keys:
387
+ # :id, :name, :hint, :scopes (array), :created_at, :last_use, :expires_at, :revoked_at, and :status.
388
+ # The status is :active, :expired, or :revoked. The database calculates it with its own clock.
389
+ def account_api_keys
390
+ api_key_items(api_keys_ds)
391
+ end
392
+
393
+ # Revoke the active API key of the account with this ID. Return true if the API key was active.
394
+ def revoke_api_key(id)
395
+ return false unless (id = convert_token_id(id))
396
+
397
+ active_api_keys_ds.where(api_keys_id_column => id).update(api_keys_revoked_at_column => Sequel::CURRENT_TIMESTAMP) == 1
398
+ end
399
+
400
+ # Revoke all active API keys of the account. Return the number of revoked API keys.
401
+ def revoke_all_api_keys
402
+ active_api_keys_ds.update(api_keys_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
403
+ end
404
+
405
+ # Rodauth calls this method after a password change, a password reset, and other changes to the account.
406
+ def clear_tokens(reason)
407
+ super
408
+ if revoke_api_keys_on_password_change? && [:change_password, :reset_password].include?(reason)
409
+ revoke_all_api_keys
410
+ end
411
+ end
412
+
413
+ # The jwt feature reads each Authorization header that does not start with Basic or Digest.
414
+ # Do not let it read an API key.
415
+ def jwt_token
416
+ return if api_key_from_request
417
+
418
+ super
419
+ end
420
+
421
+ # Show the new API key one time. Tell the browser and proxies not to keep a copy of the page.
422
+ def api_key_created_response
423
+ set_response_header("cache-control", "no-store")
424
+ set_notice_now_flash create_api_key_notice_flash
425
+
426
+ item = api_key_items(api_keys_ds.where(api_keys_id_column => created_api_key_id)).first
427
+ _return_from_internal_request(item.merge(api_key: created_api_key)) if internal_request?
428
+
429
+ if respond_to?(:use_json?) && use_json?
430
+ json_response.merge!(api_key_json(item), "api_key" => created_api_key)
431
+ return_json_response
432
+ end
433
+
434
+ return_response(api_key_created_view)
435
+ end
436
+
437
+ # Return a new API key: the prefix, an underscore, and a random secret.
438
+ # The secret contains only letters and digits.
439
+ def generate_api_key
440
+ "#{api_key_prefix}_#{SecureRandom.alphanumeric(api_key_secret_length)}"
441
+ end
442
+
443
+ # Return the value that the database keeps in place of the API key.
444
+ def api_key_digest(api_key)
445
+ compute_hmac(api_key)
446
+ end
447
+
448
+ # Return the start of the API key: the prefix, the underscore, and the first characters of the secret.
449
+ # The list of API keys shows this value.
450
+ def api_key_hint(api_key)
451
+ api_key[0, api_key_prefix.length + 1 + api_key_hint_length]
452
+ end
453
+
454
+ # Add an API key for the account. Return the API key.
455
+ # The database keeps only the key digest and the key hint.
456
+ # expires_in is the lifetime in seconds. When it is nil, the API key does not expire.
457
+ def create_api_key(name, scopes: [], expires_in: nil)
458
+ @created_api_key_id = nil
459
+ 3.times do |attempt|
460
+ api_key = generate_api_key
461
+ hash = api_key_insert_hash(api_key, name, scopes, expires_in)
462
+ error = raised_uniqueness_violation { @created_api_key_id = api_keys_table_ds.insert(hash) }
463
+ return api_key unless error
464
+
465
+ # Two equal digests are almost impossible. Try again with a new API key.
466
+ raise error if attempt == 2
467
+ end
468
+ end
469
+
470
+ private
471
+
472
+ def api_key_insert_hash(api_key, name, scopes, expires_in)
473
+ hash = {
474
+ api_keys_account_id_column => account_id,
475
+ api_keys_name_column => name,
476
+ api_keys_digest_column => api_key_digest(api_key),
477
+ api_keys_hint_column => api_key_hint(api_key),
478
+ api_keys_scopes_column => (scopes.join(" ") unless scopes.empty?)
479
+ }
480
+ if expires_in
481
+ # The database calculates the expiration time with its own clock, as Rodauth does for deadlines.
482
+ hash[api_keys_expires_at_column] = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: expires_in)
483
+ end
484
+ hash
485
+ end
486
+
487
+ def use_date_arithmetic?
488
+ true
489
+ end
490
+
491
+ # A closed account must not use its API keys.
492
+ # If close_account calls delete_account, remove the API key rows first, because of the foreign key.
493
+ def after_close_account
494
+ super if defined?(super)
495
+ if delete_account_on_close?
496
+ api_keys_ds.delete
497
+ else
498
+ revoke_all_api_keys
499
+ end
500
+ end
501
+
502
+ # Do not send a JWT in a response to a request that an API key authenticated.
503
+ # Such a JWT would authenticate the account without the API key, also after the revocation of the API key.
504
+ def set_jwt
505
+ super unless @api_key_row
506
+ end
507
+
508
+ # Return the items for the rows of the dataset, the newest first.
509
+ def api_key_items(ds)
510
+ status = Sequel.case(
511
+ [
512
+ [{api_keys_revoked_at_column => nil}, Sequel.case([[active_api_key_condition, "active"]], "expired")]
513
+ ],
514
+ "revoked"
515
+ )
516
+ ds.select_append(status.as(:api_key_status)).reverse(api_keys_id_column).map do |row|
517
+ {
518
+ id: row[api_keys_id_column],
519
+ name: row[api_keys_name_column],
520
+ hint: row[api_keys_hint_column],
521
+ scopes: row[api_keys_scopes_column].to_s.split(" "),
522
+ created_at: convert_timestamp(row[api_keys_created_at_column]),
523
+ last_use: convert_timestamp(row[api_keys_last_use_column]),
524
+ expires_at: convert_timestamp(row[api_keys_expires_at_column]),
525
+ revoked_at: convert_timestamp(row[api_keys_revoked_at_column]),
526
+ status: row[:api_key_status].to_sym
527
+ }
528
+ end
529
+ end
530
+
531
+ def api_key_json(api_key)
532
+ api_key.transform_keys(&:to_s).merge(
533
+ "status" => api_key[:status].to_s,
534
+ "created_at" => api_key[:created_at]&.iso8601,
535
+ "last_use" => api_key[:last_use]&.iso8601,
536
+ "expires_at" => api_key[:expires_at]&.iso8601,
537
+ "revoked_at" => api_key[:revoked_at]&.iso8601
538
+ )
539
+ end
540
+
541
+ def template_path(page)
542
+ path = File.expand_path("../../../templates/#{page}.str", __dir__)
543
+ File.file?(path) ? path : super
544
+ end
545
+
546
+ # An API key must not use the Rodauth routes. It must not manage the account or its API keys.
547
+ # Send a 403 response for a request that an API key authenticated.
548
+ def require_api_key_management_session
549
+ return unless api_key_authenticated?
550
+
551
+ set_response_error_reason_status(:api_key_management_not_permitted, api_key_management_not_permitted_error_status)
552
+ return_api_key_error_response(api_key_management_not_permitted_message)
553
+ end
554
+
555
+ def api_key_name_param_value
556
+ name = param(api_key_name_param).strip
557
+ unless valid_api_key_name?(name)
558
+ throw_error_reason(:invalid_api_key_name, invalid_field_error_status, api_key_name_param, invalid_api_key_name_message)
559
+ end
560
+ name
561
+ end
562
+
563
+ # The parameter can be an array (HTML check boxes or JSON) or a string with scopes separated by spaces.
564
+ def api_key_scopes_param_value
565
+ scopes = case (value = raw_param(api_key_scopes_param))
566
+ when nil then []
567
+ when Array then value.uniq
568
+ when String then value.split.uniq
569
+ end
570
+
571
+ unless scopes && valid_api_key_scopes?(scopes)
572
+ throw_error_reason(:invalid_api_key_scopes, invalid_field_error_status, api_key_scopes_param, invalid_api_key_scopes_message)
573
+ end
574
+ if scopes.empty? && !api_key_scopes.empty?
575
+ throw_error_reason(:api_key_scopes_required, invalid_field_error_status, api_key_scopes_param, api_key_scopes_required_message)
576
+ end
577
+ scopes
578
+ end
579
+
580
+ # Return the lifetime in seconds, or nil for an API key that does not expire.
581
+ def api_key_expires_in_param_value
582
+ value = param(api_key_expires_at_param).strip
583
+ if value.empty?
584
+ if api_key_max_lifetime
585
+ throw_error_reason(:api_key_expires_at_required, invalid_field_error_status, api_key_expires_at_param, api_key_expires_at_required_message)
586
+ end
587
+ return
588
+ end
589
+
590
+ unless (expires_at = parse_api_key_expires_at(value))
591
+ throw_error_reason(:invalid_api_key_expires_at, invalid_field_error_status, api_key_expires_at_param, invalid_api_key_expires_at_message)
592
+ end
593
+
594
+ expires_in = (expires_at - Time.now).ceil
595
+ if expires_in <= 0
596
+ throw_error_reason(:api_key_expires_at_past, invalid_field_error_status, api_key_expires_at_param, api_key_expires_at_past_message)
597
+ end
598
+ if api_key_max_lifetime && expires_in > api_key_max_lifetime
599
+ throw_error_reason(:api_key_expires_at_too_late, invalid_field_error_status, api_key_expires_at_param, api_key_expires_at_too_late_message)
600
+ end
601
+ expires_in
602
+ end
603
+
604
+ # Find the active API key and its account. Return the session hash for the request.
605
+ def api_key_session(api_key)
606
+ digests = api_key_digests(api_key)
607
+ row = api_keys_table_ds.where(api_keys_digest_column => digests).where(active_api_key_condition).first
608
+ invalid_api_key_response unless row
609
+
610
+ # The account must exist and be open. Do not use the status filter of a cookie session.
611
+ # The verify_account_grace_period feature adds unverified accounts to that filter.
612
+ # Do not keep the account. A Rodauth route loads it again and shows a warning when it loads it two times.
613
+ @session = {
614
+ session_key => row[api_keys_account_id_column],
615
+ authenticated_by_session_key => ["api_key"],
616
+ api_key_id_session_key => row[api_keys_id_column]
617
+ }
618
+ invalid_api_key_response unless api_key_account_open?(row[api_keys_account_id_column])
619
+
620
+ @api_key_row = row
621
+ if hmac_secret_rotation? && row[api_keys_digest_column] != digests.first
622
+ api_keys_table_ds.where(api_keys_id_column => row[api_keys_id_column]).update(api_keys_digest_column => digests.first)
623
+ end
624
+ update_api_key_last_use
625
+ set_api_key_expiration_header
626
+ @session
627
+ end
628
+
629
+ # Tell the client when the API key expires. An API key without an expiration time gets no header.
630
+ def set_api_key_expiration_header
631
+ return unless (header = api_key_expiration_header)
632
+ return unless (expires_at = convert_timestamp(@api_key_row[api_keys_expires_at_column]))
633
+
634
+ set_response_header(header, api_key_expiration_header_value(expires_at))
635
+ end
636
+
637
+ def api_key_account_open?(id)
638
+ ds = account_ds(id)
639
+ ds = ds.where(account_status_column => account_open_status_value) unless skip_status_checks?
640
+ !ds.empty?
641
+ end
642
+
643
+ def invalid_api_key_response
644
+ @session = {}
645
+ @account = nil
646
+ set_response_error_reason_status(:invalid_api_key, invalid_key_error_status)
647
+ set_response_header("www-authenticate", "Bearer realm=\"#{api_key_realm}\", error=\"invalid_token\"")
648
+ return_api_key_error_response(invalid_api_key_message)
649
+ end
650
+
651
+ # Send the error message as JSON when the application enables the json feature and the request uses JSON.
652
+ # Otherwise, send it as plain text.
653
+ def return_api_key_error_response(message)
654
+ if respond_to?(:use_json?) && use_json?
655
+ json_response[json_response_error_key] = message
656
+ return_json_response
657
+ else
658
+ response.headers[convert_response_header_key("content-type")] = "text/plain"
659
+ return_response(message)
660
+ end
661
+ end
662
+
663
+ # Do not clear the cookie session for a request that an API key authenticated.
664
+ def use_scope_clear_session?
665
+ super && !api_key_authenticated?
666
+ end
667
+
668
+ # All API keys in the table, for all accounts.
669
+ def api_keys_table_ds
670
+ db[api_keys_table]
671
+ end
672
+
673
+ # All API keys of the account.
674
+ def api_keys_ds(id = account_id)
675
+ api_keys_table_ds.where(api_keys_account_id_column => id)
676
+ end
677
+
678
+ # The API keys of the account that are not revoked and not expired.
679
+ def active_api_keys_ds(id = account_id)
680
+ api_keys_ds(id).where(active_api_key_condition)
681
+ end
682
+
683
+ # An API key is active when it is not revoked and not expired.
684
+ def active_api_key_condition
685
+ expires_at = Sequel[api_keys_expires_at_column]
686
+ Sequel.&(
687
+ {api_keys_revoked_at_column => nil},
688
+ Sequel.|({expires_at => nil}, expires_at > Sequel::CURRENT_TIMESTAMP)
689
+ )
690
+ end
691
+ end
692
+ end
693
+
694
+ # With rodauth-model, the account model gets an api_keys association.
695
+ if defined?(Rodauth::Model)
696
+ Rodauth::Model.register_association(:api_keys) do
697
+ {name: :api_keys, type: :many, table: api_keys_table, key: api_keys_account_id_column}
698
+ end
699
+ end
@@ -0,0 +1,5 @@
1
+ <div class="form-group mb-3">
2
+ <label for="api-key" class="form-label">#{rodauth.api_key_label}</label>
3
+ <input type="text" class="form-control" id="api-key" readonly="readonly" autocomplete="off" value="#{h rodauth.created_api_key}"/>
4
+ </div>
5
+ <p><a href="#{rodauth.api_keys_path}" id="api-keys-link">#{rodauth.api_keys_page_title}</a></p>
@@ -0,0 +1,30 @@
1
+ #{(api_keys = rodauth.account_api_keys).empty? ? "<p id=\"api-keys-empty\">#{rodauth.api_keys_empty_message}</p>" : nil}
2
+ #{unless api_keys.empty?
3
+ format_time = lambda { |time| time ? h(time.strftime(rodauth.strftime_format)) : rodauth.api_key_never_label }
4
+ status_label = {active: rodauth.api_key_active_label, expired: rodauth.api_key_expired_label, revoked: rodauth.api_key_revoked_label}
5
+ show_scopes = !rodauth.api_key_scopes.empty?
6
+ rows = api_keys.map do |api_key|
7
+ "<tr id=\"api-key-#{h api_key[:id]}\" class=\"api-key-#{api_key[:status]}\">" \
8
+ "<td>#{h api_key[:name]}</td>" \
9
+ "<td><code>#{h api_key[:hint]}&hellip;</code></td>" \
10
+ "#{"<td>#{h api_key[:scopes].join(" ")}</td>" if show_scopes}" \
11
+ "<td>#{format_time.call(api_key[:created_at])}</td>" \
12
+ "<td>#{format_time.call(api_key[:last_use])}</td>" \
13
+ "<td>#{format_time.call(api_key[:expires_at])}</td>" \
14
+ "<td>#{status_label[api_key[:status]]}</td>" \
15
+ "</tr>"
16
+ end.join("\n")
17
+ "<table class=\"table\" id=\"api-keys\"><thead><tr>" \
18
+ "<th>#{rodauth.api_key_name_label}</th>" \
19
+ "<th>#{rodauth.api_key_label}</th>" \
20
+ "#{"<th>#{rodauth.api_key_scopes_label}</th>" if show_scopes}" \
21
+ "<th>#{rodauth.api_key_created_at_label}</th>" \
22
+ "<th>#{rodauth.api_key_last_use_label}</th>" \
23
+ "<th>#{rodauth.api_key_expires_at_label}</th>" \
24
+ "<th>#{rodauth.api_key_status_label}</th>" \
25
+ "</tr></thead><tbody>#{rows}</tbody></table>"
26
+ end}
27
+ <p>
28
+ <a href="#{rodauth.create_api_key_path}" id="create-api-key-link">#{rodauth.create_api_key_link_text}</a>
29
+ #{"<a href=\"#{rodauth.revoke_api_key_path}\" id=\"revoke-api-key-link\">#{rodauth.revoke_api_key_link_text}</a>" if api_keys.any? { |api_key| api_key[:status] == :active }}
30
+ </p>
@@ -0,0 +1,24 @@
1
+ <form method="post" class="rodauth" role="form" id="create-api-key-form">
2
+ #{rodauth.create_api_key_additional_form_tags}
3
+ #{rodauth.csrf_tag}
4
+ <div class="form-group mb-3">
5
+ <label for="api-key-name" class="form-label">#{rodauth.api_key_name_label}#{rodauth.input_field_label_suffix}</label>
6
+ #{rodauth.input_field_string(rodauth.api_key_name_param, "api-key-name", :attr=>"maxlength=\"#{rodauth.api_key_name_max_length}\"")}
7
+ </div>
8
+ <div class="form-group mb-3">
9
+ <label for="api-key-expires-at" class="form-label">#{rodauth.api_key_expires_at_label}#{rodauth.input_field_label_suffix}</label>
10
+ #{rodauth.input_field_string(rodauth.api_key_expires_at_param, "api-key-expires-at", :type=>"date", :required=>!!rodauth.api_key_max_lifetime)}
11
+ </div>
12
+ #{unless rodauth.api_key_scopes.empty?
13
+ selected = Array(rodauth.raw_param(rodauth.api_key_scopes_param))
14
+ checkboxes = rodauth.api_key_scopes.map do |scope|
15
+ id = "api-key-scope-#{h scope}"
16
+ checked = 'checked="checked"' if selected.include?(scope)
17
+ input = rodauth.input_field_string("#{rodauth.api_key_scopes_param}[]", id, :type=>"checkbox", :class=>"form-check-input", :value=>h(scope), :required=>false, :skip_error_message=>true, :attr=>checked)
18
+ "<div class=\"form-check\">#{input}<label class=\"form-check-label\" for=\"#{id}\">#{h scope}</label></div>"
19
+ end.join("\n")
20
+ "<fieldset class=\"form-group mb-3\"><legend class=\"form-label\">#{rodauth.api_key_scopes_label}</legend>#{checkboxes}#{rodauth.formatted_field_error(rodauth.api_key_scopes_param)}</fieldset>"
21
+ end}
22
+ #{rodauth.render('password-field') if rodauth.modifications_require_password?}
23
+ #{rodauth.button(rodauth.create_api_key_button)}
24
+ </form>
@@ -0,0 +1,19 @@
1
+ #{(active_api_keys = rodauth.account_api_keys.select { |api_key| api_key[:status] == :active }).empty? ? "<p id=\"no-active-api-keys\">#{rodauth.no_active_api_keys_message}</p>" : nil}
2
+ #{unless active_api_keys.empty?
3
+ last_id = active_api_keys.last[:id]
4
+ radios = active_api_keys.map do |api_key|
5
+ id = "api-key-id-#{h api_key[:id]}"
6
+ input = rodauth.input_field_string(rodauth.api_key_id_param, id, :type=>"radio", :class=>"form-check-input", :skip_error_message=>true, :value=>h(api_key[:id]), :required=>false)
7
+ last_use = api_key[:last_use] ? h(api_key[:last_use].strftime(rodauth.strftime_format)) : rodauth.api_key_never_label
8
+ label = "<label class=\"form-check-label\" for=\"#{id}\">#{h api_key[:name]} (<code>#{h api_key[:hint]}&hellip;</code>), #{rodauth.api_key_last_use_label}: #{last_use}</label>"
9
+ error = rodauth.formatted_field_error(rodauth.api_key_id_param) if api_key[:id] == last_id
10
+ "<div class=\"form-check radio\">#{input}#{label}#{error}</div>"
11
+ end.join("\n")
12
+ "<form method=\"post\" class=\"rodauth\" role=\"form\" id=\"revoke-api-key-form\">" \
13
+ "#{rodauth.revoke_api_key_additional_form_tags}" \
14
+ "#{rodauth.csrf_tag}" \
15
+ "<fieldset class=\"form-group mb-3\">#{radios}</fieldset>" \
16
+ "#{rodauth.render("password-field") if rodauth.modifications_require_password?}" \
17
+ "#{rodauth.button(rodauth.revoke_api_key_button, :class=>"btn btn-danger")}" \
18
+ "</form>"
19
+ end}
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rodauth-api_keys
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pavel Dušánek
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rodauth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.48'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.48'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ description: The api_keys feature for Rodauth lets an account create, list, and revoke
33
+ API keys. A client sends an API key in the Authorization header to authenticate
34
+ a request.
35
+ email:
36
+ - dusanek@iquest.cz
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - LICENSE.txt
43
+ - README.md
44
+ - lib/rodauth/features/api_keys.rb
45
+ - templates/api-key-created.str
46
+ - templates/api-keys.str
47
+ - templates/create-api-key.str
48
+ - templates/revoke-api-key.str
49
+ homepage: https://github.com/dush/rodauth-api_keys
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ allowed_push_host: https://rubygems.org
54
+ homepage_uri: https://github.com/dush/rodauth-api_keys
55
+ changelog_uri: https://github.com/dush/rodauth-api_keys/blob/main/CHANGELOG.md
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.3.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.20
72
+ specification_version: 4
73
+ summary: API keys feature for Rodauth
74
+ test_files: []