@crombolo/auth 0.13.2 → 0.14.4
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 +5 -3
- package/src/cookies.ts +3 -0
- package/src/device.ts +183 -0
- package/src/index.ts +4 -0
- package/src/oidc.ts +223 -0
- package/src/routes.ts +220 -9
- package/src/service.ts +138 -2
- package/src/short-lived.ts +33 -0
- package/src/store.ts +4 -0
- package/src/totp.ts +136 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.14.4",
|
|
32
|
+
"@crombolo/data": "0.14.4",
|
|
33
|
+
"jose": "^6.2.12",
|
|
34
|
+
"uqr": "^0.1.3"
|
|
33
35
|
}
|
|
34
36
|
}
|
package/src/cookies.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface CookieNames {
|
|
|
17
17
|
accessToken: string
|
|
18
18
|
refreshToken: string
|
|
19
19
|
session: string
|
|
20
|
+
/** A sign-in whose password was right, waiting for its two-factor code. */
|
|
21
|
+
twoFactorPending: string
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface CookieOptions {
|
|
@@ -32,6 +34,7 @@ export function cookieNames(options: CookieOptions): CookieNames {
|
|
|
32
34
|
accessToken: `${prefix}umbAccessToken${options.siteName}`,
|
|
33
35
|
refreshToken: `${prefix}umbRefreshToken${options.siteName}`,
|
|
34
36
|
session: `${prefix}umbSession${options.siteName}`,
|
|
37
|
+
twoFactorPending: `${prefix}umbTwoFactor${options.siteName}`,
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
|
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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export * from './admin.ts'
|
|
2
2
|
export * from './cookies.ts'
|
|
3
|
+
export * from './device.ts'
|
|
3
4
|
export * from './member-session.ts'
|
|
5
|
+
export * from './oidc.ts'
|
|
4
6
|
export * from './password.ts'
|
|
5
7
|
export * from './pkce.ts'
|
|
6
8
|
export * from './routes.ts'
|
|
7
9
|
export * from './service.ts'
|
|
10
|
+
export * from './short-lived.ts'
|
|
8
11
|
export * from './store.ts'
|
|
12
|
+
export * from './totp.ts'
|
|
9
13
|
export * from './umbraco-passwords.ts'
|
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in through an external identity provider (story 031): OpenID Connect,
|
|
3
|
+
* authorization code with PKCE. Discovery, the redirect and the code exchange
|
|
4
|
+
* are plain `fetch`; the ID token is verified with `jose` against the
|
|
5
|
+
* provider's published keys — signature, issuer, audience, expiry and nonce.
|
|
6
|
+
*
|
|
7
|
+
* What the browser carries between leaving for the provider and coming back —
|
|
8
|
+
* `state`, `nonce`, the PKCE verifier and where to return — is a short-lived
|
|
9
|
+
* token signed with the site's own key, so nothing is stored server side.
|
|
10
|
+
*/
|
|
11
|
+
import { createRemoteJWKSet, type JWTPayload, jwtVerify, SignJWT } from 'jose'
|
|
12
|
+
import { deriveChallenge, generateToken } from './pkce.ts'
|
|
13
|
+
|
|
14
|
+
/** How a provider's claims are read. Each names a claim in the ID token. */
|
|
15
|
+
export interface OidcClaims {
|
|
16
|
+
email: string
|
|
17
|
+
name: string
|
|
18
|
+
/** Where group or role values come from, for mapping onto groups. */
|
|
19
|
+
groups?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One provider, as a preset describes it. */
|
|
23
|
+
export interface OidcProviderOptions {
|
|
24
|
+
/** The provider's id: what `external_login` records, and part of its URLs. */
|
|
25
|
+
id: string
|
|
26
|
+
/** What a sign-in button says: "Sign in with {name}". */
|
|
27
|
+
name: string
|
|
28
|
+
issuer: string
|
|
29
|
+
clientId: string
|
|
30
|
+
clientSecret?: string
|
|
31
|
+
scopes: readonly string[]
|
|
32
|
+
claims: OidcClaims
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Who the provider says signed in. */
|
|
36
|
+
export interface OidcIdentity {
|
|
37
|
+
/** The provider's stable id for the account (`sub`). */
|
|
38
|
+
subject: string
|
|
39
|
+
email: string | undefined
|
|
40
|
+
emailVerified: boolean
|
|
41
|
+
name: string | undefined
|
|
42
|
+
/** The values of the provider's groups claim, when it names one. */
|
|
43
|
+
groups: string[]
|
|
44
|
+
claims: JWTPayload
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Discovery {
|
|
48
|
+
issuer: string
|
|
49
|
+
authorization_endpoint: string
|
|
50
|
+
token_endpoint: string
|
|
51
|
+
jwks_uri: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a sign-in carries while the browser is away at the provider. */
|
|
55
|
+
export interface OidcFlowState {
|
|
56
|
+
provider: string
|
|
57
|
+
state: string
|
|
58
|
+
nonce: string
|
|
59
|
+
verifier: string
|
|
60
|
+
returnUrl: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DISCOVERY_TTL_MS = 60 * 60 * 1000
|
|
64
|
+
const FLOW_SECONDS = 10 * 60
|
|
65
|
+
|
|
66
|
+
export class OidcError extends Error {}
|
|
67
|
+
|
|
68
|
+
const stringClaim = (claims: JWTPayload, name: string): string | undefined => {
|
|
69
|
+
const value = claims[name]
|
|
70
|
+
return typeof value === 'string' && value ? value : undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const listClaim = (claims: JWTPayload, name: string | undefined): string[] => {
|
|
74
|
+
if (!name) return []
|
|
75
|
+
const value = claims[name]
|
|
76
|
+
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
|
|
77
|
+
return typeof value === 'string' && value ? [value] : []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A provider's sign-in, from discovery to a verified identity. */
|
|
81
|
+
export class OidcClient {
|
|
82
|
+
readonly options: OidcProviderOptions
|
|
83
|
+
#discovery: { value: Discovery; at: number } | undefined
|
|
84
|
+
#keys: ReturnType<typeof createRemoteJWKSet> | undefined
|
|
85
|
+
|
|
86
|
+
constructor(options: OidcProviderOptions) {
|
|
87
|
+
this.options = options
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The provider's endpoints, from its discovery document, cached for an hour. */
|
|
91
|
+
async discovery(): Promise<Discovery> {
|
|
92
|
+
if (this.#discovery && Date.now() - this.#discovery.at < DISCOVERY_TTL_MS)
|
|
93
|
+
return this.#discovery.value
|
|
94
|
+
const url = `${this.options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
|
|
95
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } })
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
throw new OidcError(`${this.options.name} discovery failed: ${response.status} from ${url}`)
|
|
98
|
+
const value = (await response.json()) as Discovery
|
|
99
|
+
for (const field of ['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as const)
|
|
100
|
+
if (typeof value[field] !== 'string')
|
|
101
|
+
throw new OidcError(`${this.options.name} discovery has no ${field}`)
|
|
102
|
+
this.#discovery = { value, at: Date.now() }
|
|
103
|
+
this.#keys = createRemoteJWKSet(new URL(value.jwks_uri))
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A new flow: its state, nonce and PKCE verifier, and the URL to send the browser to. */
|
|
108
|
+
async start(
|
|
109
|
+
redirectUri: string,
|
|
110
|
+
returnUrl: string,
|
|
111
|
+
): Promise<{ url: string; flow: OidcFlowState }> {
|
|
112
|
+
const { authorization_endpoint } = await this.discovery()
|
|
113
|
+
const flow: OidcFlowState = {
|
|
114
|
+
provider: this.options.id,
|
|
115
|
+
state: generateToken(),
|
|
116
|
+
nonce: generateToken(),
|
|
117
|
+
verifier: generateToken(),
|
|
118
|
+
returnUrl,
|
|
119
|
+
}
|
|
120
|
+
const url = new URL(authorization_endpoint)
|
|
121
|
+
url.searchParams.set('response_type', 'code')
|
|
122
|
+
url.searchParams.set('client_id', this.options.clientId)
|
|
123
|
+
url.searchParams.set('redirect_uri', redirectUri)
|
|
124
|
+
url.searchParams.set('scope', this.options.scopes.join(' '))
|
|
125
|
+
url.searchParams.set('state', flow.state)
|
|
126
|
+
url.searchParams.set('nonce', flow.nonce)
|
|
127
|
+
url.searchParams.set('code_challenge', await deriveChallenge(flow.verifier))
|
|
128
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
129
|
+
return { url: url.href, flow }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The provider's answer, checked against the flow that started it, turned
|
|
134
|
+
* into a verified identity. Throws `OidcError` with a reason fit to log.
|
|
135
|
+
*/
|
|
136
|
+
async finish(callback: URL, flow: OidcFlowState, redirectUri: string): Promise<OidcIdentity> {
|
|
137
|
+
const error = callback.searchParams.get('error')
|
|
138
|
+
if (error)
|
|
139
|
+
throw new OidcError(
|
|
140
|
+
`${this.options.name} refused the sign-in: ${callback.searchParams.get('error_description') ?? error}`,
|
|
141
|
+
)
|
|
142
|
+
if (callback.searchParams.get('state') !== flow.state)
|
|
143
|
+
throw new OidcError('the sign-in came back with a state it did not start with')
|
|
144
|
+
const code = callback.searchParams.get('code')
|
|
145
|
+
if (!code) throw new OidcError(`${this.options.name} sent no code`)
|
|
146
|
+
|
|
147
|
+
const { token_endpoint } = await this.discovery()
|
|
148
|
+
const body = new URLSearchParams({
|
|
149
|
+
grant_type: 'authorization_code',
|
|
150
|
+
code,
|
|
151
|
+
redirect_uri: redirectUri,
|
|
152
|
+
client_id: this.options.clientId,
|
|
153
|
+
code_verifier: flow.verifier,
|
|
154
|
+
})
|
|
155
|
+
if (this.options.clientSecret) body.set('client_secret', this.options.clientSecret)
|
|
156
|
+
const response = await fetch(token_endpoint, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
159
|
+
body,
|
|
160
|
+
})
|
|
161
|
+
if (!response.ok)
|
|
162
|
+
throw new OidcError(`${this.options.name} would not exchange the code: ${response.status}`)
|
|
163
|
+
const tokens = (await response.json()) as { id_token?: string }
|
|
164
|
+
if (!tokens.id_token) throw new OidcError(`${this.options.name} sent no ID token`)
|
|
165
|
+
return this.verify(tokens.id_token, flow.nonce)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An ID token, verified: signed by the provider's keys, for us, current, and this flow's. */
|
|
169
|
+
async verify(idToken: string, nonce: string): Promise<OidcIdentity> {
|
|
170
|
+
const discovery = await this.discovery()
|
|
171
|
+
const keys = this.#keys as ReturnType<typeof createRemoteJWKSet>
|
|
172
|
+
let claims: JWTPayload
|
|
173
|
+
try {
|
|
174
|
+
;({ payload: claims } = await jwtVerify(idToken, keys, {
|
|
175
|
+
issuer: discovery.issuer ?? this.options.issuer,
|
|
176
|
+
audience: this.options.clientId,
|
|
177
|
+
}))
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new OidcError(
|
|
180
|
+
`the ID token from ${this.options.name} is not valid: ${(error as Error).message}`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
if (claims.nonce !== nonce) throw new OidcError('the ID token was not issued for this sign-in')
|
|
184
|
+
if (!claims.sub) throw new OidcError(`the ID token from ${this.options.name} names no subject`)
|
|
185
|
+
const verified = claims.email_verified
|
|
186
|
+
return {
|
|
187
|
+
subject: claims.sub,
|
|
188
|
+
email: stringClaim(claims, this.options.claims.email)?.toLowerCase(),
|
|
189
|
+
emailVerified: verified === true || verified === 'true',
|
|
190
|
+
name: stringClaim(claims, this.options.claims.name),
|
|
191
|
+
groups: listClaim(claims, this.options.claims.groups),
|
|
192
|
+
claims,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const secretKey = (secret: string) => new TextEncoder().encode(secret)
|
|
198
|
+
|
|
199
|
+
/** The flow, signed, for the cookie the browser carries to the provider and back. */
|
|
200
|
+
export async function signFlowState(secret: string, flow: OidcFlowState): Promise<string> {
|
|
201
|
+
return new SignJWT({ ...flow })
|
|
202
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
203
|
+
.setIssuedAt()
|
|
204
|
+
.setExpirationTime(`${FLOW_SECONDS}s`)
|
|
205
|
+
.sign(secretKey(secret))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The flow back from its cookie, or undefined when it is missing, altered or stale. */
|
|
209
|
+
export async function readFlowState(
|
|
210
|
+
secret: string,
|
|
211
|
+
value: string | undefined,
|
|
212
|
+
): Promise<OidcFlowState | undefined> {
|
|
213
|
+
if (!value) return undefined
|
|
214
|
+
try {
|
|
215
|
+
const { payload } = await jwtVerify(value, secretKey(secret), { algorithms: ['HS256'] })
|
|
216
|
+
const flow = payload as unknown as OidcFlowState
|
|
217
|
+
for (const field of ['provider', 'state', 'nonce', 'verifier', 'returnUrl'] as const)
|
|
218
|
+
if (typeof flow[field] !== 'string') return undefined
|
|
219
|
+
return flow
|
|
220
|
+
} catch {
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -15,18 +15,33 @@ 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'
|
|
21
|
+
import { readShortLived, signShortLived } from './short-lived.ts'
|
|
20
22
|
import type { UserRow } from './store.ts'
|
|
21
23
|
|
|
24
|
+
/** How long a sign-in waits for its two-factor code. */
|
|
25
|
+
const TWO_FACTOR_PENDING_SECONDS = 5 * 60
|
|
26
|
+
|
|
22
27
|
export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
|
|
23
28
|
|
|
29
|
+
/** RFC 8628's grant type. */
|
|
30
|
+
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
31
|
+
|
|
24
32
|
export interface AuthRouteOptions {
|
|
25
33
|
backOfficePath: string
|
|
26
34
|
service: AuthService
|
|
27
35
|
cookies: CookieOptions
|
|
28
36
|
/** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
|
|
29
37
|
onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
|
|
38
|
+
/** Two-factor sign-in (story 032): who has a provider on, and whether a code is theirs. */
|
|
39
|
+
twoFactor?: {
|
|
40
|
+
providersFor(userKey: string): Promise<string[]>
|
|
41
|
+
verify(userKey: string, provider: string, code: string): Promise<boolean>
|
|
42
|
+
/** The key the pending cookie is signed with. */
|
|
43
|
+
secret(): Promise<string>
|
|
44
|
+
}
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
export interface AuthAuditEvent {
|
|
@@ -175,6 +190,32 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
175
190
|
})
|
|
176
191
|
}
|
|
177
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
|
+
|
|
178
219
|
if (clientId !== BACKOFFICE_CLIENT_ID) {
|
|
179
220
|
return oauthError('invalid_client', `Unknown client '${clientId}'.`)
|
|
180
221
|
}
|
|
@@ -281,6 +322,9 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
281
322
|
username,
|
|
282
323
|
password,
|
|
283
324
|
request.headers.get('x-forwarded-for') ?? undefined,
|
|
325
|
+
options.twoFactor
|
|
326
|
+
? async (user) => (await options.twoFactor?.providersFor(user.key)) ?? []
|
|
327
|
+
: undefined,
|
|
284
328
|
)
|
|
285
329
|
|
|
286
330
|
if (result.status === 'success') await audit({ action: 'SignIn', user: result.user }, request)
|
|
@@ -322,12 +366,31 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
322
366
|
problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
|
|
323
367
|
)
|
|
324
368
|
}
|
|
325
|
-
if (result.status === 'twoFactorRequired') {
|
|
369
|
+
if (result.status === 'twoFactorRequired' && options.twoFactor) {
|
|
370
|
+
// The password was right; the session waits for the code. The pending
|
|
371
|
+
// cookie names who is part-way in, for five minutes.
|
|
372
|
+
const pending = await signShortLived(
|
|
373
|
+
await options.twoFactor.secret(),
|
|
374
|
+
{ sub: result.user.key, login: result.user.login },
|
|
375
|
+
TWO_FACTOR_PENDING_SECONDS,
|
|
376
|
+
)
|
|
326
377
|
return Response.json(
|
|
327
378
|
{ twoFactorLoginView: null, enabledTwoFactorProviderNames: result.providers },
|
|
328
|
-
{
|
|
379
|
+
{
|
|
380
|
+
status: 402,
|
|
381
|
+
headers: {
|
|
382
|
+
'set-cookie': serializeCookie({
|
|
383
|
+
name: names.twoFactorPending,
|
|
384
|
+
value: pending,
|
|
385
|
+
secure,
|
|
386
|
+
maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
|
|
387
|
+
}),
|
|
388
|
+
},
|
|
389
|
+
},
|
|
329
390
|
)
|
|
330
391
|
}
|
|
392
|
+
if (result.status === 'twoFactorRequired')
|
|
393
|
+
return oauthError('server_error', 'Two-factor sign-in is not set up.', 500)
|
|
331
394
|
|
|
332
395
|
return new Response(null, {
|
|
333
396
|
status: 200,
|
|
@@ -337,6 +400,60 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
337
400
|
})
|
|
338
401
|
}
|
|
339
402
|
|
|
403
|
+
/** The second step of a sign-in: the code, against the pending cookie the password set. */
|
|
404
|
+
async function handleVerifyTwoFactor(request: Request): Promise<Response> {
|
|
405
|
+
const twoFactor = options.twoFactor
|
|
406
|
+
if (!twoFactor)
|
|
407
|
+
return problemResponse(
|
|
408
|
+
problemDetails({ title: 'Two-factor sign-in is not set up', status: 400 }),
|
|
409
|
+
)
|
|
410
|
+
const pending = await readShortLived(
|
|
411
|
+
await twoFactor.secret(),
|
|
412
|
+
readCookie(request, names.twoFactorPending),
|
|
413
|
+
)
|
|
414
|
+
const userKey = typeof pending?.sub === 'string' ? pending.sub : undefined
|
|
415
|
+
const login = typeof pending?.login === 'string' ? pending.login : undefined
|
|
416
|
+
if (!userKey || !login)
|
|
417
|
+
return problemResponse(
|
|
418
|
+
problemDetails({
|
|
419
|
+
title: 'Sign in again',
|
|
420
|
+
status: 401,
|
|
421
|
+
detail: 'The sign-in took too long. Enter your password again.',
|
|
422
|
+
}),
|
|
423
|
+
)
|
|
424
|
+
const body = (await request.json().catch(() => ({}))) as { provider?: unknown; code?: unknown }
|
|
425
|
+
const provider = typeof body.provider === 'string' ? body.provider : ''
|
|
426
|
+
const code = typeof body.code === 'string' ? body.code.trim() : ''
|
|
427
|
+
const ok = provider && code ? await twoFactor.verify(userKey, provider, code) : false
|
|
428
|
+
if (!ok) {
|
|
429
|
+
const { lockedOut } = await service.twoFactorFailed(login)
|
|
430
|
+
await audit({ action: 'SignInFailed', login, detail: 'Wrong two-factor code' }, request)
|
|
431
|
+
return problemResponse(
|
|
432
|
+
problemDetails({
|
|
433
|
+
title: lockedOut ? 'Login not allowed' : 'Invalid code',
|
|
434
|
+
status: lockedOut ? 403 : 400,
|
|
435
|
+
operationStatus: lockedOut ? 'lockedOut' : 'InvalidCode',
|
|
436
|
+
}),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
const result = await service.signInExternally(
|
|
440
|
+
login,
|
|
441
|
+
request.headers.get('x-forwarded-for') ?? undefined,
|
|
442
|
+
)
|
|
443
|
+
if (result.status !== 'success')
|
|
444
|
+
return problemResponse(
|
|
445
|
+
problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
|
|
446
|
+
)
|
|
447
|
+
await audit({ action: 'SignIn', user: result.user, detail: 'With two-factor sign-in' }, request)
|
|
448
|
+
const headers = new Headers()
|
|
449
|
+
headers.append(
|
|
450
|
+
'set-cookie',
|
|
451
|
+
sessionCookie(result.sessionId, service.settings.refreshTokenSeconds),
|
|
452
|
+
)
|
|
453
|
+
headers.append('set-cookie', expireCookie(names.twoFactorPending, secure))
|
|
454
|
+
return new Response(null, { status: 200, headers })
|
|
455
|
+
}
|
|
456
|
+
|
|
340
457
|
async function handleRevoke(request: Request): Promise<Response> {
|
|
341
458
|
const form = new URLSearchParams(await request.text())
|
|
342
459
|
const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
|
|
@@ -360,6 +477,101 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
360
477
|
return response
|
|
361
478
|
}
|
|
362
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
|
+
|
|
363
575
|
return {
|
|
364
576
|
async handle(request, pathname) {
|
|
365
577
|
if (!pathname.startsWith(`${prefix}/`)) return undefined
|
|
@@ -371,13 +583,12 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
371
583
|
if (action === 'login' && request.method === 'POST') return handleLogin(request)
|
|
372
584
|
if (action === 'revoke' && request.method === 'POST') return handleRevoke(request)
|
|
373
585
|
if (action === 'signout') return handleSignOut(request, url)
|
|
374
|
-
if (action === 'verify-2fa' && request.method === 'POST')
|
|
375
|
-
return
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
)
|
|
380
|
-
}
|
|
586
|
+
if (action === 'verify-2fa' && request.method === 'POST')
|
|
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)
|
|
381
592
|
return undefined
|
|
382
593
|
},
|
|
383
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'
|
|
@@ -45,7 +51,7 @@ export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
|
|
|
45
51
|
export type LoginResult =
|
|
46
52
|
| { status: 'success'; user: UserRow; sessionId: string }
|
|
47
53
|
| { status: 'invalid'; user?: UserRow; lockedOut?: boolean }
|
|
48
|
-
| { status: 'twoFactorRequired'; providers: string[] }
|
|
54
|
+
| { status: 'twoFactorRequired'; providers: string[]; user: UserRow }
|
|
49
55
|
| { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved'; user?: UserRow }
|
|
50
56
|
|
|
51
57
|
export interface AuthorizationRequest {
|
|
@@ -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 }
|
|
@@ -114,7 +125,17 @@ export class AuthService {
|
|
|
114
125
|
return this.#settings
|
|
115
126
|
}
|
|
116
127
|
|
|
117
|
-
|
|
128
|
+
/**
|
|
129
|
+
* `twoFactorProviders`, when given, names the two-factor providers a user has
|
|
130
|
+
* turned on (story 032): with any, a correct password stops short of a session
|
|
131
|
+
* and the caller asks for a code.
|
|
132
|
+
*/
|
|
133
|
+
async login(
|
|
134
|
+
login: string,
|
|
135
|
+
password: string,
|
|
136
|
+
ipAddress?: string,
|
|
137
|
+
twoFactorProviders?: (user: UserRow) => Promise<string[]>,
|
|
138
|
+
): Promise<LoginResult> {
|
|
118
139
|
return withMinimumDuration(120, async () => {
|
|
119
140
|
const user = await this.#store.findUserByLogin(login)
|
|
120
141
|
if (!user) {
|
|
@@ -155,12 +176,50 @@ export class AuthService {
|
|
|
155
176
|
passwordConfigJson(),
|
|
156
177
|
)
|
|
157
178
|
|
|
179
|
+
const providers = (await twoFactorProviders?.(user)) ?? []
|
|
180
|
+
if (providers.length > 0) return { status: 'twoFactorRequired', providers, user } as const
|
|
181
|
+
|
|
158
182
|
const sessionId = crypto.randomUUID()
|
|
159
183
|
await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
|
|
160
184
|
return { status: 'success', user, sessionId } as const
|
|
161
185
|
})
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A wrong second factor counts as a wrong password does, towards lockout, so a
|
|
190
|
+
* stolen password cannot be followed by guessing codes for free.
|
|
191
|
+
*/
|
|
192
|
+
async twoFactorFailed(login: string): Promise<{ lockedOut: boolean }> {
|
|
193
|
+
const user = await this.#store.findUserByLogin(login)
|
|
194
|
+
if (!user) return { lockedOut: false }
|
|
195
|
+
const attempts = await this.#store.recordLoginFailure(user.id)
|
|
196
|
+
const lockedOut =
|
|
197
|
+
this.#settings.maxFailedLoginAttempts > 0 && attempts >= this.#settings.maxFailedLoginAttempts
|
|
198
|
+
if (lockedOut) await this.#store.lockOut(user.id)
|
|
199
|
+
return { lockedOut }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Signs a user in whom an external identity provider has vouched for (story
|
|
204
|
+
* 031): no password, but the same refusals a password sign-in has — locked out
|
|
205
|
+
* or not approved — and the same session afterwards.
|
|
206
|
+
*/
|
|
207
|
+
async signInExternally(
|
|
208
|
+
login: string,
|
|
209
|
+
ipAddress?: string,
|
|
210
|
+
): Promise<
|
|
211
|
+
| { status: 'success'; user: UserRow; sessionId: string }
|
|
212
|
+
| { status: 'notAllowed'; reason: 'lockedOut' | 'notApproved' | 'unknown' }
|
|
213
|
+
> {
|
|
214
|
+
const user = await this.#store.findUserByLogin(login)
|
|
215
|
+
if (!user) return { status: 'notAllowed', reason: 'unknown' }
|
|
216
|
+
if (user.isLockedOut) return { status: 'notAllowed', reason: 'lockedOut' }
|
|
217
|
+
if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' }
|
|
218
|
+
const sessionId = crypto.randomUUID()
|
|
219
|
+
await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
|
|
220
|
+
return { status: 'success', user, sessionId }
|
|
221
|
+
}
|
|
222
|
+
|
|
164
223
|
/** Issues a single-use authorization code for an already-authenticated session. */
|
|
165
224
|
async authorize(
|
|
166
225
|
sessionId: string,
|
|
@@ -329,6 +388,83 @@ export class AuthService {
|
|
|
329
388
|
return this.signOut(token.sessionId)
|
|
330
389
|
}
|
|
331
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
|
+
|
|
332
468
|
async #issue(
|
|
333
469
|
userId: number,
|
|
334
470
|
sessionId: string,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small signed token for a step between two requests — the browser holding
|
|
3
|
+
* it while it fetches a two-factor code, say — so nothing is stored server
|
|
4
|
+
* side. HS256 with the site's own key; it expires on its own.
|
|
5
|
+
*/
|
|
6
|
+
import { type JWTPayload, jwtVerify, SignJWT } from 'jose'
|
|
7
|
+
|
|
8
|
+
const keyOf = (secret: string) => new TextEncoder().encode(secret)
|
|
9
|
+
|
|
10
|
+
export async function signShortLived(
|
|
11
|
+
secret: string,
|
|
12
|
+
payload: Record<string, string | number>,
|
|
13
|
+
seconds: number,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
return new SignJWT(payload)
|
|
16
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
17
|
+
.setIssuedAt()
|
|
18
|
+
.setExpirationTime(`${seconds}s`)
|
|
19
|
+
.sign(keyOf(secret))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The payload back, or undefined when the token is missing, altered or expired. */
|
|
23
|
+
export async function readShortLived(
|
|
24
|
+
secret: string,
|
|
25
|
+
value: string | undefined,
|
|
26
|
+
): Promise<JWTPayload | undefined> {
|
|
27
|
+
if (!value) return undefined
|
|
28
|
+
try {
|
|
29
|
+
return (await jwtVerify(value, keyOf(secret), { algorithms: ['HS256'] })).payload
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
}
|
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> {
|
package/src/totp.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-based one-time passwords for two-factor sign-in (story 032): RFC 6238
|
|
3
|
+
* over HMAC-SHA1, 30-second steps and 6 digits, which is what every
|
|
4
|
+
* authenticator app expects. A code one step either side of now is accepted for
|
|
5
|
+
* clock drift; a code at or before the last step used is refused, so a code seen
|
|
6
|
+
* over someone's shoulder cannot be used again.
|
|
7
|
+
*
|
|
8
|
+
* Recovery codes stand in for a code once each, and are kept only as hashes.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
11
|
+
import { renderSVG } from 'uqr'
|
|
12
|
+
|
|
13
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
14
|
+
export const TOTP_STEP_SECONDS = 30
|
|
15
|
+
export const TOTP_DIGITS = 6
|
|
16
|
+
|
|
17
|
+
/** Bytes as base32 (RFC 4648, no padding), the form authenticator apps read. */
|
|
18
|
+
export function base32Encode(bytes: Uint8Array): string {
|
|
19
|
+
let bits = 0
|
|
20
|
+
let value = 0
|
|
21
|
+
let out = ''
|
|
22
|
+
for (const byte of bytes) {
|
|
23
|
+
value = (value << 8) | byte
|
|
24
|
+
bits += 8
|
|
25
|
+
while (bits >= 5) {
|
|
26
|
+
out += ALPHABET[(value >>> (bits - 5)) & 31]
|
|
27
|
+
bits -= 5
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31]
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Base32 back to bytes; spaces and lower case, as people type it, are fine. */
|
|
35
|
+
export function base32Decode(text: string): Uint8Array {
|
|
36
|
+
const clean = text.replace(/[\s=-]/g, '').toUpperCase()
|
|
37
|
+
let bits = 0
|
|
38
|
+
let value = 0
|
|
39
|
+
const out: number[] = []
|
|
40
|
+
for (const char of clean) {
|
|
41
|
+
const index = ALPHABET.indexOf(char)
|
|
42
|
+
if (index < 0) throw new Error('not base32')
|
|
43
|
+
value = (value << 5) | index
|
|
44
|
+
bits += 5
|
|
45
|
+
if (bits >= 8) {
|
|
46
|
+
out.push((value >>> (bits - 8)) & 255)
|
|
47
|
+
bits -= 8
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return Uint8Array.from(out)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A new secret: 20 random bytes, as RFC 4226 recommends for HMAC-SHA1. */
|
|
54
|
+
export function generateTotpSecret(): string {
|
|
55
|
+
return base32Encode(randomBytes(20))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The step a moment falls in. */
|
|
59
|
+
export const totpStep = (at: Date = new Date()) =>
|
|
60
|
+
Math.floor(at.getTime() / 1000 / TOTP_STEP_SECONDS)
|
|
61
|
+
|
|
62
|
+
/** The code for a step (RFC 4226's HOTP over the step counter). */
|
|
63
|
+
export function totpCode(secret: Uint8Array, step: number, digits = TOTP_DIGITS): string {
|
|
64
|
+
const counter = Buffer.alloc(8)
|
|
65
|
+
counter.writeBigUInt64BE(BigInt(step))
|
|
66
|
+
const hmac = createHmac('sha1', secret).update(counter).digest()
|
|
67
|
+
const offset = (hmac[hmac.length - 1] as number) & 0x0f
|
|
68
|
+
const binary = (hmac.readUInt32BE(offset) & 0x7fffffff) % 10 ** digits
|
|
69
|
+
return binary.toString().padStart(digits, '0')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The step a code matches, within one step of now and after the last step used;
|
|
74
|
+
* undefined for a wrong, stale or reused code.
|
|
75
|
+
*/
|
|
76
|
+
export function verifyTotp(
|
|
77
|
+
secretBase32: string,
|
|
78
|
+
code: string,
|
|
79
|
+
options: { at?: Date; lastUsedStep?: number | null } = {},
|
|
80
|
+
): number | undefined {
|
|
81
|
+
const digits = code.replace(/\s/g, '')
|
|
82
|
+
if (!/^\d{6}$/.test(digits)) return undefined
|
|
83
|
+
let secret: Uint8Array
|
|
84
|
+
try {
|
|
85
|
+
secret = base32Decode(secretBase32)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
const now = totpStep(options.at)
|
|
90
|
+
for (const step of [now, now - 1, now + 1]) {
|
|
91
|
+
if (
|
|
92
|
+
options.lastUsedStep !== undefined &&
|
|
93
|
+
options.lastUsedStep !== null &&
|
|
94
|
+
step <= options.lastUsedStep
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
const expected = Buffer.from(totpCode(secret, step))
|
|
98
|
+
if (timingSafeEqual(expected, Buffer.from(digits))) return step
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The `otpauth://` URI an authenticator app reads from the QR code. */
|
|
104
|
+
export function otpauthUri(options: { secret: string; account: string; issuer: string }): string {
|
|
105
|
+
const label = encodeURIComponent(`${options.issuer}:${options.account}`)
|
|
106
|
+
const query = new URLSearchParams({
|
|
107
|
+
secret: options.secret,
|
|
108
|
+
issuer: options.issuer,
|
|
109
|
+
algorithm: 'SHA1',
|
|
110
|
+
digits: String(TOTP_DIGITS),
|
|
111
|
+
period: String(TOTP_STEP_SECONDS),
|
|
112
|
+
})
|
|
113
|
+
return `otpauth://totp/${label}?${query}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The QR code for an `otpauth` URI, as an SVG `data:` URL an `<img>` can show. */
|
|
117
|
+
export function qrCodeDataUrl(text: string): string {
|
|
118
|
+
const svg = renderSVG(text, { border: 2 })
|
|
119
|
+
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const RECOVERY_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
|
|
123
|
+
|
|
124
|
+
/** Ten recovery codes, `xxxxx-xxxxx`, from an alphabet with nothing to confuse. */
|
|
125
|
+
export function generateRecoveryCodes(count = 10): string[] {
|
|
126
|
+
return Array.from({ length: count }, () => {
|
|
127
|
+
const bytes = randomBytes(10)
|
|
128
|
+
const chars = Array.from(bytes, (b) => RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length])
|
|
129
|
+
return `${chars.slice(0, 5).join('')}-${chars.slice(5).join('')}`
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** How a recovery code is stored, and looked up: its SHA-256, ignoring case and spacing. */
|
|
134
|
+
export function hashRecoveryCode(code: string): string {
|
|
135
|
+
return createHash('sha256').update(code.replace(/[\s-]/g, '').toLowerCase()).digest('hex')
|
|
136
|
+
}
|