@meith/accounts 0.21.2 → 0.22.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 +2 -2
- package/src/federation/catalog.ts +0 -5
- package/src/member-settings.ts +3 -0
- package/src/ports.ts +0 -25
- package/src/service.ts +0 -21
- package/src/totp/secret-box.ts +0 -6
- package/src/totp/service.ts +0 -10
- package/src/totp/totp.ts +0 -11
- package/src/webauthn/service.ts +0 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/accounts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"hash-wasm": "^4.12.0",
|
|
23
|
-
"@meith/i18n": "0.
|
|
23
|
+
"@meith/i18n": "0.22.0"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -11,11 +11,6 @@ import type {
|
|
|
11
11
|
|
|
12
12
|
export const PROVIDER_KINDS: readonly ProviderKind[] = ['github', 'google', 'oidc']
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* What a link is called when the provider behind it is switched off: the
|
|
16
|
-
* identity stays on the account, so the row still has to say something a
|
|
17
|
-
* member recognises rather than the key it is stored under.
|
|
18
|
-
*/
|
|
19
14
|
const PROVIDER_LABELS: Readonly<Record<ProviderKind, string>> = {
|
|
20
15
|
github: 'GitHub',
|
|
21
16
|
google: 'Google',
|
package/src/member-settings.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface MemberSettings {
|
|
|
32
32
|
readonly website: string | null
|
|
33
33
|
readonly bio: string | null
|
|
34
34
|
readonly displayGroupId: number | null
|
|
35
|
+
readonly massMailOptInAt: Date | null
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export interface MemberGroupChoice {
|
|
@@ -67,6 +68,8 @@ export interface MemberSettingsRepository {
|
|
|
67
68
|
readonly invisible: boolean
|
|
68
69
|
}): Promise<void>
|
|
69
70
|
|
|
71
|
+
saveMassMailOptIn(input: { readonly userId: number; readonly optIn: boolean }): Promise<void>
|
|
72
|
+
|
|
70
73
|
adoptEmail(input: {
|
|
71
74
|
readonly userId: number
|
|
72
75
|
readonly email: string
|
package/src/ports.ts
CHANGED
|
@@ -115,27 +115,8 @@ export type RememberRotation =
|
|
|
115
115
|
| { readonly status: 'reuse'; readonly userId: number; readonly familyId: string }
|
|
116
116
|
| { readonly status: 'invalid' }
|
|
117
117
|
|
|
118
|
-
/**
|
|
119
|
-
* How long after a remember token is rotated its old value is still honoured.
|
|
120
|
-
*
|
|
121
|
-
* Rotation is single-use, and two requests carrying the same cookie arrive
|
|
122
|
-
* whenever a browser restores several tabs at once, a link is double-clicked, or
|
|
123
|
-
* a prefetch races the navigation that prompted it. Exactly one wins the claim;
|
|
124
|
-
* without this window the others are indistinguishable from a stolen token and
|
|
125
|
-
* cost the member every session they hold. Real theft reuses a token long after
|
|
126
|
-
* the fact, not inside the same breath.
|
|
127
|
-
*/
|
|
128
118
|
export const REMEMBER_ROTATION_GRACE_SECONDS = 30
|
|
129
119
|
|
|
130
|
-
/**
|
|
131
|
-
* Whether a spent remember token is a concurrent request rather than a theft.
|
|
132
|
-
*
|
|
133
|
-
* A revoked family is always theft: the board has already decided about it.
|
|
134
|
-
* Elapsed time is not required to be positive — requests racing each other
|
|
135
|
-
* stamp `now` on the way in, so the one that loses the claim can carry a
|
|
136
|
-
* timestamp from just before the winner wrote `usedAt`, which is the very case
|
|
137
|
-
* this window exists to forgive.
|
|
138
|
-
*/
|
|
139
120
|
export function withinRotationGrace(
|
|
140
121
|
row: { readonly usedAt: Date | null; readonly revokedAt: Date | null },
|
|
141
122
|
now: Date,
|
|
@@ -187,11 +168,6 @@ export interface CredentialTokenRepository {
|
|
|
187
168
|
purpose: CredentialPurpose,
|
|
188
169
|
now: Date,
|
|
189
170
|
): Promise<{ userId: number; payload: string | null } | null>
|
|
190
|
-
/**
|
|
191
|
-
* The same lookup without spending the token. A half-finished sign-in has to
|
|
192
|
-
* survive a mistyped code, so the token that carries it is only consumed once
|
|
193
|
-
* the second factor is actually satisfied.
|
|
194
|
-
*/
|
|
195
171
|
peek(
|
|
196
172
|
tokenHash: string,
|
|
197
173
|
purpose: CredentialPurpose,
|
|
@@ -272,7 +248,6 @@ export interface TwoFactorRepository {
|
|
|
272
248
|
now: Date
|
|
273
249
|
}): Promise<TwoFactorRecord>
|
|
274
250
|
confirm(userId: number, step: number, now: Date): Promise<boolean>
|
|
275
|
-
/** False when the step has already been spent, which is a replayed code. */
|
|
276
251
|
spendStep(userId: number, step: number): Promise<boolean>
|
|
277
252
|
remove(userId: number): Promise<boolean>
|
|
278
253
|
}
|
package/src/service.ts
CHANGED
|
@@ -25,11 +25,6 @@ export interface IdentityDeps {
|
|
|
25
25
|
readonly secondFactor?: SecondFactorLookup
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* Whether an account asks for something beyond its password. Kept as a port
|
|
30
|
-
* rather than the whole two-factor service so that the login path cannot reach
|
|
31
|
-
* the secrets it holds — it only ever needs the yes or no.
|
|
32
|
-
*/
|
|
33
28
|
export interface SecondFactorLookup {
|
|
34
29
|
isEnrolled(userId: number): Promise<boolean>
|
|
35
30
|
}
|
|
@@ -61,11 +56,6 @@ export interface LoginResult {
|
|
|
61
56
|
readonly expiresAt: Date
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
/**
|
|
65
|
-
* How long a half-finished sign-in waits for its second factor. Long enough to
|
|
66
|
-
* fetch a phone from another room, short enough that a password proven on a
|
|
67
|
-
* shared machine does not stay proven.
|
|
68
|
-
*/
|
|
69
59
|
export const SECOND_FACTOR_TTL_MINUTES = 10
|
|
70
60
|
|
|
71
61
|
export type LoginOutcome =
|
|
@@ -432,11 +422,6 @@ export class IdentityService {
|
|
|
432
422
|
return { token, expiresAt }
|
|
433
423
|
}
|
|
434
424
|
|
|
435
|
-
/**
|
|
436
|
-
* Who a half-finished sign-in belongs to, without spending it — the code can
|
|
437
|
-
* be mistyped, and a member who fumbles it should not have to start from
|
|
438
|
-
* their password again.
|
|
439
|
-
*/
|
|
440
425
|
async pendingSecondFactor(token: string): Promise<PendingSecondFactor | null> {
|
|
441
426
|
const held = await this.store.tokens.peek(await hashToken(token), 'second_factor', this.now())
|
|
442
427
|
if (held === null) return null
|
|
@@ -444,7 +429,6 @@ export class IdentityService {
|
|
|
444
429
|
return { userId: held.userId, remember: held.payload === 'remember' }
|
|
445
430
|
}
|
|
446
431
|
|
|
447
|
-
/** Spends the hold and starts the session it was standing in for. */
|
|
448
432
|
async redeemSecondFactor(token: string, context: RequestContext = {}): Promise<LoginResult> {
|
|
449
433
|
const at = this.now()
|
|
450
434
|
const redeemed = await this.store.tokens.consume(await hashToken(token), 'second_factor', at)
|
|
@@ -464,11 +448,6 @@ export class IdentityService {
|
|
|
464
448
|
await this.store.tokens.revokeAllForUser(userId, 'second_factor')
|
|
465
449
|
}
|
|
466
450
|
|
|
467
|
-
/**
|
|
468
|
-
* The second step gets its own counter. The password counters were cleared
|
|
469
|
-
* when the password proved out, and a six-digit code is worth a million
|
|
470
|
-
* guesses — far fewer than a password, and so worth far less patience.
|
|
471
|
-
*/
|
|
472
451
|
async assertSecondFactorAttemptsLeft(userId: number): Promise<void> {
|
|
473
452
|
const max = this.config.maxLoginAttempts
|
|
474
453
|
if (max <= 0) return
|
package/src/totp/secret-box.ts
CHANGED
|
@@ -8,12 +8,6 @@ const IV_BYTES = 12
|
|
|
8
8
|
|
|
9
9
|
const INFO = new TextEncoder().encode('meith/two-factor-secret')
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* The shared secret an authenticator app holds is a password equivalent: with
|
|
13
|
-
* it, anybody can mint that member's codes forever. It is sealed with a key
|
|
14
|
-
* derived from AUTH_SECRET so that a leaked backup, or a read of the table by
|
|
15
|
-
* anything that never had the environment, is not enough on its own.
|
|
16
|
-
*/
|
|
17
11
|
export async function sealSecret(plaintext: string, passphrase: string): Promise<string> {
|
|
18
12
|
const key = await deriveKey(passphrase)
|
|
19
13
|
const iv = new Uint8Array(IV_BYTES)
|
package/src/totp/service.ts
CHANGED
|
@@ -117,11 +117,6 @@ export class TwoFactorService {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/**
|
|
121
|
-
* The enrolment already under way, so the setup screen survives a reload
|
|
122
|
-
* without minting a second secret — which would strand whatever the member
|
|
123
|
-
* had already typed into their authenticator app.
|
|
124
|
-
*/
|
|
125
120
|
async pendingEnrolment(userId: number, boardName: string): Promise<Enrolment | null> {
|
|
126
121
|
const record = await this.twoFactor.find(userId)
|
|
127
122
|
if (record === null || record.confirmedAt !== null) return null
|
|
@@ -223,11 +218,6 @@ export class TwoFactorService {
|
|
|
223
218
|
}
|
|
224
219
|
}
|
|
225
220
|
|
|
226
|
-
/**
|
|
227
|
-
* The yes-or-no the login path needs, straight off the repository. Built here
|
|
228
|
-
* rather than from the whole service so that composing the two does not hand
|
|
229
|
-
* the login path a key it has no use for.
|
|
230
|
-
*/
|
|
231
221
|
export function enrolmentLookup(repository: TwoFactorRepository): {
|
|
232
222
|
isEnrolled(userId: number): Promise<boolean>
|
|
233
223
|
} {
|
package/src/totp/totp.ts
CHANGED
|
@@ -6,11 +6,6 @@ export const TOTP_DIGITS = 6
|
|
|
6
6
|
|
|
7
7
|
export const TOTP_SECRET_BYTES = 20
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* How far either side of now a code is still taken. One step each way covers a
|
|
11
|
-
* device whose clock has drifted and a member who started typing at 29 seconds
|
|
12
|
-
* past; more than that widens the window an intercepted code stays usable in.
|
|
13
|
-
*/
|
|
14
9
|
export const TOTP_SKEW_STEPS = 1
|
|
15
10
|
|
|
16
11
|
export function generateTotpSecret(byteLength = TOTP_SECRET_BYTES): string {
|
|
@@ -57,12 +52,6 @@ export interface TotpMatch {
|
|
|
57
52
|
readonly step: number
|
|
58
53
|
}
|
|
59
54
|
|
|
60
|
-
/**
|
|
61
|
-
* The step the code belongs to, or null. The step is returned rather than a
|
|
62
|
-
* bare yes so the caller can refuse a code it has already accepted: a code is
|
|
63
|
-
* valid for thirty seconds, and anybody who reads it over a shoulder or off a
|
|
64
|
-
* proxy log has that long to use it first.
|
|
65
|
-
*/
|
|
66
55
|
export async function matchTotp(input: {
|
|
67
56
|
readonly secret: string
|
|
68
57
|
readonly code: string
|
package/src/webauthn/service.ts
CHANGED
|
@@ -200,12 +200,6 @@ export class PasskeyService {
|
|
|
200
200
|
return { account, login }
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
204
|
-
* The same signature check as signing in, against one named account and with
|
|
205
|
-
* no session at the end of it. A second factor has to prove the device
|
|
206
|
-
* belongs to the member who has just given their password — accepting any
|
|
207
|
-
* registered passkey would let anybody past anybody's second step.
|
|
208
|
-
*/
|
|
209
203
|
async proveOwnership(input: {
|
|
210
204
|
readonly userId: number
|
|
211
205
|
readonly credentialId: string
|