@crombolo/auth 0.14.1 → 0.15.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.
- package/package.json +3 -3
- package/src/device.ts +183 -0
- package/src/index.ts +1 -0
- package/src/routes.ts +129 -0
- package/src/service.ts +88 -0
- package/src/store.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.15.0",
|
|
32
|
+
"@crombolo/data": "0.15.0",
|
|
33
33
|
"jose": "^6.2.12",
|
|
34
34
|
"uqr": "^0.1.3"
|
|
35
35
|
}
|
package/src/device.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth 2.0 device authorization grant (RFC 8628), for the `crombolo` CLI
|
|
3
|
+
* signing in to a remote (story 107): the CLI shows a short code, the person
|
|
4
|
+
* approves it in a browser where they are already signed in, and the CLI
|
|
5
|
+
* receives tokens for that person, with that person's permissions.
|
|
6
|
+
*
|
|
7
|
+
* The device code is a bearer credential and is stored hashed; the user code is
|
|
8
|
+
* what a person types, short and unambiguous, and is single-use. An approved
|
|
9
|
+
* authorization becomes a session of its own, so "signed-in devices" is the
|
|
10
|
+
* list of those sessions and revoking one ends it.
|
|
11
|
+
*/
|
|
12
|
+
import { randomInt } from 'node:crypto'
|
|
13
|
+
import { type Db, DbDate } from '@crombolo/data'
|
|
14
|
+
import { generateToken, hashToken } from './pkce.ts'
|
|
15
|
+
|
|
16
|
+
/** The client id the CLI signs in as. */
|
|
17
|
+
export const REMOTE_CLIENT_ID = 'crombolo-cli'
|
|
18
|
+
export const DEVICE_CODE_SECONDS = 10 * 60
|
|
19
|
+
export const DEVICE_POLL_SECONDS = 5
|
|
20
|
+
|
|
21
|
+
/** No vowels or look-alikes, so a code never spells a word or reads two ways. */
|
|
22
|
+
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ'
|
|
23
|
+
|
|
24
|
+
export type DeviceStatus = 'pending' | 'approved' | 'denied' | 'issued'
|
|
25
|
+
|
|
26
|
+
export interface DeviceAuthorizationRow {
|
|
27
|
+
id: number
|
|
28
|
+
userCode: string
|
|
29
|
+
clientId: string
|
|
30
|
+
clientName: string | null
|
|
31
|
+
scope: string
|
|
32
|
+
status: DeviceStatus
|
|
33
|
+
userId: number | null
|
|
34
|
+
sessionId: string | null
|
|
35
|
+
intervalSeconds: number
|
|
36
|
+
lastPolledAt: Date | undefined
|
|
37
|
+
expiresAt: Date
|
|
38
|
+
decidedAt: Date | undefined
|
|
39
|
+
createdAt: Date
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SignedInDevice {
|
|
43
|
+
id: number
|
|
44
|
+
clientName: string | null
|
|
45
|
+
signedInAt: Date
|
|
46
|
+
lastUsedAt: Date | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function userCode(): string {
|
|
50
|
+
const pick = () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)] as string
|
|
51
|
+
return `${Array.from({ length: 4 }, pick).join('')}-${Array.from({ length: 4 }, pick).join('')}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A code as typed: case, spaces and the dash do not matter. */
|
|
55
|
+
export const normaliseUserCode = (code: string): string => {
|
|
56
|
+
const plain = code.toUpperCase().replace(/[^A-Z]/g, '')
|
|
57
|
+
return plain.length === 8 ? `${plain.slice(0, 4)}-${plain.slice(4)}` : plain
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mapRow(row: Record<string, unknown>): DeviceAuthorizationRow {
|
|
61
|
+
return {
|
|
62
|
+
id: Number(row.id),
|
|
63
|
+
userCode: String(row.user_code),
|
|
64
|
+
clientId: String(row.client_id),
|
|
65
|
+
clientName:
|
|
66
|
+
row.client_name === null || row.client_name === undefined ? null : String(row.client_name),
|
|
67
|
+
scope: String(row.scope),
|
|
68
|
+
status: String(row.status) as DeviceStatus,
|
|
69
|
+
userId: row.user_id === null || row.user_id === undefined ? null : Number(row.user_id),
|
|
70
|
+
sessionId:
|
|
71
|
+
row.session_id === null || row.session_id === undefined ? null : String(row.session_id),
|
|
72
|
+
intervalSeconds: Number(row.interval_seconds),
|
|
73
|
+
lastPolledAt: DbDate.fromDb(row.last_polled_at),
|
|
74
|
+
expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
|
|
75
|
+
decidedAt: DbDate.fromDb(row.decided_at),
|
|
76
|
+
createdAt: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class DeviceStore {
|
|
81
|
+
#db: Db
|
|
82
|
+
|
|
83
|
+
constructor(db: Db) {
|
|
84
|
+
this.#db = db
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async create(init: {
|
|
88
|
+
clientId: string
|
|
89
|
+
clientName: string | null
|
|
90
|
+
scope: string
|
|
91
|
+
}): Promise<{ deviceCode: string; userCode: string; expiresAt: Date }> {
|
|
92
|
+
const deviceCode = generateToken()
|
|
93
|
+
const expiresAt = new Date(Date.now() + DEVICE_CODE_SECONDS * 1000)
|
|
94
|
+
// A clash with a live code is vanishingly rare; another draw settles it.
|
|
95
|
+
for (let attempt = 0; ; attempt++) {
|
|
96
|
+
const code = userCode()
|
|
97
|
+
try {
|
|
98
|
+
await this.#db.exec(
|
|
99
|
+
`INSERT INTO device_authorization
|
|
100
|
+
(device_code_hash, user_code, client_id, client_name, scope, status, interval_seconds, expires_at, create_date)
|
|
101
|
+
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
|
|
102
|
+
[
|
|
103
|
+
hashToken(deviceCode),
|
|
104
|
+
code,
|
|
105
|
+
init.clientId,
|
|
106
|
+
init.clientName,
|
|
107
|
+
init.scope,
|
|
108
|
+
DEVICE_POLL_SECONDS,
|
|
109
|
+
DbDate.toDb(expiresAt),
|
|
110
|
+
DbDate.toDb(new Date()),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
return { deviceCode, userCode: code, expiresAt }
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (attempt >= 4) throw error
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async byDeviceCode(deviceCode: string): Promise<DeviceAuthorizationRow | undefined> {
|
|
121
|
+
const rows = await this.#db.query(
|
|
122
|
+
'SELECT * FROM device_authorization WHERE device_code_hash = ?',
|
|
123
|
+
[hashToken(deviceCode)],
|
|
124
|
+
)
|
|
125
|
+
return rows[0] ? mapRow(rows[0]) : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async byUserCode(code: string): Promise<DeviceAuthorizationRow | undefined> {
|
|
129
|
+
const rows = await this.#db.query('SELECT * FROM device_authorization WHERE user_code = ?', [
|
|
130
|
+
normaliseUserCode(code),
|
|
131
|
+
])
|
|
132
|
+
return rows[0] ? mapRow(rows[0]) : undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async polled(id: number, intervalSeconds: number): Promise<void> {
|
|
136
|
+
await this.#db.exec(
|
|
137
|
+
'UPDATE device_authorization SET last_polled_at = ?, interval_seconds = ? WHERE id = ?',
|
|
138
|
+
[DbDate.toDb(new Date()), intervalSeconds, id],
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Approves or denies a pending one; false when it was no longer pending. */
|
|
143
|
+
async decide(id: number, userId: number, approve: boolean): Promise<boolean> {
|
|
144
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
145
|
+
`UPDATE device_authorization SET status = ?, user_id = ?, decided_at = ?
|
|
146
|
+
WHERE id = ? AND status = 'pending' RETURNING id`,
|
|
147
|
+
[approve ? 'approved' : 'denied', userId, DbDate.toDb(new Date()), id],
|
|
148
|
+
)
|
|
149
|
+
return rows.length > 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Marks an approved one as having had its tokens issued, once; false if it already had. */
|
|
153
|
+
async issued(id: number, sessionId: string): Promise<boolean> {
|
|
154
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
155
|
+
`UPDATE device_authorization SET status = 'issued', session_id = ?
|
|
156
|
+
WHERE id = ? AND status = 'approved' RETURNING id`,
|
|
157
|
+
[sessionId, id],
|
|
158
|
+
)
|
|
159
|
+
return rows.length > 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The devices signed in as this user, whose sessions are still open. */
|
|
163
|
+
async signedIn(userId: number): Promise<SignedInDevice[]> {
|
|
164
|
+
const rows = await this.#db.query<Record<string, unknown>>(
|
|
165
|
+
`SELECT d.id, d.client_name, s.logged_in_utc, s.last_validated_utc
|
|
166
|
+
FROM device_authorization d JOIN user_login_session s ON s.session_id = d.session_id
|
|
167
|
+
WHERE d.user_id = ? AND d.status = 'issued' AND s.logged_out_utc IS NULL
|
|
168
|
+
ORDER BY s.logged_in_utc DESC`,
|
|
169
|
+
[userId],
|
|
170
|
+
)
|
|
171
|
+
return rows.map((row) => ({
|
|
172
|
+
id: Number(row.id),
|
|
173
|
+
clientName: row.client_name === null ? null : String(row.client_name),
|
|
174
|
+
signedInAt: DbDate.fromDb(row.logged_in_utc) ?? new Date(0),
|
|
175
|
+
lastUsedAt: DbDate.fromDb(row.last_validated_utc),
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async byId(id: number): Promise<DeviceAuthorizationRow | undefined> {
|
|
180
|
+
const rows = await this.#db.query('SELECT * FROM device_authorization WHERE id = ?', [id])
|
|
181
|
+
return rows[0] ? mapRow(rows[0]) : undefined
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/index.ts
CHANGED
package/src/routes.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveRedacted,
|
|
16
16
|
serializeCookie,
|
|
17
17
|
} from './cookies.ts'
|
|
18
|
+
import { REMOTE_CLIENT_ID } from './device.ts'
|
|
18
19
|
import type { AuthService } from './service.ts'
|
|
19
20
|
import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
|
|
20
21
|
import { readShortLived, signShortLived } from './short-lived.ts'
|
|
@@ -25,6 +26,9 @@ const TWO_FACTOR_PENDING_SECONDS = 5 * 60
|
|
|
25
26
|
|
|
26
27
|
export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
|
|
27
28
|
|
|
29
|
+
/** RFC 8628's grant type. */
|
|
30
|
+
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
31
|
+
|
|
28
32
|
export interface AuthRouteOptions {
|
|
29
33
|
backOfficePath: string
|
|
30
34
|
service: AuthService
|
|
@@ -186,6 +190,32 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
186
190
|
})
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
// The CLI (story 107): tokens in the body, as a client with no cookie jar needs.
|
|
194
|
+
if (clientId === REMOTE_CLIENT_ID) {
|
|
195
|
+
const result =
|
|
196
|
+
grantType === DEVICE_CODE_GRANT
|
|
197
|
+
? await service.pollDevice(form.get('device_code') ?? '', clientId)
|
|
198
|
+
: grantType === 'refresh_token'
|
|
199
|
+
? await service.refresh(form.get('refresh_token') ?? '', clientId)
|
|
200
|
+
: {
|
|
201
|
+
ok: false as const,
|
|
202
|
+
error: 'unsupported_grant_type' as const,
|
|
203
|
+
description: `Unsupported grant_type '${grantType}'.`,
|
|
204
|
+
}
|
|
205
|
+
if (!result.ok) return oauthError(result.error, result.description, 400)
|
|
206
|
+
const signedIn = (result as { user?: UserRow }).user
|
|
207
|
+
if (grantType === DEVICE_CODE_GRANT && signedIn)
|
|
208
|
+
await audit({ action: 'SignIn', user: signedIn, detail: 'Device sign-in' }, request)
|
|
209
|
+
return Response.json({
|
|
210
|
+
access_token: result.grant.accessToken,
|
|
211
|
+
refresh_token: result.grant.refreshToken,
|
|
212
|
+
token_type: 'Bearer',
|
|
213
|
+
expires_in: result.grant.expiresIn,
|
|
214
|
+
issued_at: result.grant.issuedAt,
|
|
215
|
+
scope: DEFAULT_SCOPE,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
189
219
|
if (clientId !== BACKOFFICE_CLIENT_ID) {
|
|
190
220
|
return oauthError('invalid_client', `Unknown client '${clientId}'.`)
|
|
191
221
|
}
|
|
@@ -447,6 +477,101 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
447
477
|
return response
|
|
448
478
|
}
|
|
449
479
|
|
|
480
|
+
/** RFC 8628's device authorization endpoint: the codes the CLI shows and polls with. */
|
|
481
|
+
async function handleDeviceAuthorization(request: Request, url: URL): Promise<Response> {
|
|
482
|
+
const form = new URLSearchParams(await request.text())
|
|
483
|
+
if ((form.get('client_id') ?? '') !== REMOTE_CLIENT_ID)
|
|
484
|
+
return oauthError('invalid_client', `Unknown client '${form.get('client_id') ?? ''}'.`, 401)
|
|
485
|
+
const name = form.get('client_name')?.slice(0, 200) || null
|
|
486
|
+
const started = await service.startDeviceAuthorization(REMOTE_CLIENT_ID, name)
|
|
487
|
+
const verification = `${url.origin}${options.backOfficePath}/device`
|
|
488
|
+
return Response.json({
|
|
489
|
+
device_code: started.deviceCode,
|
|
490
|
+
user_code: started.userCode,
|
|
491
|
+
verification_uri: verification,
|
|
492
|
+
verification_uri_complete: `${verification}?user_code=${encodeURIComponent(started.userCode)}`,
|
|
493
|
+
expires_in: started.expiresIn,
|
|
494
|
+
interval: started.interval,
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The signed-in back-office person, from the session cookie, or undefined. */
|
|
499
|
+
async function sessionUser(request: Request): Promise<number | undefined> {
|
|
500
|
+
const sessionId = readCookie(request, names.session)
|
|
501
|
+
return sessionId ? (await service.resolveSession(sessionId))?.userId : undefined
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A cookie-authenticated write must come from this site, not a form elsewhere. */
|
|
505
|
+
const sameSite = (request: Request) => {
|
|
506
|
+
const site = request.headers.get('sec-fetch-site')
|
|
507
|
+
if (site) return site === 'same-origin'
|
|
508
|
+
const origin = request.headers.get('origin')
|
|
509
|
+
return !origin || origin === new URL(request.url).origin
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const signInFirst = () => problemResponse(problemDetails({ title: 'Sign in first', status: 401 }))
|
|
513
|
+
|
|
514
|
+
/** What the approval page shows for a code, and its decision. */
|
|
515
|
+
async function handleDevicePage(
|
|
516
|
+
request: Request,
|
|
517
|
+
url: URL,
|
|
518
|
+
action: string,
|
|
519
|
+
): Promise<Response | undefined> {
|
|
520
|
+
const userId = await sessionUser(request)
|
|
521
|
+
if (!userId) return signInFirst()
|
|
522
|
+
if (action === 'device/pending' && request.method === 'GET') {
|
|
523
|
+
const pending = await service.pendingDevice(url.searchParams.get('user_code') ?? '')
|
|
524
|
+
if (!pending)
|
|
525
|
+
return problemResponse(
|
|
526
|
+
problemDetails({
|
|
527
|
+
title: 'That code is not waiting',
|
|
528
|
+
status: 404,
|
|
529
|
+
detail:
|
|
530
|
+
'It may have expired or been used. Start the sign-in again from the command line.',
|
|
531
|
+
}),
|
|
532
|
+
)
|
|
533
|
+
return Response.json({
|
|
534
|
+
userCode: pending.userCode,
|
|
535
|
+
clientName: pending.clientName,
|
|
536
|
+
expiresAt: pending.expiresAt.toISOString(),
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
if (action === 'device/decide' && request.method === 'POST') {
|
|
540
|
+
if (!sameSite(request)) return new Response('Forbidden', { status: 403 })
|
|
541
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
542
|
+
userCode?: unknown
|
|
543
|
+
approve?: unknown
|
|
544
|
+
}
|
|
545
|
+
const decided = await service.decideDevice(
|
|
546
|
+
typeof body.userCode === 'string' ? body.userCode : '',
|
|
547
|
+
userId,
|
|
548
|
+
body.approve === true,
|
|
549
|
+
)
|
|
550
|
+
return decided
|
|
551
|
+
? new Response(null, { status: 204 })
|
|
552
|
+
: problemResponse(problemDetails({ title: 'That code is not waiting', status: 404 }))
|
|
553
|
+
}
|
|
554
|
+
if (action === 'devices' && request.method === 'GET') {
|
|
555
|
+
const devices = await service.signedInDevices(userId)
|
|
556
|
+
return Response.json(
|
|
557
|
+
devices.map((device) => ({
|
|
558
|
+
id: device.id,
|
|
559
|
+
name: device.clientName,
|
|
560
|
+
signedInAt: device.signedInAt.toISOString(),
|
|
561
|
+
lastUsedAt: device.lastUsedAt?.toISOString() ?? null,
|
|
562
|
+
})),
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
const revoking = /^devices\/(\d+)$/.exec(action)
|
|
566
|
+
if (revoking && request.method === 'DELETE') {
|
|
567
|
+
if (!sameSite(request)) return new Response('Forbidden', { status: 403 })
|
|
568
|
+
return (await service.revokeDevice(Number(revoking[1]), userId))
|
|
569
|
+
? new Response(null, { status: 204 })
|
|
570
|
+
: problemResponse(problemDetails({ title: 'No such device', status: 404 }))
|
|
571
|
+
}
|
|
572
|
+
return undefined
|
|
573
|
+
}
|
|
574
|
+
|
|
450
575
|
return {
|
|
451
576
|
async handle(request, pathname) {
|
|
452
577
|
if (!pathname.startsWith(`${prefix}/`)) return undefined
|
|
@@ -460,6 +585,10 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
460
585
|
if (action === 'signout') return handleSignOut(request, url)
|
|
461
586
|
if (action === 'verify-2fa' && request.method === 'POST')
|
|
462
587
|
return handleVerifyTwoFactor(request)
|
|
588
|
+
if (action === 'device' && request.method === 'POST')
|
|
589
|
+
return handleDeviceAuthorization(request, url)
|
|
590
|
+
if (action.startsWith('device/') || action === 'devices' || action.startsWith('devices/'))
|
|
591
|
+
return handleDevicePage(request, url, action)
|
|
463
592
|
return undefined
|
|
464
593
|
},
|
|
465
594
|
}
|
package/src/service.ts
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
* actually sends. See docs/backoffice-hosting.md for the wire contract.
|
|
4
4
|
*/
|
|
5
5
|
import { type ExtensionSectionAlias, type SectionAlias, toSectionAliases } from '@crombolo/core'
|
|
6
|
+
import {
|
|
7
|
+
DEVICE_CODE_SECONDS,
|
|
8
|
+
DEVICE_POLL_SECONDS,
|
|
9
|
+
type DeviceAuthorizationRow,
|
|
10
|
+
type SignedInDevice,
|
|
11
|
+
} from './device.ts'
|
|
6
12
|
import { hashPassword, isNativeHash, passwordConfigJson, verifyPassword } from './password.ts'
|
|
7
13
|
import { type CodeChallengeMethod, generateToken, verifyChallenge } from './pkce.ts'
|
|
8
14
|
import type { AuthStore, UserRow } from './store.ts'
|
|
@@ -71,6 +77,11 @@ export type TokenError =
|
|
|
71
77
|
| 'invalid_request'
|
|
72
78
|
| 'invalid_client'
|
|
73
79
|
| 'unauthorized_client'
|
|
80
|
+
// RFC 8628's answers while a device sign-in waits.
|
|
81
|
+
| 'authorization_pending'
|
|
82
|
+
| 'slow_down'
|
|
83
|
+
| 'expired_token'
|
|
84
|
+
| 'access_denied'
|
|
74
85
|
|
|
75
86
|
export type TokenResult =
|
|
76
87
|
| { ok: true; grant: TokenGrant }
|
|
@@ -377,6 +388,83 @@ export class AuthService {
|
|
|
377
388
|
return this.signOut(token.sessionId)
|
|
378
389
|
}
|
|
379
390
|
|
|
391
|
+
/** Starts a device sign-in (RFC 8628): the codes, and how often the client may poll. */
|
|
392
|
+
async startDeviceAuthorization(
|
|
393
|
+
clientId: string,
|
|
394
|
+
clientName: string | null,
|
|
395
|
+
): Promise<{ deviceCode: string; userCode: string; expiresIn: number; interval: number }> {
|
|
396
|
+
const created = await this.#store.devices.create({ clientId, clientName, scope: DEFAULT_SCOPE })
|
|
397
|
+
return {
|
|
398
|
+
deviceCode: created.deviceCode,
|
|
399
|
+
userCode: created.userCode,
|
|
400
|
+
expiresIn: DEVICE_CODE_SECONDS,
|
|
401
|
+
interval: DEVICE_POLL_SECONDS,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** A device sign-in waiting for a person, by the code they typed; undefined once it is not. */
|
|
406
|
+
async pendingDevice(userCode: string): Promise<DeviceAuthorizationRow | undefined> {
|
|
407
|
+
const row = await this.#store.devices.byUserCode(userCode)
|
|
408
|
+
if (row?.status !== 'pending' || row.expiresAt.getTime() < Date.now()) return undefined
|
|
409
|
+
return row
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Approves or denies a waiting device sign-in for the signed-in person. */
|
|
413
|
+
async decideDevice(userCode: string, userId: number, approve: boolean): Promise<boolean> {
|
|
414
|
+
const row = await this.pendingDevice(userCode)
|
|
415
|
+
return row ? this.#store.devices.decide(row.id, userId, approve) : false
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The device's poll: pending until a person decides, then the tokens, once.
|
|
420
|
+
* Polling faster than the interval answers `slow_down` and lengthens it, as
|
|
421
|
+
* RFC 8628 says.
|
|
422
|
+
*/
|
|
423
|
+
async pollDevice(
|
|
424
|
+
deviceCode: string,
|
|
425
|
+
clientId: string,
|
|
426
|
+
): Promise<TokenResult & { user?: UserRow }> {
|
|
427
|
+
const row = await this.#store.devices.byDeviceCode(deviceCode)
|
|
428
|
+
if (!row || row.clientId !== clientId)
|
|
429
|
+
return { ok: false, error: 'invalid_grant', description: 'Unknown device code.' }
|
|
430
|
+
if (row.status === 'denied')
|
|
431
|
+
return { ok: false, error: 'access_denied', description: 'The sign-in was declined.' }
|
|
432
|
+
if (row.status === 'issued')
|
|
433
|
+
return { ok: false, error: 'invalid_grant', description: 'This device code has been used.' }
|
|
434
|
+
if (row.expiresAt.getTime() < Date.now())
|
|
435
|
+
return { ok: false, error: 'expired_token', description: 'The device code has expired.' }
|
|
436
|
+
if (row.status === 'pending') {
|
|
437
|
+
const tooSoon =
|
|
438
|
+
row.lastPolledAt !== undefined &&
|
|
439
|
+
Date.now() - row.lastPolledAt.getTime() < row.intervalSeconds * 1000
|
|
440
|
+
await this.#store.devices.polled(row.id, row.intervalSeconds + (tooSoon ? 5 : 0))
|
|
441
|
+
return tooSoon
|
|
442
|
+
? { ok: false, error: 'slow_down', description: 'Poll less often.' }
|
|
443
|
+
: { ok: false, error: 'authorization_pending', description: 'Waiting for approval.' }
|
|
444
|
+
}
|
|
445
|
+
const user = row.userId === null ? undefined : await this.#store.findUserById(row.userId)
|
|
446
|
+
if (!user || user.isLockedOut || !user.isApproved)
|
|
447
|
+
return { ok: false, error: 'access_denied', description: 'This account cannot sign in.' }
|
|
448
|
+
const sessionId = crypto.randomUUID()
|
|
449
|
+
if (!(await this.#store.devices.issued(row.id, sessionId)))
|
|
450
|
+
return { ok: false, error: 'invalid_grant', description: 'This device code has been used.' }
|
|
451
|
+
await this.#store.recordLoginSuccess(user.id, sessionId)
|
|
452
|
+
return { ok: true, grant: await this.#issue(user.id, sessionId, clientId, row.scope), user }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The devices signed in as this user. */
|
|
456
|
+
signedInDevices(userId: number): Promise<SignedInDevice[]> {
|
|
457
|
+
return this.#store.devices.signedIn(userId)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Signs a device out, ending its session; false when it is not this user's. */
|
|
461
|
+
async revokeDevice(id: number, userId: number): Promise<boolean> {
|
|
462
|
+
const row = await this.#store.devices.byId(id)
|
|
463
|
+
if (!row || row.userId !== userId || !row.sessionId) return false
|
|
464
|
+
await this.#store.endSession(row.sessionId)
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
|
|
380
468
|
async #issue(
|
|
381
469
|
userId: number,
|
|
382
470
|
sessionId: string,
|
package/src/store.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* usable credential.
|
|
6
6
|
*/
|
|
7
7
|
import { type Db, DbDate, fromDbBool, Locks } from '@crombolo/data'
|
|
8
|
+
import { DeviceStore } from './device.ts'
|
|
8
9
|
import { hashToken } from './pkce.ts'
|
|
9
10
|
|
|
10
11
|
export interface UserRow {
|
|
@@ -88,9 +89,12 @@ const USER_COLUMNS =
|
|
|
88
89
|
|
|
89
90
|
export class AuthStore {
|
|
90
91
|
#db: Db
|
|
92
|
+
/** Device sign-ins (story 107), beside the sessions they become. */
|
|
93
|
+
readonly devices: DeviceStore
|
|
91
94
|
|
|
92
95
|
constructor(db: Db) {
|
|
93
96
|
this.#db = db
|
|
97
|
+
this.devices = new DeviceStore(db)
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
async findUserByLogin(login: string): Promise<UserRow | undefined> {
|