@basictech/react 0.7.0-beta.2 → 0.7.0-beta.3
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.
- package/AUTH_IMPLEMENTATION_GUIDE.md +2009 -0
- package/changelog.md +6 -0
- package/dist/index.d.mts +1 -106
- package/dist/index.d.ts +1 -106
- package/dist/index.js +17 -381
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +17 -380
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/AuthContext.tsx +74 -12
- package/src/index.ts +5 -1
- package/src/utils/storage.ts +1 -0
|
@@ -0,0 +1,2009 @@
|
|
|
1
|
+
# Authentication Implementation Guide
|
|
2
|
+
|
|
3
|
+
This guide provides comprehensive REST API specifications and requirements for implementing authentication with Basic PDS Server.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [PDS Authentication](#pds-authentication)
|
|
10
|
+
- [Account Registration](#account-registration)
|
|
11
|
+
- [Account Login](#account-login)
|
|
12
|
+
- [OAuth Authorization](#oauth-authorization)
|
|
13
|
+
- [Session Management](#session-management)
|
|
14
|
+
- [Password Management](#password-management)
|
|
15
|
+
- [Email Verification](#email-verification)
|
|
16
|
+
- [Username Availability](#username-availability)
|
|
17
|
+
- [DID Resolution](#did-resolution)
|
|
18
|
+
2. [OAuth2 Implementation](#oauth2-implementation)
|
|
19
|
+
- [Discovery & Configuration](#discovery--configuration)
|
|
20
|
+
- [Scopes](#scopes)
|
|
21
|
+
- [Authorization Code Flow](#authorization-code-flow)
|
|
22
|
+
- [Token Management](#token-management)
|
|
23
|
+
- [User Information](#user-information)
|
|
24
|
+
3. [Scopes & Authorization](#scopes--authorization)
|
|
25
|
+
- [Scope System](#scope-system)
|
|
26
|
+
- [Available Scopes Reference](#available-scopes-reference)
|
|
27
|
+
- [Action Implications](#action-implications)
|
|
28
|
+
- [Admin Scope Behavior](#admin-scope-behavior)
|
|
29
|
+
- [Authorization Errors](#authorization-errors)
|
|
30
|
+
- [Best Practices](#best-practices)
|
|
31
|
+
4. [PKCE Extension](#pkce-extension-optional)
|
|
32
|
+
5. [DPoP Extension](#dpop-extension-optional)
|
|
33
|
+
6. [Security Requirements](#security-requirements)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## PDS Authentication
|
|
38
|
+
|
|
39
|
+
PDS authentication provides username/password-based authentication with email verification, password reset, and decentralized identifiers (DIDs). This is meant for clients of the PDS server, usually a front-end.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### Account Registration
|
|
44
|
+
|
|
45
|
+
**Endpoint:** `POST /auth/signup`
|
|
46
|
+
|
|
47
|
+
Create a new user account with username and password.
|
|
48
|
+
|
|
49
|
+
#### Request
|
|
50
|
+
|
|
51
|
+
```http
|
|
52
|
+
POST /auth/signup HTTP/1.1
|
|
53
|
+
Content-Type: application/json
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
"type": "password",
|
|
57
|
+
"username": "john_doe",
|
|
58
|
+
"password": "securepassword123",
|
|
59
|
+
"email": "john@example.com",
|
|
60
|
+
"name": "John Doe"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
#### Request Body Parameters
|
|
65
|
+
|
|
66
|
+
| Parameter | Type | Required | Description |
|
|
67
|
+
|-----------|------|----------|-------------|
|
|
68
|
+
| `type` | string | Yes | Authentication type. Must be `"password"` |
|
|
69
|
+
| `username` | string | Yes | Unique username (alphanumeric, underscores) |
|
|
70
|
+
| `password` | string | Yes | User password (minimum 3 characters) |
|
|
71
|
+
| `email` | string | No | User email address (for verification) |
|
|
72
|
+
| `name` | string | No | User's display name |
|
|
73
|
+
|
|
74
|
+
#### Response
|
|
75
|
+
|
|
76
|
+
**Success (200):**
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"data": {
|
|
80
|
+
"id": "acc_12345",
|
|
81
|
+
"username": "john_doe",
|
|
82
|
+
"email": "john@example.com",
|
|
83
|
+
"name": "John Doe",
|
|
84
|
+
"created_at": "2025-09-29T10:00:00Z"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Error (400):**
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"error": "Email already verified",
|
|
93
|
+
"message": "This email address is already verified with another account"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Email Verification
|
|
98
|
+
|
|
99
|
+
If an email is provided during signup, a verification email is automatically sent containing a verification link:
|
|
100
|
+
```
|
|
101
|
+
https://your-app.com/login?verify_email_token={token}&email={email}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The verification token is valid for **24 hours**.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### Account Login
|
|
109
|
+
|
|
110
|
+
**Endpoint:** `POST /auth/login`
|
|
111
|
+
|
|
112
|
+
Authenticate with username and password to receive access and refresh tokens.
|
|
113
|
+
|
|
114
|
+
#### Request
|
|
115
|
+
|
|
116
|
+
```http
|
|
117
|
+
POST /auth/login HTTP/1.1
|
|
118
|
+
Content-Type: application/json
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
"username": "john_doe",
|
|
122
|
+
"password": "securepassword123"
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Request Body Parameters
|
|
127
|
+
|
|
128
|
+
| Parameter | Type | Required | Description |
|
|
129
|
+
|-----------|------|----------|-------------|
|
|
130
|
+
| `username` | string | Yes | User's username or email |
|
|
131
|
+
| `password` | string | Yes | User's password |
|
|
132
|
+
|
|
133
|
+
#### Response
|
|
134
|
+
|
|
135
|
+
**Success (200):**
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"auth": {
|
|
139
|
+
"account_id": "acc_12345",
|
|
140
|
+
"ok": true
|
|
141
|
+
},
|
|
142
|
+
"token": {
|
|
143
|
+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
144
|
+
"token_type": "Bearer",
|
|
145
|
+
"expires_in": 60,
|
|
146
|
+
"refresh_token": "3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef123456..."
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Error (401):**
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"error": "Unauthorized - Invalid credentials"
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
#### Token Details
|
|
159
|
+
|
|
160
|
+
- **Access Token**: JWT signed with RS256, valid for 1 minute
|
|
161
|
+
- **Refresh Token**: Cryptographically random opaque token stored in database, valid for 30 days
|
|
162
|
+
- **Token Type**: Always `"Bearer"`
|
|
163
|
+
|
|
164
|
+
The access token payload contains:
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"userId": "acc_12345",
|
|
168
|
+
"clientId": "self",
|
|
169
|
+
"scope": "admin",
|
|
170
|
+
"sid": "sess_abc123",
|
|
171
|
+
"iat": 1727606400,
|
|
172
|
+
"exp": 1727606460
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Token Claims:**
|
|
177
|
+
- `userId` - Account ID
|
|
178
|
+
- `clientId` - Always `"self"` for login
|
|
179
|
+
- `scope` - Always `"admin"` for login (grants full account access)
|
|
180
|
+
- `sid` - Session ID
|
|
181
|
+
- `iat` - Issued at timestamp
|
|
182
|
+
- `exp` - Expiration timestamp
|
|
183
|
+
|
|
184
|
+
**The `admin` scope:**
|
|
185
|
+
- Grants access to all account features
|
|
186
|
+
- Can view/manage all sessions
|
|
187
|
+
- Can access data from all connected apps
|
|
188
|
+
- Only available via login (not OAuth apps)
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### OAuth Authorization
|
|
193
|
+
|
|
194
|
+
**Endpoint:** `POST /auth/authorize`
|
|
195
|
+
|
|
196
|
+
After a user logs in with PDS authentication, they can authorize OAuth applications to access their account.
|
|
197
|
+
|
|
198
|
+
#### Request
|
|
199
|
+
|
|
200
|
+
```http
|
|
201
|
+
POST /auth/authorize HTTP/1.1
|
|
202
|
+
Host: auth.example.com
|
|
203
|
+
Authorization: Bearer {user_access_token}
|
|
204
|
+
Content-Type: application/json
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
"client_id": "app_123",
|
|
208
|
+
"redirect_uri": "https://app.example.com/callback",
|
|
209
|
+
"scope": "profile email",
|
|
210
|
+
"state": "xyz123",
|
|
211
|
+
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
212
|
+
"code_challenge_method": "S256"
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### Request Headers
|
|
217
|
+
|
|
218
|
+
| Header | Required | Description |
|
|
219
|
+
|--------|----------|-------------|
|
|
220
|
+
| `Authorization` | Yes | Bearer token with `admin` scope (from login) |
|
|
221
|
+
|
|
222
|
+
**Note:** Only users logged in via `/auth/login` can authorize apps (have admin scope)
|
|
223
|
+
|
|
224
|
+
#### Request Body Parameters
|
|
225
|
+
|
|
226
|
+
| Parameter | Type | Required | Description |
|
|
227
|
+
|-----------|------|----------|-------------|
|
|
228
|
+
| `client_id` | string | Yes | Application identifier |
|
|
229
|
+
| `redirect_uri` | string | Yes | Callback URI |
|
|
230
|
+
| `scope` | string | Yes | Requested scopes |
|
|
231
|
+
| `state` | string | No | State parameter from initial request |
|
|
232
|
+
| `code_challenge` | string | No | PKCE code challenge |
|
|
233
|
+
| `code_challenge_method` | string | No | Must be `"S256"` if challenge provided |
|
|
234
|
+
|
|
235
|
+
#### Response
|
|
236
|
+
|
|
237
|
+
**Success (200):**
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"code": "auth_abc123def456",
|
|
241
|
+
"redirect": "https://app.example.com/callback?code=auth_abc123def456&state=xyz123",
|
|
242
|
+
"connect": {
|
|
243
|
+
"id": "conn_789",
|
|
244
|
+
"account_id": "acc_12345",
|
|
245
|
+
"project_id": "app_123",
|
|
246
|
+
"status": "connected",
|
|
247
|
+
"scope": "profile email"
|
|
248
|
+
},
|
|
249
|
+
"pkce_supported": true
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Error (401):**
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"error": "access_denied",
|
|
257
|
+
"error_description": "User authentication failed"
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### Authorization Code Properties
|
|
262
|
+
|
|
263
|
+
- **Format**: Opaque string (e.g., `abc123def456`)
|
|
264
|
+
- **Validity**: Single use only
|
|
265
|
+
- **Expiration**: Short-lived (typically 10 minutes)
|
|
266
|
+
- **Storage**: Includes redirect_uri, scope, PKCE challenge if provided
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
### Session Management
|
|
271
|
+
|
|
272
|
+
Sessions track authenticated devices and provide security features like device management and refresh token family tracking.
|
|
273
|
+
|
|
274
|
+
#### Session Creation
|
|
275
|
+
|
|
276
|
+
Sessions are automatically created during:
|
|
277
|
+
1. **PDS Login** (`POST /auth/login`)
|
|
278
|
+
2. **OAuth2 Token Exchange** (`POST /auth/token` with authorization code)
|
|
279
|
+
|
|
280
|
+
Each session includes:
|
|
281
|
+
- **Session ID**: Unique identifier
|
|
282
|
+
- **Device Instance ID**: Unique device identifier
|
|
283
|
+
- **User Agent**: Browser/application information
|
|
284
|
+
- **Platform**: Device platform (Web, Mobile, etc.)
|
|
285
|
+
- **IP Address**: Connection IP
|
|
286
|
+
- **Last Seen**: Timestamp of last activity
|
|
287
|
+
|
|
288
|
+
#### Session Properties
|
|
289
|
+
|
|
290
|
+
```json
|
|
291
|
+
{
|
|
292
|
+
"id": "sess_abc123",
|
|
293
|
+
"account_connection_id": "conn_789",
|
|
294
|
+
"account_id": "acc_12345",
|
|
295
|
+
"project_id": "app_123",
|
|
296
|
+
"device_instance_id": "device_1727606400_abc",
|
|
297
|
+
"label": "OAuth Client Session",
|
|
298
|
+
"user_agent": "Mozilla/5.0...",
|
|
299
|
+
"platform": "Web",
|
|
300
|
+
"ip_inet": "192.168.1.1",
|
|
301
|
+
"last_seen_at": "2025-09-29T10:30:00Z",
|
|
302
|
+
"created_at": "2025-09-29T10:00:00Z"
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### Refresh Token Chain
|
|
307
|
+
|
|
308
|
+
Each session maintains a **refresh token family**:
|
|
309
|
+
- All refresh tokens in a session belong to the same family
|
|
310
|
+
- Token rotation creates new tokens in the same family
|
|
311
|
+
- Reuse detection revokes the entire family
|
|
312
|
+
- Session termination revokes all family tokens
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
### Password Management
|
|
317
|
+
|
|
318
|
+
#### Request Password Reset
|
|
319
|
+
|
|
320
|
+
**Endpoint:** `POST /auth/forgot-password`
|
|
321
|
+
|
|
322
|
+
Request a password reset link to be sent via email.
|
|
323
|
+
|
|
324
|
+
#### Request
|
|
325
|
+
|
|
326
|
+
```http
|
|
327
|
+
POST /auth/forgot-password HTTP/1.1
|
|
328
|
+
Content-Type: application/json
|
|
329
|
+
|
|
330
|
+
{
|
|
331
|
+
"identifier": "john@example.com"
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
#### Request Body Parameters
|
|
336
|
+
|
|
337
|
+
| Parameter | Type | Required | Description |
|
|
338
|
+
|-----------|------|----------|-------------|
|
|
339
|
+
| `identifier` | string | Yes | User's email or username |
|
|
340
|
+
|
|
341
|
+
#### Response
|
|
342
|
+
|
|
343
|
+
**Success (200):**
|
|
344
|
+
```json
|
|
345
|
+
{
|
|
346
|
+
"success": true,
|
|
347
|
+
"message": "If an account with that email/username exists, a password reset link has been sent"
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**Note:** For security, the response is always the same whether the account exists or not.
|
|
352
|
+
|
|
353
|
+
#### Reset Token
|
|
354
|
+
|
|
355
|
+
The reset link sent via email has the format:
|
|
356
|
+
```
|
|
357
|
+
https://your-app.com/login?reset_token={token}&email={email}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The reset token is valid for **15 minutes**.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
#### Reset Password with Token
|
|
365
|
+
|
|
366
|
+
**Endpoint:** `POST /auth/reset-password`
|
|
367
|
+
|
|
368
|
+
Reset the password using a valid reset token.
|
|
369
|
+
|
|
370
|
+
#### Request
|
|
371
|
+
|
|
372
|
+
```http
|
|
373
|
+
POST /auth/reset-password HTTP/1.1
|
|
374
|
+
Content-Type: application/json
|
|
375
|
+
|
|
376
|
+
{
|
|
377
|
+
"reset_token": "abc123def456...",
|
|
378
|
+
"password": "newSecurePassword456"
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
#### Request Body Parameters
|
|
383
|
+
|
|
384
|
+
| Parameter | Type | Required | Description |
|
|
385
|
+
|-----------|------|----------|-------------|
|
|
386
|
+
| `reset_token` | string | Yes | Valid reset token from email |
|
|
387
|
+
| `password` | string | Yes | New password (minimum 3 characters) |
|
|
388
|
+
|
|
389
|
+
#### Response
|
|
390
|
+
|
|
391
|
+
**Success (200):**
|
|
392
|
+
```json
|
|
393
|
+
{
|
|
394
|
+
"success": true,
|
|
395
|
+
"message": "Password has been successfully reset"
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Error (400):**
|
|
400
|
+
```json
|
|
401
|
+
{
|
|
402
|
+
"error": "Invalid or expired reset token"
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
#### Verify Reset Token
|
|
409
|
+
|
|
410
|
+
**Endpoint:** `POST /auth/verify-reset-token`
|
|
411
|
+
|
|
412
|
+
Check if a password reset token is valid before showing the reset form.
|
|
413
|
+
|
|
414
|
+
#### Request
|
|
415
|
+
|
|
416
|
+
```http
|
|
417
|
+
POST /auth/verify-reset-token HTTP/1.1
|
|
418
|
+
Content-Type: application/json
|
|
419
|
+
|
|
420
|
+
{
|
|
421
|
+
"reset_token": "abc123def456..."
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
#### Response
|
|
426
|
+
|
|
427
|
+
```json
|
|
428
|
+
{
|
|
429
|
+
"valid": true
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
### Email Verification
|
|
436
|
+
|
|
437
|
+
#### Send Verification Email
|
|
438
|
+
|
|
439
|
+
**Endpoint:** `POST /auth/verify-email`
|
|
440
|
+
|
|
441
|
+
Request a verification email to be sent.
|
|
442
|
+
|
|
443
|
+
#### Request
|
|
444
|
+
|
|
445
|
+
```http
|
|
446
|
+
POST /auth/verify-email HTTP/1.1
|
|
447
|
+
Content-Type: application/json
|
|
448
|
+
|
|
449
|
+
{
|
|
450
|
+
"email": "john@example.com"
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
#### Response
|
|
455
|
+
|
|
456
|
+
**Success (200):**
|
|
457
|
+
```json
|
|
458
|
+
{
|
|
459
|
+
"success": true,
|
|
460
|
+
"message": "If an account with that email exists, a verification link has been sent",
|
|
461
|
+
"verification_link": "https://your-app.com/login?verify_email_token=..."
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
**Already Verified (200):**
|
|
466
|
+
```json
|
|
467
|
+
{
|
|
468
|
+
"success": true
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
#### Verify Email with Token
|
|
475
|
+
|
|
476
|
+
**Endpoint:** `POST /auth/verify-email-token`
|
|
477
|
+
|
|
478
|
+
Complete email verification using the token from the verification link.
|
|
479
|
+
|
|
480
|
+
#### Request
|
|
481
|
+
|
|
482
|
+
```http
|
|
483
|
+
POST /auth/verify-email-token HTTP/1.1
|
|
484
|
+
Content-Type: application/json
|
|
485
|
+
|
|
486
|
+
{
|
|
487
|
+
"token": "abc123def456..."
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
#### Response
|
|
492
|
+
|
|
493
|
+
**Success (200):**
|
|
494
|
+
```json
|
|
495
|
+
{
|
|
496
|
+
"success": true,
|
|
497
|
+
"message": "Email has been successfully verified"
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**Error (400):**
|
|
502
|
+
```json
|
|
503
|
+
{
|
|
504
|
+
"error": "Invalid or expired verification token"
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
### Username Availability
|
|
511
|
+
|
|
512
|
+
**Endpoint:** `GET /auth/check-username`
|
|
513
|
+
|
|
514
|
+
Check if a username is available for registration.
|
|
515
|
+
|
|
516
|
+
#### Request
|
|
517
|
+
|
|
518
|
+
```http
|
|
519
|
+
GET /auth/check-username?username=john_doe HTTP/1.1
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
#### Query Parameters
|
|
523
|
+
|
|
524
|
+
| Parameter | Type | Required | Description |
|
|
525
|
+
|-----------|------|----------|-------------|
|
|
526
|
+
| `username` | string | Yes | Username to check |
|
|
527
|
+
|
|
528
|
+
#### Response
|
|
529
|
+
|
|
530
|
+
```json
|
|
531
|
+
{
|
|
532
|
+
"available": true
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
### DID Resolution
|
|
539
|
+
|
|
540
|
+
**Endpoint:** `GET /auth/resolve`
|
|
541
|
+
|
|
542
|
+
Resolve a decentralized identifier (DID) to its document. Further DID support coming soon.
|
|
543
|
+
|
|
544
|
+
#### Request
|
|
545
|
+
|
|
546
|
+
```http
|
|
547
|
+
GET /auth/resolve?did=did:pds:base64url_encoded_data HTTP/1.1
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
#### Query Parameters
|
|
551
|
+
|
|
552
|
+
| Parameter | Type | Required | Description |
|
|
553
|
+
|-----------|------|----------|-------------|
|
|
554
|
+
| `did` | string | Yes | DID to resolve (format: `did:pds:{base64url}`) |
|
|
555
|
+
|
|
556
|
+
#### Response
|
|
557
|
+
|
|
558
|
+
```json
|
|
559
|
+
{
|
|
560
|
+
"did": "decoded_did_data"
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
#### DID Format
|
|
565
|
+
|
|
566
|
+
DIDs follow the format: `did:pds:{base64url_encoded_data}`
|
|
567
|
+
|
|
568
|
+
- Must start with `did:`
|
|
569
|
+
- Method must be `pds`
|
|
570
|
+
- Body is base64url encoded data
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## OAuth2 Implementation
|
|
575
|
+
|
|
576
|
+
OAuth2 endpoints are meant for client applications who want to connect to a user's PDS.
|
|
577
|
+
OAuth2 provides secure delegated access using the **Authorization Code Flow** with optional PKCE support. This implementation follows RFC 6749 (OAuth 2.0) and RFC 6750 (Bearer Token).
|
|
578
|
+
|
|
579
|
+
### Discovery & Configuration
|
|
580
|
+
|
|
581
|
+
#### OpenID Connect Discovery
|
|
582
|
+
|
|
583
|
+
**Endpoint:** `GET /.well-known/openid-configuration`
|
|
584
|
+
|
|
585
|
+
Provides metadata about the OAuth2/OIDC server capabilities.
|
|
586
|
+
|
|
587
|
+
#### Request
|
|
588
|
+
|
|
589
|
+
```http
|
|
590
|
+
GET /.well-known/openid-configuration HTTP/1.1
|
|
591
|
+
Host: auth.example.com
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### Response
|
|
595
|
+
|
|
596
|
+
```json
|
|
597
|
+
{
|
|
598
|
+
"issuer": "https://auth.example.com",
|
|
599
|
+
"authorization_endpoint": "https://auth.example.com/auth/authorize",
|
|
600
|
+
"token_endpoint": "https://auth.example.com/auth/token",
|
|
601
|
+
"userinfo_endpoint": "https://auth.example.com/auth/userinfo",
|
|
602
|
+
"jwks_uri": "https://auth.example.com/auth/.well-known/jwks.json",
|
|
603
|
+
"response_types_supported": ["code"],
|
|
604
|
+
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
605
|
+
"subject_types_supported": ["public"],
|
|
606
|
+
"id_token_signing_alg_values_supported": ["RS256"],
|
|
607
|
+
"scopes_supported": [
|
|
608
|
+
"openid:read",
|
|
609
|
+
"profile:read",
|
|
610
|
+
"email:read",
|
|
611
|
+
"account:read",
|
|
612
|
+
"account:write",
|
|
613
|
+
"account:update",
|
|
614
|
+
"account:app:read",
|
|
615
|
+
"account:app:write",
|
|
616
|
+
"account:app:delete",
|
|
617
|
+
"account:session:read",
|
|
618
|
+
"account:session:write",
|
|
619
|
+
"account:session:update",
|
|
620
|
+
"account:session:delete",
|
|
621
|
+
"app:db:read",
|
|
622
|
+
"app:db:write",
|
|
623
|
+
"app:db:update",
|
|
624
|
+
"app:db:delete",
|
|
625
|
+
"admin"
|
|
626
|
+
],
|
|
627
|
+
"token_endpoint_auth_methods_supported": [
|
|
628
|
+
"client_secret_post",
|
|
629
|
+
"client_secret_basic",
|
|
630
|
+
"none"
|
|
631
|
+
],
|
|
632
|
+
"code_challenge_methods_supported": ["S256"],
|
|
633
|
+
"claims_supported": [
|
|
634
|
+
"sub",
|
|
635
|
+
"iss",
|
|
636
|
+
"aud",
|
|
637
|
+
"exp",
|
|
638
|
+
"iat",
|
|
639
|
+
"email",
|
|
640
|
+
"email_verified",
|
|
641
|
+
"name",
|
|
642
|
+
"given_name",
|
|
643
|
+
"family_name",
|
|
644
|
+
"picture"
|
|
645
|
+
]
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
#### JWKS Endpoint
|
|
652
|
+
|
|
653
|
+
**Endpoint:** `GET /.well-known/jwks.json`
|
|
654
|
+
|
|
655
|
+
Provides the public keys for JWT verification in JSON Web Key Set (JWKS) format.
|
|
656
|
+
|
|
657
|
+
#### Request
|
|
658
|
+
|
|
659
|
+
```http
|
|
660
|
+
GET /.well-known/jwks.json HTTP/1.1
|
|
661
|
+
Host: auth.example.com
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
#### Response
|
|
665
|
+
|
|
666
|
+
```json
|
|
667
|
+
{
|
|
668
|
+
"keys": [
|
|
669
|
+
{
|
|
670
|
+
"kty": "RSA",
|
|
671
|
+
"use": "sig",
|
|
672
|
+
"kid": "1",
|
|
673
|
+
"n": "xGOr-H7A...",
|
|
674
|
+
"e": "AQAB",
|
|
675
|
+
"alg": "RS256"
|
|
676
|
+
}
|
|
677
|
+
]
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**Response Headers:**
|
|
682
|
+
```
|
|
683
|
+
Content-Type: application/json
|
|
684
|
+
Cache-Control: public, max-age=3600
|
|
685
|
+
Access-Control-Allow-Origin: *
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
### Authorization Code Flow
|
|
691
|
+
|
|
692
|
+
The authorization code flow is a three-step process:
|
|
693
|
+
1. Client redirects user to authorization endpoint
|
|
694
|
+
2. User authenticates and authorizes the application
|
|
695
|
+
3. Client exchanges authorization code for access token
|
|
696
|
+
|
|
697
|
+
#### Step 1: Authorization Request
|
|
698
|
+
|
|
699
|
+
**Endpoint:** `GET /auth/authorize`
|
|
700
|
+
|
|
701
|
+
Initiate the OAuth2 authorization flow.
|
|
702
|
+
|
|
703
|
+
#### Request
|
|
704
|
+
|
|
705
|
+
```http
|
|
706
|
+
GET /auth/authorize?client_id=app_123&redirect_uri=https://app.example.com/callback&response_type=code&scope=profile%20email&state=xyz123 HTTP/1.1
|
|
707
|
+
Host: auth.example.com
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
#### Query Parameters
|
|
711
|
+
|
|
712
|
+
| Parameter | Type | Required | Description |
|
|
713
|
+
|-----------|------|----------|-------------|
|
|
714
|
+
| `client_id` | string | Yes | Application/project identifier |
|
|
715
|
+
| `redirect_uri` | string | Yes | URI to redirect after authorization |
|
|
716
|
+
| `response_type` | string | Yes | Must be `"code"` |
|
|
717
|
+
| `scope` | string | No | Space-separated list of scopes (default: `"profile"`) |
|
|
718
|
+
| `state` | string | Yes | CSRF protection token (opaque value) |
|
|
719
|
+
| `code_challenge` | string | No | PKCE code challenge (see PKCE section) |
|
|
720
|
+
| `code_challenge_method` | string | No | Must be `"S256"` if PKCE is used |
|
|
721
|
+
|
|
722
|
+
#### Redirect URI Validation
|
|
723
|
+
|
|
724
|
+
The `redirect_uri` must:
|
|
725
|
+
- Be a valid URL format
|
|
726
|
+
- Match the registered redirect URI in the project profile (exact match)
|
|
727
|
+
- If no redirect URI is registered, fallback rules apply:
|
|
728
|
+
- Localhost URIs (`localhost`, `127.0.0.1`) are allowed
|
|
729
|
+
- URIs matching the project's website hostname are allowed
|
|
730
|
+
|
|
731
|
+
#### Response
|
|
732
|
+
|
|
733
|
+
The server redirects the user to the authorization UI:
|
|
734
|
+
|
|
735
|
+
```http
|
|
736
|
+
HTTP/1.1 302 Found
|
|
737
|
+
Location: https://basic.id/authorize?client_id=app_123&redirect_uri=https://app.example.com/callback&response_type=code&scope=profile%20email&state=xyz123
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
#### Error Handling
|
|
741
|
+
|
|
742
|
+
If validation fails, the server responds based on the error:
|
|
743
|
+
|
|
744
|
+
**Invalid redirect_uri (400):**
|
|
745
|
+
```json
|
|
746
|
+
{
|
|
747
|
+
"error": "invalid_request",
|
|
748
|
+
"error_description": "Invalid redirect_uri: does not match registered value"
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
**For other errors, redirect to client:**
|
|
753
|
+
```http
|
|
754
|
+
HTTP/1.1 302 Found
|
|
755
|
+
Location: https://app.example.com/callback?error=invalid_request&error_description=Missing+client_id&state=xyz123
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
#### Step 2: Token Exchange
|
|
761
|
+
|
|
762
|
+
**Endpoint:** `POST /auth/token`
|
|
763
|
+
|
|
764
|
+
Exchange authorization code for access and refresh tokens.
|
|
765
|
+
|
|
766
|
+
#### Request
|
|
767
|
+
|
|
768
|
+
```http
|
|
769
|
+
POST /auth/token HTTP/1.1
|
|
770
|
+
Host: auth.example.com
|
|
771
|
+
Content-Type: application/json
|
|
772
|
+
|
|
773
|
+
{
|
|
774
|
+
"grant_type": "authorization_code",
|
|
775
|
+
"code": "auth_abc123def456",
|
|
776
|
+
"redirect_uri": "https://app.example.com/callback",
|
|
777
|
+
"client_id": "app_123",
|
|
778
|
+
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
#### Request Body Parameters (JSON)
|
|
783
|
+
|
|
784
|
+
| Parameter | Type | Required | Description |
|
|
785
|
+
|-----------|------|----------|-------------|
|
|
786
|
+
| `grant_type` | string | Yes | Must be `"authorization_code"` |
|
|
787
|
+
| `code` | string | Yes | Authorization code from previous step |
|
|
788
|
+
| `redirect_uri` | string | Yes* | Must match the authorization request |
|
|
789
|
+
| `client_id` | string | No | Application identifier (validated if provided) |
|
|
790
|
+
| `code_verifier` | string | No** | PKCE code verifier |
|
|
791
|
+
|
|
792
|
+
\* Required if `redirect_uri` was used in authorization request
|
|
793
|
+
\** Required if PKCE code challenge was used
|
|
794
|
+
|
|
795
|
+
#### Response
|
|
796
|
+
|
|
797
|
+
**Success (200):**
|
|
798
|
+
```json
|
|
799
|
+
{
|
|
800
|
+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
801
|
+
"token_type": "Bearer",
|
|
802
|
+
"expires_in": 60,
|
|
803
|
+
"refresh_token": "789abc123def456789abc123def456789abc456def789abc123def456...",
|
|
804
|
+
"scope": "profile email"
|
|
805
|
+
}
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
**Error (400):**
|
|
809
|
+
```json
|
|
810
|
+
{
|
|
811
|
+
"error": "invalid_grant",
|
|
812
|
+
"error_description": "Invalid or expired authorization code"
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
#### Token Properties
|
|
817
|
+
|
|
818
|
+
**Access Token (JWT):**
|
|
819
|
+
- Algorithm: RS256
|
|
820
|
+
- Expiration: 60 seconds (1 minute)
|
|
821
|
+
- Claims:
|
|
822
|
+
```json
|
|
823
|
+
{
|
|
824
|
+
"clientId": "app_123",
|
|
825
|
+
"userId": "acc_12345",
|
|
826
|
+
"scope": "profile email",
|
|
827
|
+
"iat": 1727606400,
|
|
828
|
+
"exp": 1727610000
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
**Refresh Token:**
|
|
833
|
+
- Format: Opaque cryptographic token
|
|
834
|
+
- Storage: Database with hashed value
|
|
835
|
+
- Expiration: 30 days
|
|
836
|
+
- Properties: Linked to session, supports rotation
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
### Token Management
|
|
841
|
+
|
|
842
|
+
#### Refresh Access Token
|
|
843
|
+
|
|
844
|
+
**Endpoint:** `POST /auth/token`
|
|
845
|
+
|
|
846
|
+
Obtain a new access token using a refresh token.
|
|
847
|
+
|
|
848
|
+
#### Request
|
|
849
|
+
|
|
850
|
+
```http
|
|
851
|
+
POST /auth/token HTTP/1.1
|
|
852
|
+
Host: auth.example.com
|
|
853
|
+
Content-Type: application/json
|
|
854
|
+
|
|
855
|
+
{
|
|
856
|
+
"grant_type": "refresh_token",
|
|
857
|
+
"refresh_token": "789abc123def456789abc123def456789abc456def789abc123def456...",
|
|
858
|
+
"client_id": "app_123"
|
|
859
|
+
}
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
#### Request Body Parameters (JSON)
|
|
863
|
+
|
|
864
|
+
| Parameter | Type | Required | Description |
|
|
865
|
+
|-----------|------|----------|-------------|
|
|
866
|
+
| `grant_type` | string | Yes | Must be `"refresh_token"` |
|
|
867
|
+
| `refresh_token` | string | Yes | Valid refresh token |
|
|
868
|
+
| `client_id` | string | No | Application identifier (validated if provided) |
|
|
869
|
+
|
|
870
|
+
#### Response
|
|
871
|
+
|
|
872
|
+
**Success (200):**
|
|
873
|
+
```json
|
|
874
|
+
{
|
|
875
|
+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
876
|
+
"token_type": "Bearer",
|
|
877
|
+
"expires_in": 60,
|
|
878
|
+
"refresh_token": "456def789abc123def456789abc123def456abc123def456789abc...",
|
|
879
|
+
"scope": "profile email"
|
|
880
|
+
}
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
**Error (400):**
|
|
884
|
+
```json
|
|
885
|
+
{
|
|
886
|
+
"error": "invalid_grant",
|
|
887
|
+
"error_description": "Invalid or expired refresh token"
|
|
888
|
+
}
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
#### Refresh Token Rotation
|
|
892
|
+
|
|
893
|
+
The server implements **automatic refresh token rotation** for security:
|
|
894
|
+
|
|
895
|
+
1. Each refresh token use generates a new refresh token
|
|
896
|
+
2. The old refresh token is marked as "used" but kept in grace period
|
|
897
|
+
3. **Grace Period**: 60 seconds window where old token can be reused
|
|
898
|
+
- Prevents issues with network retries
|
|
899
|
+
- Multiple requests return the same new token
|
|
900
|
+
4. **Reuse Detection**: If a revoked token is used → entire chain is revoked
|
|
901
|
+
|
|
902
|
+
#### Validation Checks
|
|
903
|
+
|
|
904
|
+
Before issuing new tokens, the server validates:
|
|
905
|
+
- Refresh token is active and not expired
|
|
906
|
+
- Account connection is still active (`status = 'connected'`)
|
|
907
|
+
- Client ID matches (if provided)
|
|
908
|
+
- Token hasn't been revoked
|
|
909
|
+
|
|
910
|
+
---
|
|
911
|
+
|
|
912
|
+
### User Information
|
|
913
|
+
|
|
914
|
+
**Endpoint:** `GET /auth/userinfo`
|
|
915
|
+
|
|
916
|
+
Retrieve user information using an access token (OpenID Connect UserInfo endpoint).
|
|
917
|
+
|
|
918
|
+
#### Request
|
|
919
|
+
|
|
920
|
+
```http
|
|
921
|
+
GET /auth/userinfo HTTP/1.1
|
|
922
|
+
Host: auth.example.com
|
|
923
|
+
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
#### Request Headers
|
|
927
|
+
|
|
928
|
+
| Header | Required | Description |
|
|
929
|
+
|--------|----------|-------------|
|
|
930
|
+
| `Authorization` | Yes | Bearer token with `profile` or `admin` scope |
|
|
931
|
+
|
|
932
|
+
#### Response
|
|
933
|
+
|
|
934
|
+
**Success (200):**
|
|
935
|
+
```json
|
|
936
|
+
{
|
|
937
|
+
"sub": "acc_12345",
|
|
938
|
+
"id": "acc_12345",
|
|
939
|
+
"name": "John Doe",
|
|
940
|
+
"email": "john@example.com",
|
|
941
|
+
"username": "john_doe"
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
**Error (401):**
|
|
946
|
+
```json
|
|
947
|
+
{
|
|
948
|
+
"error": "invalid_request",
|
|
949
|
+
"error_description": "Invalid or expired access token"
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
**Error (403):**
|
|
954
|
+
```json
|
|
955
|
+
{
|
|
956
|
+
"error": "invalid_scope",
|
|
957
|
+
"error_description": "Requires 'profile:read' permission. No grant found for 'profile:read'..."
|
|
958
|
+
}
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
#### Scope Requirements
|
|
962
|
+
|
|
963
|
+
The access token must have:
|
|
964
|
+
- `profile:read` scope - Standard user information access
|
|
965
|
+
- OR `admin` scope - Full access (includes all scopes)
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## Scopes & Authorization
|
|
970
|
+
|
|
971
|
+
### Scope System
|
|
972
|
+
|
|
973
|
+
The server uses a hierarchical scope-based authorization system for fine-grained permission control.
|
|
974
|
+
|
|
975
|
+
**Scope Format:** `resource:child:action`
|
|
976
|
+
|
|
977
|
+
**Examples:**
|
|
978
|
+
- `account:read` or `account` - Read account information (read is default)
|
|
979
|
+
- `app:db:write` - Write to app database
|
|
980
|
+
- `account:session:delete` - Delete sessions
|
|
981
|
+
- `admin` - Super scope (grants everything)
|
|
982
|
+
|
|
983
|
+
**Default Action:**
|
|
984
|
+
- If no action is specified, defaults to `read`
|
|
985
|
+
- `profile` = `profile:read`
|
|
986
|
+
- `email` = `email:read`
|
|
987
|
+
- `account` = `account:read`
|
|
988
|
+
|
|
989
|
+
**Key Principles:**
|
|
990
|
+
- ✅ **Hierarchical:** Resources can have children (`account:app:read`)
|
|
991
|
+
- ✅ **Action Implications:** Higher actions grant lower ones
|
|
992
|
+
- ✅ **Default Action:** Omitted action defaults to `read`
|
|
993
|
+
- ✅ **Validated:** Invalid scopes rejected at creation
|
|
994
|
+
- ✅ **Clear Errors:** Detailed permission messages
|
|
995
|
+
|
|
996
|
+
---
|
|
997
|
+
|
|
998
|
+
### Available Scopes Reference
|
|
999
|
+
|
|
1000
|
+
#### OpenID Connect Scopes
|
|
1001
|
+
|
|
1002
|
+
| Scope | Description |
|
|
1003
|
+
|-------|-------------|
|
|
1004
|
+
| `profile:read` | User profile information (name, username) |
|
|
1005
|
+
| `email:read` | User's email address |
|
|
1006
|
+
| `openid:read` | OpenID Connect authentication |
|
|
1007
|
+
|
|
1008
|
+
#### Account Management
|
|
1009
|
+
|
|
1010
|
+
| Scope | Description | Implied Actions |
|
|
1011
|
+
|-------|-------------|-----------------|
|
|
1012
|
+
| `account:read` | View account details | - |
|
|
1013
|
+
| `account:write` | Full write access | read, create, update, delete |
|
|
1014
|
+
| `account:update` | Update account information | - |
|
|
1015
|
+
| `account:admin` | Full account control | write + all sub-actions |
|
|
1016
|
+
|
|
1017
|
+
#### Connected Apps
|
|
1018
|
+
|
|
1019
|
+
| Scope | Description | Implied Actions |
|
|
1020
|
+
|-------|-------------|-----------------|
|
|
1021
|
+
| `account:app:read` | List connected applications | - |
|
|
1022
|
+
| `account:app:write` | Full app management | read, create, update, delete |
|
|
1023
|
+
| `account:app:update` | Update app connections | - |
|
|
1024
|
+
| `account:app:delete` | Disconnect applications | - |
|
|
1025
|
+
| `account:app:admin` | Full app control | write + all sub-actions |
|
|
1026
|
+
|
|
1027
|
+
#### Session Management
|
|
1028
|
+
|
|
1029
|
+
| Scope | Description | Implied Actions |
|
|
1030
|
+
|-------|-------------|-----------------|
|
|
1031
|
+
| `account:session:read` | View active device sessions | - |
|
|
1032
|
+
| `account:session:write` | Full session management | read, create, update, delete |
|
|
1033
|
+
| `account:session:update` | Update session metadata | - |
|
|
1034
|
+
| `account:session:delete` | Revoke sessions | - |
|
|
1035
|
+
| `account:session:admin` | Full session control | write + all sub-actions |
|
|
1036
|
+
|
|
1037
|
+
#### App Database
|
|
1038
|
+
|
|
1039
|
+
| Scope | Description | Implied Actions |
|
|
1040
|
+
|-------|-------------|-----------------|
|
|
1041
|
+
| `app:db:read` | Query database tables | - |
|
|
1042
|
+
| `app:db:write` | Full database access | read, create, update, delete |
|
|
1043
|
+
| `app:db:create` | Create new records | - |
|
|
1044
|
+
| `app:db:update` | Update existing records | - |
|
|
1045
|
+
| `app:db:delete` | Delete records | - |
|
|
1046
|
+
| `app:db:admin` | Full database control | write + all sub-actions |
|
|
1047
|
+
|
|
1048
|
+
#### App Profiles
|
|
1049
|
+
|
|
1050
|
+
| Scope | Description | Implied Actions |
|
|
1051
|
+
|-------|-------------|-----------------|
|
|
1052
|
+
| `app:profile:read` | View user profiles in app | - |
|
|
1053
|
+
| `app:profile:write` | Full profile access | read, create, update, delete |
|
|
1054
|
+
| `app:profile:update` | Update user profiles | - |
|
|
1055
|
+
| `app:profile:delete` | Delete user profiles | - |
|
|
1056
|
+
| `app:profile:admin` | Full profile control | write + all sub-actions |
|
|
1057
|
+
|
|
1058
|
+
---
|
|
1059
|
+
|
|
1060
|
+
### Action Implications
|
|
1061
|
+
|
|
1062
|
+
**Automatic Permission Grants:**
|
|
1063
|
+
|
|
1064
|
+
```
|
|
1065
|
+
admin → write → read, create, update, delete
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
**How It Works:**
|
|
1069
|
+
When you have a higher-level action, you automatically get all lower-level actions.
|
|
1070
|
+
|
|
1071
|
+
**Example 1: Database Write**
|
|
1072
|
+
```
|
|
1073
|
+
Granted: app:db:write
|
|
1074
|
+
Automatically includes:
|
|
1075
|
+
✅ app:db:read
|
|
1076
|
+
✅ app:db:create
|
|
1077
|
+
✅ app:db:update
|
|
1078
|
+
✅ app:db:delete
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
**Example 2: Admin Scope**
|
|
1082
|
+
```
|
|
1083
|
+
Granted: admin
|
|
1084
|
+
Automatically includes:
|
|
1085
|
+
✅ Every scope in the system
|
|
1086
|
+
✅ Bypasses all restrictions
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
**Best Practice:** Request the highest action you need, not every individual permission.
|
|
1090
|
+
|
|
1091
|
+
---
|
|
1092
|
+
|
|
1093
|
+
### Admin Scope Behavior
|
|
1094
|
+
|
|
1095
|
+
**The `admin` scope is special:**
|
|
1096
|
+
|
|
1097
|
+
**Availability:**
|
|
1098
|
+
- ✅ Only via `/auth/login` (client_id='self')
|
|
1099
|
+
- ❌ OAuth apps cannot request it
|
|
1100
|
+
|
|
1101
|
+
**Permissions:**
|
|
1102
|
+
- ✅ Grants ALL scopes
|
|
1103
|
+
- ✅ Bypasses ownership checks
|
|
1104
|
+
- ✅ Access to all apps' data
|
|
1105
|
+
- ✅ Full account administration
|
|
1106
|
+
|
|
1107
|
+
**Use Cases:**
|
|
1108
|
+
- User managing their own account
|
|
1109
|
+
- Viewing data across all connected apps
|
|
1110
|
+
- Account administration and debugging
|
|
1111
|
+
|
|
1112
|
+
**Security:**
|
|
1113
|
+
```http
|
|
1114
|
+
Third-party app requests admin:
|
|
1115
|
+
GET /auth/authorize?scope=admin&client_id=my-app
|
|
1116
|
+
→ ERROR: "Admin scopes can only be granted to the self client"
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
---
|
|
1120
|
+
|
|
1121
|
+
### Authorization Errors
|
|
1122
|
+
|
|
1123
|
+
#### 1. Insufficient Scope (403)
|
|
1124
|
+
|
|
1125
|
+
**Cause:** Missing required scope
|
|
1126
|
+
|
|
1127
|
+
**Response:**
|
|
1128
|
+
```json
|
|
1129
|
+
{
|
|
1130
|
+
"error": "insufficient_permissions",
|
|
1131
|
+
"message": "Requires 'app:db:write' permission",
|
|
1132
|
+
"required": "app:db:write",
|
|
1133
|
+
"reason": "No grant found for 'app:db:write'. Available: profile:read"
|
|
1134
|
+
}
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
**Solution:** Request additional scopes via incremental authorization
|
|
1138
|
+
|
|
1139
|
+
---
|
|
1140
|
+
|
|
1141
|
+
#### 2. Resource Ownership (403)
|
|
1142
|
+
|
|
1143
|
+
**Cause:** Accessing another app's data
|
|
1144
|
+
|
|
1145
|
+
**Response:**
|
|
1146
|
+
```json
|
|
1147
|
+
{
|
|
1148
|
+
"error": "forbidden",
|
|
1149
|
+
"message": "Cannot access data for a different application",
|
|
1150
|
+
"type": "ownership",
|
|
1151
|
+
"hint": "Your access token is scoped to a different application."
|
|
1152
|
+
}
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
**Solution:** Use correct project_id or get admin scope
|
|
1156
|
+
|
|
1157
|
+
---
|
|
1158
|
+
|
|
1159
|
+
#### 3. Invalid Scope (400)
|
|
1160
|
+
|
|
1161
|
+
**Cause:** Requesting non-existent scope
|
|
1162
|
+
|
|
1163
|
+
**Response:**
|
|
1164
|
+
```json
|
|
1165
|
+
{
|
|
1166
|
+
"error": "invalid_scope",
|
|
1167
|
+
"error_description": "Invalid scopes: unknown:scope"
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
**Solution:** Use valid scopes from reference
|
|
1172
|
+
|
|
1173
|
+
---
|
|
1174
|
+
|
|
1175
|
+
### Best Practices
|
|
1176
|
+
|
|
1177
|
+
**1. Request Minimum Scopes**
|
|
1178
|
+
```javascript
|
|
1179
|
+
// ✅ Good
|
|
1180
|
+
scope: 'profile:read app:db:write'
|
|
1181
|
+
|
|
1182
|
+
// ❌ Bad (redundant)
|
|
1183
|
+
scope: 'profile:read app:db:read app:db:write app:db:create app:db:update'
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
**2. Handle Errors Gracefully**
|
|
1187
|
+
```javascript
|
|
1188
|
+
if (response.status === 403) {
|
|
1189
|
+
const error = await response.json();
|
|
1190
|
+
if (error.required) {
|
|
1191
|
+
// Guide user to re-authorize with needed scope
|
|
1192
|
+
requestAdditionalScope(error.required);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
**3. Use Incremental Authorization**
|
|
1198
|
+
```javascript
|
|
1199
|
+
// Start with basic scopes
|
|
1200
|
+
initialScopes: 'profile:read'
|
|
1201
|
+
|
|
1202
|
+
// Request more later when needed
|
|
1203
|
+
additionalScopes: 'app:db:write'
|
|
1204
|
+
// Result: User has both scopes
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
**4. Test with Real Scopes**
|
|
1208
|
+
- Don't use admin in development
|
|
1209
|
+
- Test with actual OAuth scopes
|
|
1210
|
+
- Validate error handling
|
|
1211
|
+
|
|
1212
|
+
---
|
|
1213
|
+
|
|
1214
|
+
### Scope Format Rules
|
|
1215
|
+
|
|
1216
|
+
**Valid Formats:**
|
|
1217
|
+
- Full format: `resource:action` (e.g., `profile:read`)
|
|
1218
|
+
- Shorthand: `resource` (e.g., `profile` - defaults to `read`)
|
|
1219
|
+
- Hierarchical: `resource:child:action` (e.g., `account:app:read`)
|
|
1220
|
+
|
|
1221
|
+
**Examples:**
|
|
1222
|
+
```
|
|
1223
|
+
✅ Valid:
|
|
1224
|
+
- profile (defaults to profile:read)
|
|
1225
|
+
- email (defaults to email:read)
|
|
1226
|
+
- account:read
|
|
1227
|
+
- app:db:write
|
|
1228
|
+
- account:session:delete
|
|
1229
|
+
|
|
1230
|
+
❌ Invalid:
|
|
1231
|
+
- account.read (wrong separator)
|
|
1232
|
+
- app/db (wrong separator)
|
|
1233
|
+
- app::read (consecutive colons)
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
**OAuth Standard Compatibility:**
|
|
1237
|
+
- OpenID Connect scopes: `profile`, `email`, `openid` (read action implied)
|
|
1238
|
+
- Custom scopes: Use full format or shorthand for read
|
|
1239
|
+
|
|
1240
|
+
---
|
|
1241
|
+
|
|
1242
|
+
## PKCE Extension (Optional)
|
|
1243
|
+
|
|
1244
|
+
**Proof Key for Code Exchange (PKCE)** enhances security for public clients that cannot securely store client secrets. This follows RFC 7636.
|
|
1245
|
+
|
|
1246
|
+
### PKCE Flow Overview
|
|
1247
|
+
|
|
1248
|
+
1. Client generates code verifier (random string)
|
|
1249
|
+
2. Client creates code challenge (SHA256 hash of verifier)
|
|
1250
|
+
3. Authorization request includes code challenge
|
|
1251
|
+
4. Token request includes code verifier
|
|
1252
|
+
5. Server validates: `SHA256(code_verifier) === code_challenge`
|
|
1253
|
+
|
|
1254
|
+
### Implementation Steps
|
|
1255
|
+
|
|
1256
|
+
#### Step 1: Generate Code Verifier
|
|
1257
|
+
|
|
1258
|
+
Create a cryptographically random string:
|
|
1259
|
+
|
|
1260
|
+
```javascript
|
|
1261
|
+
// Generate 32-byte random string
|
|
1262
|
+
const verifier = base64url(randomBytes(32))
|
|
1263
|
+
// Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
**Requirements:**
|
|
1267
|
+
- Length: 43-128 characters
|
|
1268
|
+
- Character set: `[A-Z]`, `[a-z]`, `[0-9]`, `-`, `.`, `_`, `~`
|
|
1269
|
+
- Encoding: Base64url without padding
|
|
1270
|
+
|
|
1271
|
+
---
|
|
1272
|
+
|
|
1273
|
+
#### Step 2: Generate Code Challenge
|
|
1274
|
+
|
|
1275
|
+
Create SHA256 hash of the code verifier:
|
|
1276
|
+
|
|
1277
|
+
```javascript
|
|
1278
|
+
const challenge = base64url(sha256(verifier))
|
|
1279
|
+
// Example: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
**Algorithm:** `S256` (SHA256) - Only supported method
|
|
1283
|
+
|
|
1284
|
+
---
|
|
1285
|
+
|
|
1286
|
+
#### Step 3: Authorization Request with PKCE
|
|
1287
|
+
|
|
1288
|
+
```http
|
|
1289
|
+
GET /auth/authorize?client_id=app_123&redirect_uri=https://app.example.com/callback&response_type=code&state=xyz123&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256 HTTP/1.1
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
**Additional Parameters:**
|
|
1293
|
+
|
|
1294
|
+
| Parameter | Value | Description |
|
|
1295
|
+
|-----------|-------|-------------|
|
|
1296
|
+
| `code_challenge` | Base64url string | SHA256 hash of verifier |
|
|
1297
|
+
| `code_challenge_method` | `"S256"` | Always use SHA256 |
|
|
1298
|
+
|
|
1299
|
+
---
|
|
1300
|
+
|
|
1301
|
+
#### Step 4: Token Exchange with PKCE
|
|
1302
|
+
|
|
1303
|
+
```http
|
|
1304
|
+
POST /auth/token HTTP/1.1
|
|
1305
|
+
Content-Type: application/json
|
|
1306
|
+
|
|
1307
|
+
{
|
|
1308
|
+
"grant_type": "authorization_code",
|
|
1309
|
+
"code": "auth_abc123",
|
|
1310
|
+
"redirect_uri": "https://app.example.com/callback",
|
|
1311
|
+
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
1312
|
+
}
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
**Additional Parameter:**
|
|
1316
|
+
|
|
1317
|
+
| Parameter | Value | Description |
|
|
1318
|
+
|-----------|-------|-------------|
|
|
1319
|
+
| `code_verifier` | Original random string | Used to verify challenge |
|
|
1320
|
+
|
|
1321
|
+
---
|
|
1322
|
+
|
|
1323
|
+
### PKCE Validation
|
|
1324
|
+
|
|
1325
|
+
The server validates PKCE as follows:
|
|
1326
|
+
|
|
1327
|
+
1. **If code challenge was provided in authorization:**
|
|
1328
|
+
- `code_verifier` is **required** in token request
|
|
1329
|
+
- Server computes: `SHA256(code_verifier)`
|
|
1330
|
+
- Must match stored `code_challenge`
|
|
1331
|
+
- Mismatch → `400 invalid_grant`
|
|
1332
|
+
|
|
1333
|
+
2. **If no code challenge in authorization:**
|
|
1334
|
+
- PKCE is optional
|
|
1335
|
+
- `code_verifier` is ignored if provided
|
|
1336
|
+
|
|
1337
|
+
### Error Responses
|
|
1338
|
+
|
|
1339
|
+
**Missing code_verifier:**
|
|
1340
|
+
```json
|
|
1341
|
+
{
|
|
1342
|
+
"error": "invalid_request",
|
|
1343
|
+
"error_description": "Missing required parameter: code_verifier (PKCE validation required)"
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
**Invalid code_verifier:**
|
|
1348
|
+
```json
|
|
1349
|
+
{
|
|
1350
|
+
"error": "invalid_grant",
|
|
1351
|
+
"error_description": "Invalid code_verifier - PKCE validation failed"
|
|
1352
|
+
}
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
**Unsupported challenge method:**
|
|
1356
|
+
```json
|
|
1357
|
+
{
|
|
1358
|
+
"error": "invalid_request",
|
|
1359
|
+
"error_description": "Invalid code_challenge_method. Only S256 is supported"
|
|
1360
|
+
}
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
## DPoP Extension (Optional)
|
|
1366
|
+
|
|
1367
|
+
**Demonstrating Proof-of-Possession (DPoP)** binds tokens to specific clients using public key cryptography. This follows [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449).
|
|
1368
|
+
|
|
1369
|
+
### DPoP Overview
|
|
1370
|
+
|
|
1371
|
+
DPoP prevents token theft by binding tokens to a client's private key:
|
|
1372
|
+
1. Client generates key pair
|
|
1373
|
+
2. Client creates DPoP proof (signed JWT)
|
|
1374
|
+
3. Server binds token to key thumbprint
|
|
1375
|
+
4. All requests require valid DPoP proof
|
|
1376
|
+
|
|
1377
|
+
### DPoP Proof Format
|
|
1378
|
+
|
|
1379
|
+
A DPoP proof is a JWT with specific claims:
|
|
1380
|
+
|
|
1381
|
+
```json
|
|
1382
|
+
{
|
|
1383
|
+
"typ": "dpop+jwt",
|
|
1384
|
+
"alg": "ES256",
|
|
1385
|
+
"jwk": {
|
|
1386
|
+
"kty": "EC",
|
|
1387
|
+
"crv": "P-256",
|
|
1388
|
+
"x": "...",
|
|
1389
|
+
"y": "..."
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
**Payload:**
|
|
1395
|
+
```json
|
|
1396
|
+
{
|
|
1397
|
+
"jti": "unique-request-id",
|
|
1398
|
+
"htm": "POST",
|
|
1399
|
+
"htu": "https://auth.example.com/auth/token",
|
|
1400
|
+
"iat": 1727606400,
|
|
1401
|
+
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
|
|
1402
|
+
}
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
### DPoP Claims
|
|
1406
|
+
|
|
1407
|
+
| Claim | Required | Description |
|
|
1408
|
+
|-------|----------|-------------|
|
|
1409
|
+
| `typ` | Yes | Must be `"dpop+jwt"` |
|
|
1410
|
+
| `alg` | Yes | Signing algorithm (ES256, RS256) |
|
|
1411
|
+
| `jwk` | Yes | Public key in JWK format |
|
|
1412
|
+
| `jti` | Yes | Unique identifier (prevents replay) |
|
|
1413
|
+
| `htm` | Yes | HTTP method (uppercase) |
|
|
1414
|
+
| `htu` | Yes | HTTP URI (without query/fragment) |
|
|
1415
|
+
| `iat` | Yes | Issued at timestamp |
|
|
1416
|
+
| `ath` | No | Hash of access token (for resource requests) |
|
|
1417
|
+
|
|
1418
|
+
### Implementation Steps
|
|
1419
|
+
|
|
1420
|
+
#### Step 1: Generate Key Pair
|
|
1421
|
+
|
|
1422
|
+
```javascript
|
|
1423
|
+
// Generate ES256 key pair
|
|
1424
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
1425
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
1426
|
+
true,
|
|
1427
|
+
["sign", "verify"]
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
// Export public key as JWK
|
|
1431
|
+
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey)
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
---
|
|
1435
|
+
|
|
1436
|
+
#### Step 2: Create DPoP Proof
|
|
1437
|
+
|
|
1438
|
+
```javascript
|
|
1439
|
+
// Create proof header
|
|
1440
|
+
const header = {
|
|
1441
|
+
typ: "dpop+jwt",
|
|
1442
|
+
alg: "ES256",
|
|
1443
|
+
jwk: publicJwk
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Create proof payload
|
|
1447
|
+
const payload = {
|
|
1448
|
+
jti: crypto.randomUUID(),
|
|
1449
|
+
htm: "POST",
|
|
1450
|
+
htu: "https://auth.example.com/auth/token",
|
|
1451
|
+
iat: Math.floor(Date.now() / 1000)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Sign JWT
|
|
1455
|
+
const dpopProof = await signJWT(header, payload, privateKey)
|
|
1456
|
+
```
|
|
1457
|
+
|
|
1458
|
+
---
|
|
1459
|
+
|
|
1460
|
+
#### Step 3: Token Request with DPoP
|
|
1461
|
+
|
|
1462
|
+
```http
|
|
1463
|
+
POST /auth/token HTTP/1.1
|
|
1464
|
+
Host: auth.example.com
|
|
1465
|
+
Content-Type: application/json
|
|
1466
|
+
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6Ik...
|
|
1467
|
+
|
|
1468
|
+
{
|
|
1469
|
+
"grant_type": "authorization_code",
|
|
1470
|
+
"code": "auth_abc123",
|
|
1471
|
+
"redirect_uri": "https://app.example.com/callback"
|
|
1472
|
+
}
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
**Headers:**
|
|
1476
|
+
```
|
|
1477
|
+
Content-Type: application/json
|
|
1478
|
+
DPoP: {dpop_proof_jwt}
|
|
1479
|
+
```
|
|
1480
|
+
|
|
1481
|
+
---
|
|
1482
|
+
|
|
1483
|
+
#### Step 4: DPoP-Bound Token Response
|
|
1484
|
+
|
|
1485
|
+
```json
|
|
1486
|
+
{
|
|
1487
|
+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
|
|
1488
|
+
"token_type": "DPoP",
|
|
1489
|
+
"expires_in": 60,
|
|
1490
|
+
"refresh_token": "xyz789abc123def456789abc123def456789def456abc123def789abc...",
|
|
1491
|
+
"scope": "profile email"
|
|
1492
|
+
}
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
**Key Differences:**
|
|
1496
|
+
- `token_type` is `"DPoP"` (not `"Bearer"`)
|
|
1497
|
+
- Access token contains `cnf` claim with key thumbprint:
|
|
1498
|
+
```json
|
|
1499
|
+
{
|
|
1500
|
+
"cnf": {
|
|
1501
|
+
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
---
|
|
1507
|
+
|
|
1508
|
+
#### Step 5: Resource Request with DPoP
|
|
1509
|
+
|
|
1510
|
+
```http
|
|
1511
|
+
GET /account/profile HTTP/1.1
|
|
1512
|
+
Host: api.example.com
|
|
1513
|
+
Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
|
|
1514
|
+
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6Ik...
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
**DPoP Proof includes `ath` claim:**
|
|
1518
|
+
```json
|
|
1519
|
+
{
|
|
1520
|
+
"jti": "unique-request-id-2",
|
|
1521
|
+
"htm": "GET",
|
|
1522
|
+
"htu": "https://api.example.com/account/profile",
|
|
1523
|
+
"iat": 1727606500,
|
|
1524
|
+
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
|
|
1525
|
+
}
|
|
1526
|
+
```
|
|
1527
|
+
|
|
1528
|
+
`ath` = Base64url(SHA256(access_token))
|
|
1529
|
+
|
|
1530
|
+
---
|
|
1531
|
+
|
|
1532
|
+
### DPoP Validation
|
|
1533
|
+
|
|
1534
|
+
The server validates DPoP proofs:
|
|
1535
|
+
|
|
1536
|
+
1. **Signature Validation:**
|
|
1537
|
+
- Verify JWT signature using `jwk` in header
|
|
1538
|
+
- Algorithm must match `alg` claim
|
|
1539
|
+
|
|
1540
|
+
2. **Claim Validation:**
|
|
1541
|
+
- `typ` must be `"dpop+jwt"`
|
|
1542
|
+
- `htm` must match HTTP method
|
|
1543
|
+
- `htu` must match request URI (scheme + authority + path)
|
|
1544
|
+
- `iat` must be recent (within acceptable skew)
|
|
1545
|
+
|
|
1546
|
+
3. **Token Binding:**
|
|
1547
|
+
- Compute `jkt` = Base64url(SHA256(JWK))
|
|
1548
|
+
- Must match `cnf.jkt` in access token
|
|
1549
|
+
|
|
1550
|
+
4. **Replay Protection:**
|
|
1551
|
+
- `jti` must be unique
|
|
1552
|
+
- Store recent `jti` values (cache)
|
|
1553
|
+
|
|
1554
|
+
### Error Responses
|
|
1555
|
+
|
|
1556
|
+
**Invalid DPoP Proof:**
|
|
1557
|
+
```
|
|
1558
|
+
HTTP/1.1 401 Unauthorized
|
|
1559
|
+
WWW-Authenticate: DPoP error="invalid_dpop_proof"
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
**Missing DPoP Proof:**
|
|
1563
|
+
```
|
|
1564
|
+
HTTP/1.1 401 Unauthorized
|
|
1565
|
+
WWW-Authenticate: DPoP error="invalid_token", error_description="DPoP proof required"
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
---
|
|
1569
|
+
|
|
1570
|
+
## Security Requirements
|
|
1571
|
+
|
|
1572
|
+
### Token Security
|
|
1573
|
+
|
|
1574
|
+
#### Access Token
|
|
1575
|
+
- **Format:** JWT with RS256 signature
|
|
1576
|
+
- **Lifetime:** 1 minute (60 seconds)
|
|
1577
|
+
- **Transmission:** HTTPS only, in Authorization header
|
|
1578
|
+
|
|
1579
|
+
#### Token Validation with JWKS
|
|
1580
|
+
|
|
1581
|
+
To verify access tokens, clients should use the public keys provided by the JWKS endpoint.
|
|
1582
|
+
|
|
1583
|
+
**Step 1: Fetch JWKS**
|
|
1584
|
+
|
|
1585
|
+
```javascript
|
|
1586
|
+
// Fetch and cache the JWKS
|
|
1587
|
+
async function getJWKS() {
|
|
1588
|
+
const response = await fetch('https://auth.example.com/auth/.well-known/jwks.json');
|
|
1589
|
+
const jwks = await response.json();
|
|
1590
|
+
return jwks;
|
|
1591
|
+
}
|
|
1592
|
+
```
|
|
1593
|
+
|
|
1594
|
+
**Step 2: Verify JWT Signature**
|
|
1595
|
+
|
|
1596
|
+
Using the `jose` library (recommended):
|
|
1597
|
+
|
|
1598
|
+
```javascript
|
|
1599
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
1600
|
+
|
|
1601
|
+
// Create JWKS instance (cache this)
|
|
1602
|
+
const JWKS = createRemoteJWKSet(
|
|
1603
|
+
new URL('https://auth.example.com/auth/.well-known/jwks.json')
|
|
1604
|
+
);
|
|
1605
|
+
|
|
1606
|
+
// Verify token
|
|
1607
|
+
async function verifyAccessToken(token) {
|
|
1608
|
+
try {
|
|
1609
|
+
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
|
|
1610
|
+
algorithms: ['RS256'],
|
|
1611
|
+
issuer: 'https://auth.example.com', // Optional: validate issuer
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// Token is valid
|
|
1615
|
+
return {
|
|
1616
|
+
valid: true,
|
|
1617
|
+
userId: payload.userId,
|
|
1618
|
+
clientId: payload.clientId,
|
|
1619
|
+
scope: payload.scope,
|
|
1620
|
+
exp: payload.exp
|
|
1621
|
+
};
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
// Token is invalid or expired
|
|
1624
|
+
return {
|
|
1625
|
+
valid: false,
|
|
1626
|
+
error: error.message
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
**Step 3: Validate Claims**
|
|
1633
|
+
|
|
1634
|
+
```javascript
|
|
1635
|
+
async function validateToken(token) {
|
|
1636
|
+
// Verify signature and decode
|
|
1637
|
+
const result = await verifyAccessToken(token);
|
|
1638
|
+
|
|
1639
|
+
if (!result.valid) {
|
|
1640
|
+
throw new Error('Invalid token: ' + result.error);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Additional claim validation
|
|
1644
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1645
|
+
|
|
1646
|
+
// Check expiration
|
|
1647
|
+
if (result.exp < now) {
|
|
1648
|
+
throw new Error('Token expired');
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Check scope (recommended approach)
|
|
1652
|
+
const tokenScopes = result.scope.split(',').map(s => s.trim());
|
|
1653
|
+
|
|
1654
|
+
// Check for required scope or admin (which grants everything)
|
|
1655
|
+
const hasRequiredScope = tokenScopes.includes('profile:read') || tokenScopes.includes('admin');
|
|
1656
|
+
|
|
1657
|
+
if (!hasRequiredScope) {
|
|
1658
|
+
throw new Error('Insufficient scope: requires profile:read');
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
return result;
|
|
1662
|
+
}
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
**Best Practices:**
|
|
1666
|
+
|
|
1667
|
+
1. **Cache JWKS**: Cache the JWKS response for at least 1 hour (check `Cache-Control` header)
|
|
1668
|
+
2. **Key Rotation**: Support multiple keys in JWKS for seamless rotation
|
|
1669
|
+
3. **Algorithm Validation**: Always specify `algorithms: ['RS256']` to prevent algorithm confusion attacks
|
|
1670
|
+
4. **Clock Skew**: Allow 60-second clock skew for `exp` and `iat` validation
|
|
1671
|
+
5. **Error Handling**: Distinguish between expired, invalid signature, and malformed tokens
|
|
1672
|
+
|
|
1673
|
+
**Manual Verification (without library):**
|
|
1674
|
+
|
|
1675
|
+
```javascript
|
|
1676
|
+
import crypto from 'crypto';
|
|
1677
|
+
|
|
1678
|
+
async function manualVerifyToken(token) {
|
|
1679
|
+
// Split JWT into parts
|
|
1680
|
+
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
|
1681
|
+
|
|
1682
|
+
// Decode header and payload
|
|
1683
|
+
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
|
|
1684
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
1685
|
+
|
|
1686
|
+
// Fetch JWKS and find matching key
|
|
1687
|
+
const jwks = await getJWKS();
|
|
1688
|
+
const key = jwks.keys.find(k => k.kid === header.kid);
|
|
1689
|
+
|
|
1690
|
+
if (!key) {
|
|
1691
|
+
throw new Error('Key not found in JWKS');
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Convert JWK to PEM format (requires jwk-to-pem library)
|
|
1695
|
+
const publicKey = jwkToPem(key);
|
|
1696
|
+
|
|
1697
|
+
// Verify signature
|
|
1698
|
+
const verify = crypto.createVerify('RSA-SHA256');
|
|
1699
|
+
verify.update(`${headerB64}.${payloadB64}`);
|
|
1700
|
+
|
|
1701
|
+
const signature = Buffer.from(signatureB64, 'base64url');
|
|
1702
|
+
const isValid = verify.verify(publicKey, signature);
|
|
1703
|
+
|
|
1704
|
+
if (!isValid) {
|
|
1705
|
+
throw new Error('Invalid signature');
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Validate claims
|
|
1709
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1710
|
+
if (payload.exp < now) {
|
|
1711
|
+
throw new Error('Token expired');
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
return payload;
|
|
1715
|
+
}
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
**Token Validation Response:**
|
|
1719
|
+
|
|
1720
|
+
```javascript
|
|
1721
|
+
// Valid token
|
|
1722
|
+
{
|
|
1723
|
+
"valid": true,
|
|
1724
|
+
"userId": "acc_12345",
|
|
1725
|
+
"clientId": "app_123",
|
|
1726
|
+
"scope": "profile email",
|
|
1727
|
+
"exp": 1727606460
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Invalid token
|
|
1731
|
+
{
|
|
1732
|
+
"valid": false,
|
|
1733
|
+
"error": "signature verification failed"
|
|
1734
|
+
}
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
#### Refresh Token
|
|
1738
|
+
- **Format:** Cryptographically random string (256-bit)
|
|
1739
|
+
- **Storage:** Database with SHA256 hash
|
|
1740
|
+
- **Lifetime:** 30 days
|
|
1741
|
+
- **Rotation:** Automatic on each use
|
|
1742
|
+
- **Grace Period:** 60 seconds for network retries
|
|
1743
|
+
- **Reuse Detection:** Entire chain revoked on reuse
|
|
1744
|
+
|
|
1745
|
+
---
|
|
1746
|
+
|
|
1747
|
+
### CSRF Protection
|
|
1748
|
+
|
|
1749
|
+
**State Parameter:**
|
|
1750
|
+
- Required in authorization flow
|
|
1751
|
+
- Minimum 128-bit entropy
|
|
1752
|
+
- Single-use (bound to session)
|
|
1753
|
+
- Verified on callback
|
|
1754
|
+
|
|
1755
|
+
**Implementation:**
|
|
1756
|
+
```javascript
|
|
1757
|
+
// Generate state
|
|
1758
|
+
const state = base64url(randomBytes(32))
|
|
1759
|
+
// Store in session/cookie
|
|
1760
|
+
session.oauthState = state
|
|
1761
|
+
// Include in authorization URL
|
|
1762
|
+
const authUrl = `...&state=${state}`
|
|
1763
|
+
// Verify on callback
|
|
1764
|
+
if (callbackState !== session.oauthState) {
|
|
1765
|
+
throw new Error('Invalid state')
|
|
1766
|
+
}
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
---
|
|
1770
|
+
|
|
1771
|
+
### Redirect URI Security
|
|
1772
|
+
|
|
1773
|
+
**Validation Rules:**
|
|
1774
|
+
1. **Exact Match:** Must match registered URI
|
|
1775
|
+
2. **No Wildcards:** Wildcards not supported
|
|
1776
|
+
3. **HTTPS Only:** In production (HTTP allowed for localhost)
|
|
1777
|
+
4. **No Fragments:** Fragment identifiers not allowed
|
|
1778
|
+
5. **Validation Timing:** Before showing authorization UI
|
|
1779
|
+
|
|
1780
|
+
**Error Handling:**
|
|
1781
|
+
- Invalid redirect_uri → Do not redirect (return 400)
|
|
1782
|
+
- Other errors → Redirect with error parameter
|
|
1783
|
+
|
|
1784
|
+
---
|
|
1785
|
+
|
|
1786
|
+
### Password Requirements
|
|
1787
|
+
|
|
1788
|
+
**Minimum Requirements:**
|
|
1789
|
+
- Length: 3 characters (update this for production)
|
|
1790
|
+
- Storage: Hashed with bcrypt (cost factor 10)
|
|
1791
|
+
- Transmission: HTTPS only
|
|
1792
|
+
- Reset tokens: 15-minute expiration
|
|
1793
|
+
- Verification tokens: 24-hour expiration
|
|
1794
|
+
|
|
1795
|
+
---
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
### Error Handling
|
|
1799
|
+
|
|
1800
|
+
**Error Response Format (OAuth2):**
|
|
1801
|
+
```json
|
|
1802
|
+
{
|
|
1803
|
+
"error": "invalid_request",
|
|
1804
|
+
"error_description": "Missing required parameter: client_id",
|
|
1805
|
+
"error_uri": "https://docs.example.com/errors/invalid_request"
|
|
1806
|
+
}
|
|
1807
|
+
```
|
|
1808
|
+
|
|
1809
|
+
**Standard Error Codes:**
|
|
1810
|
+
- `invalid_request` - Malformed request
|
|
1811
|
+
- `invalid_client` - Invalid client credentials
|
|
1812
|
+
- `invalid_grant` - Invalid/expired code or token
|
|
1813
|
+
- `unauthorized_client` - Client not authorized
|
|
1814
|
+
- `unsupported_grant_type` - Grant type not supported
|
|
1815
|
+
- `invalid_scope` - Requested scope invalid
|
|
1816
|
+
- `access_denied` - User denied authorization
|
|
1817
|
+
- `server_error` - Internal server error
|
|
1818
|
+
|
|
1819
|
+
**Error Response Rules:**
|
|
1820
|
+
- Never leak sensitive information
|
|
1821
|
+
- Log detailed errors server-side
|
|
1822
|
+
- Return generic errors to client
|
|
1823
|
+
- Include `state` parameter in redirects
|
|
1824
|
+
|
|
1825
|
+
---
|
|
1826
|
+
|
|
1827
|
+
## Complete Flow Examples
|
|
1828
|
+
|
|
1829
|
+
### Example 1: Simple Web App Login
|
|
1830
|
+
|
|
1831
|
+
**Step 1 - User clicks "Login":**
|
|
1832
|
+
```javascript
|
|
1833
|
+
const authUrl = new URL('https://auth.example.com/auth/authorize')
|
|
1834
|
+
authUrl.searchParams.set('client_id', 'webapp_123')
|
|
1835
|
+
authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback')
|
|
1836
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
1837
|
+
authUrl.searchParams.set('scope', 'profile email')
|
|
1838
|
+
authUrl.searchParams.set('state', generateState())
|
|
1839
|
+
|
|
1840
|
+
window.location.href = authUrl.toString()
|
|
1841
|
+
```
|
|
1842
|
+
|
|
1843
|
+
**Step 2 - User authenticates and authorizes**
|
|
1844
|
+
|
|
1845
|
+
**Step 3 - Callback receives code:**
|
|
1846
|
+
```javascript
|
|
1847
|
+
// https://myapp.com/callback?code=auth_abc123&state=xyz789
|
|
1848
|
+
|
|
1849
|
+
// Validate state
|
|
1850
|
+
if (params.state !== session.state) throw new Error('Invalid state')
|
|
1851
|
+
|
|
1852
|
+
// Exchange code for tokens
|
|
1853
|
+
const response = await fetch('https://auth.example.com/auth/token', {
|
|
1854
|
+
method: 'POST',
|
|
1855
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1856
|
+
body: JSON.stringify({
|
|
1857
|
+
grant_type: 'authorization_code',
|
|
1858
|
+
code: params.code,
|
|
1859
|
+
redirect_uri: 'https://myapp.com/callback'
|
|
1860
|
+
})
|
|
1861
|
+
})
|
|
1862
|
+
|
|
1863
|
+
const tokens = await response.json()
|
|
1864
|
+
// Store tokens securely
|
|
1865
|
+
session.accessToken = tokens.access_token
|
|
1866
|
+
session.refreshToken = tokens.refresh_token
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
**Step 4 - Use access token:**
|
|
1870
|
+
```javascript
|
|
1871
|
+
const userInfo = await fetch('https://auth.example.com/auth/userinfo', {
|
|
1872
|
+
headers: {
|
|
1873
|
+
'Authorization': `Bearer ${session.accessToken}`
|
|
1874
|
+
}
|
|
1875
|
+
})
|
|
1876
|
+
```
|
|
1877
|
+
|
|
1878
|
+
---
|
|
1879
|
+
|
|
1880
|
+
### Example 2: Mobile App with PKCE
|
|
1881
|
+
|
|
1882
|
+
**Step 1 - Generate PKCE values:**
|
|
1883
|
+
```javascript
|
|
1884
|
+
// Generate code verifier
|
|
1885
|
+
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
|
|
1886
|
+
|
|
1887
|
+
// Generate code challenge
|
|
1888
|
+
const encoder = new TextEncoder()
|
|
1889
|
+
const data = encoder.encode(verifier)
|
|
1890
|
+
const hash = await crypto.subtle.digest('SHA-256', data)
|
|
1891
|
+
const challenge = base64url(new Uint8Array(hash))
|
|
1892
|
+
|
|
1893
|
+
// Store verifier for later
|
|
1894
|
+
storage.set('pkce_verifier', verifier)
|
|
1895
|
+
```
|
|
1896
|
+
|
|
1897
|
+
**Step 2 - Authorization request:**
|
|
1898
|
+
```javascript
|
|
1899
|
+
const authUrl = new URL('https://auth.example.com/auth/authorize')
|
|
1900
|
+
authUrl.searchParams.set('client_id', 'mobile_app_456')
|
|
1901
|
+
authUrl.searchParams.set('redirect_uri', 'myapp://callback')
|
|
1902
|
+
authUrl.searchParams.set('response_type', 'code')
|
|
1903
|
+
authUrl.searchParams.set('scope', 'profile email')
|
|
1904
|
+
authUrl.searchParams.set('state', generateState())
|
|
1905
|
+
authUrl.searchParams.set('code_challenge', challenge)
|
|
1906
|
+
authUrl.searchParams.set('code_challenge_method', 'S256')
|
|
1907
|
+
|
|
1908
|
+
// Open browser
|
|
1909
|
+
openBrowser(authUrl.toString())
|
|
1910
|
+
```
|
|
1911
|
+
|
|
1912
|
+
**Step 3 - Token exchange with verifier:**
|
|
1913
|
+
```javascript
|
|
1914
|
+
// Deep link: myapp://callback?code=auth_abc123&state=xyz789
|
|
1915
|
+
|
|
1916
|
+
const response = await fetch('https://auth.example.com/auth/token', {
|
|
1917
|
+
method: 'POST',
|
|
1918
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1919
|
+
body: JSON.stringify({
|
|
1920
|
+
grant_type: 'authorization_code',
|
|
1921
|
+
code: params.code,
|
|
1922
|
+
redirect_uri: 'myapp://callback',
|
|
1923
|
+
code_verifier: storage.get('pkce_verifier')
|
|
1924
|
+
})
|
|
1925
|
+
})
|
|
1926
|
+
|
|
1927
|
+
const tokens = await response.json()
|
|
1928
|
+
```
|
|
1929
|
+
|
|
1930
|
+
---
|
|
1931
|
+
|
|
1932
|
+
### Example 3: Token Refresh
|
|
1933
|
+
|
|
1934
|
+
**Automatic token refresh:**
|
|
1935
|
+
```javascript
|
|
1936
|
+
async function getValidAccessToken() {
|
|
1937
|
+
// Check if current token is expired
|
|
1938
|
+
const decodedToken = jwt.decode(session.accessToken)
|
|
1939
|
+
const expiresAt = decodedToken.exp * 1000
|
|
1940
|
+
const now = Date.now()
|
|
1941
|
+
|
|
1942
|
+
// Refresh if token expires in less than 5 minutes
|
|
1943
|
+
if (expiresAt - now < 5 * 60 * 1000) {
|
|
1944
|
+
const response = await fetch('https://auth.example.com/auth/token', {
|
|
1945
|
+
method: 'POST',
|
|
1946
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1947
|
+
body: JSON.stringify({
|
|
1948
|
+
grant_type: 'refresh_token',
|
|
1949
|
+
refresh_token: session.refreshToken
|
|
1950
|
+
})
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1953
|
+
if (!response.ok) {
|
|
1954
|
+
// Refresh token is invalid, need to re-authenticate
|
|
1955
|
+
redirectToLogin()
|
|
1956
|
+
return null
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
const tokens = await response.json()
|
|
1960
|
+
session.accessToken = tokens.access_token
|
|
1961
|
+
session.refreshToken = tokens.refresh_token
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
return session.accessToken
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Use in API calls
|
|
1968
|
+
async function apiRequest(url) {
|
|
1969
|
+
const token = await getValidAccessToken()
|
|
1970
|
+
return fetch(url, {
|
|
1971
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
1972
|
+
})
|
|
1973
|
+
}
|
|
1974
|
+
```
|
|
1975
|
+
|
|
1976
|
+
---
|
|
1977
|
+
|
|
1978
|
+
## Additional Resources
|
|
1979
|
+
|
|
1980
|
+
### RFCs and Standards
|
|
1981
|
+
|
|
1982
|
+
- [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) - OAuth 2.0 Authorization Framework
|
|
1983
|
+
- [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) - OAuth 2.0 Bearer Token Usage
|
|
1984
|
+
- [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) - Proof Key for Code Exchange (PKCE)
|
|
1985
|
+
- [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) - Token Introspection
|
|
1986
|
+
- [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) - OAuth 2.0 Demonstrating Proof-of-Possession (DPoP)
|
|
1987
|
+
- [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)
|
|
1988
|
+
- [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
1989
|
+
|
|
1990
|
+
### Security Best Practices
|
|
1991
|
+
|
|
1992
|
+
- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)
|
|
1993
|
+
- [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)
|
|
1994
|
+
- [OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
|
|
1995
|
+
|
|
1996
|
+
---
|
|
1997
|
+
|
|
1998
|
+
## Support
|
|
1999
|
+
|
|
2000
|
+
For implementation questions or issues:
|
|
2001
|
+
- Review the Swagger documentation at `/docs`
|
|
2002
|
+
- OpenAPI available at /docs/json
|
|
2003
|
+
|
|
2004
|
+
---
|
|
2005
|
+
|
|
2006
|
+
---
|
|
2007
|
+
|
|
2008
|
+
**Last Updated:** October 1, 2025
|
|
2009
|
+
**Version:** 2.0.0 - With Scope-Based Authorization
|