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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/lib/rodauth/features/api_keys.rb +699 -0
- data/templates/api-key-created.str +5 -0
- data/templates/api-keys.str +30 -0
- data/templates/create-api-key.str +24 -0
- data/templates/revoke-api-key.str +19 -0
- metadata +74 -0
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]}…</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]}…</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: []
|