@meith/accounts 0.16.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.
@@ -0,0 +1,198 @@
1
+ import { ConflictError, ForbiddenError, ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { foldIdentifier } from '../case-fold'
5
+ import type {
6
+ AccountRecord,
7
+ AccountRepository,
8
+ Clock,
9
+ UserIdentityRecord,
10
+ UserIdentityRepository,
11
+ } from '../ports'
12
+ import type { IdentityService, LoginResult, RequestContext } from '../service'
13
+ import type { IdentityProvider, ProviderProfile } from './types'
14
+
15
+ export interface FederationDeps {
16
+ readonly identity: IdentityService
17
+ readonly accounts: AccountRepository
18
+ readonly identities: UserIdentityRepository
19
+ readonly clock?: Clock
20
+ }
21
+
22
+ export interface CompleteSignInInput {
23
+ readonly provider: IdentityProvider
24
+ readonly profile: ProviderProfile
25
+ readonly context?: RequestContext
26
+ readonly beforeProvision?: () => Promise<void>
27
+ }
28
+
29
+ export type FederationOutcome =
30
+ | {
31
+ readonly status: 'signed-in'
32
+ readonly account: AccountRecord
33
+ readonly login: LoginResult
34
+ readonly created: boolean
35
+ }
36
+ | {
37
+ readonly status: 'pending'
38
+ readonly account: AccountRecord
39
+ readonly verificationToken: string | null
40
+ }
41
+
42
+ export const UNLINK_LAST_CREDENTIAL =
43
+ 'That is the only way you have left to sign in. Set a password first, then unlink it.'
44
+
45
+ export class FederationService {
46
+ private readonly identity: IdentityService
47
+ private readonly accounts: AccountRepository
48
+ private readonly identities: UserIdentityRepository
49
+ private readonly now: Clock
50
+
51
+ constructor(deps: FederationDeps) {
52
+ this.identity = deps.identity
53
+ this.accounts = deps.accounts
54
+ this.identities = deps.identities
55
+ this.now = deps.clock ?? (() => new Date())
56
+ }
57
+
58
+ async completeSignIn(input: CompleteSignInInput): Promise<FederationOutcome> {
59
+ const context = input.context ?? {}
60
+ const linked = await this.identities.findBySubject(input.provider.id, input.profile.subject)
61
+
62
+ if (linked !== null) {
63
+ const account = await this.accounts.findById(linked.userId)
64
+ if (account === null) {
65
+ throw new ForbiddenError(
66
+ `The account behind that ${input.provider.label} sign-in no longer exists.`,
67
+ )
68
+ }
69
+
70
+ const login = await this.identity.startSessionFor(account, context)
71
+ await this.identities.markUsed(linked.id, this.now())
72
+ return { status: 'signed-in', account, login, created: false }
73
+ }
74
+
75
+ const email = normalisedEmail(input.profile)
76
+ const existing =
77
+ email === null ? null : await this.accounts.findByEmailLower(foldIdentifier(email))
78
+
79
+ if (existing !== null) {
80
+ if (!input.profile.emailVerified) {
81
+ throw new ForbiddenError(
82
+ `An account here already uses that address, and ${input.provider.label} has not ` +
83
+ 'confirmed it belongs to you. Sign in with your password and link the two from ' +
84
+ 'your account security page.',
85
+ )
86
+ }
87
+
88
+ const login = await this.identity.startSessionFor(existing, context)
89
+ await this.link({
90
+ userId: existing.id,
91
+ provider: input.provider,
92
+ profile: input.profile,
93
+ })
94
+ return { status: 'signed-in', account: existing, login, created: false }
95
+ }
96
+
97
+ if (email === null) {
98
+ throw new ValidationError(
99
+ `${input.provider.label} did not share a confirmed e-mail address, so there is ` +
100
+ 'nothing to register an account against. Register with a password instead.',
101
+ )
102
+ }
103
+
104
+ await input.beforeProvision?.()
105
+
106
+ const provisioned = await this.identity.provisionFederated(
107
+ {
108
+ username: suggestedUsername(input.profile, email),
109
+ email,
110
+ emailVerified: input.profile.emailVerified,
111
+ },
112
+ context,
113
+ )
114
+
115
+ await this.link({
116
+ userId: provisioned.account.id,
117
+ provider: input.provider,
118
+ profile: input.profile,
119
+ })
120
+
121
+ if (provisioned.account.state !== 'active') {
122
+ return {
123
+ status: 'pending',
124
+ account: provisioned.account,
125
+ verificationToken: provisioned.verificationToken ?? null,
126
+ }
127
+ }
128
+
129
+ return {
130
+ status: 'signed-in',
131
+ account: provisioned.account,
132
+ login: await this.identity.startSessionFor(provisioned.account, context),
133
+ created: true,
134
+ }
135
+ }
136
+
137
+ async linkToViewer(input: {
138
+ readonly userId: number
139
+ readonly provider: IdentityProvider
140
+ readonly profile: ProviderProfile
141
+ }): Promise<UserIdentityRecord> {
142
+ const linked = await this.identities.findBySubject(input.provider.id, input.profile.subject)
143
+
144
+ if (linked !== null && linked.userId !== input.userId) {
145
+ throw new ConflictError(
146
+ `That ${input.provider.label} account is already linked to another member here.`,
147
+ )
148
+ }
149
+
150
+ return linked ?? this.link(input)
151
+ }
152
+
153
+ async unlink(input: {
154
+ readonly userId: number
155
+ readonly identityId: number
156
+ readonly hasPassword: boolean
157
+ readonly usablePasskeys: number
158
+ readonly usableProviders: readonly string[]
159
+ }): Promise<void> {
160
+ const remaining = (await this.identities.listForUser(input.userId)).filter(
161
+ (identity) =>
162
+ identity.id !== input.identityId && input.usableProviders.includes(identity.provider),
163
+ )
164
+
165
+ if (!input.hasPassword && input.usablePasskeys === 0 && remaining.length === 0) {
166
+ throw new ForbiddenError(UNLINK_LAST_CREDENTIAL)
167
+ }
168
+
169
+ const removed = await this.identities.unlink(input.userId, input.identityId)
170
+ if (!removed) {
171
+ throw new ConflictError(msg('error.accounts.sign-in-already-unlinked'))
172
+ }
173
+ }
174
+
175
+ private async link(input: {
176
+ readonly userId: number
177
+ readonly provider: IdentityProvider
178
+ readonly profile: ProviderProfile
179
+ }): Promise<UserIdentityRecord> {
180
+ return this.identities.link({
181
+ userId: input.userId,
182
+ provider: input.provider.id,
183
+ subject: input.profile.subject,
184
+ label: input.profile.username ?? input.profile.email ?? input.profile.displayName,
185
+ now: this.now(),
186
+ })
187
+ }
188
+ }
189
+
190
+ function normalisedEmail(profile: ProviderProfile): string | null {
191
+ const email = profile.email?.trim() ?? ''
192
+ return email === '' ? null : email
193
+ }
194
+
195
+ function suggestedUsername(profile: ProviderProfile, email: string): string {
196
+ const local = email.split('@')[0] ?? ''
197
+ return profile.username ?? profile.displayName ?? local
198
+ }
@@ -0,0 +1,50 @@
1
+ export type ProviderKind = 'github' | 'google' | 'oidc'
2
+
3
+ export interface ProviderProfile {
4
+ readonly subject: string
5
+ readonly email: string | null
6
+ readonly emailVerified: boolean
7
+ readonly username: string | null
8
+ readonly displayName: string | null
9
+ }
10
+
11
+ export interface AuthorizeInput {
12
+ readonly redirectUri: string
13
+ readonly state: string
14
+ readonly nonce: string
15
+ readonly codeVerifier: string
16
+ }
17
+
18
+ export interface ExchangeInput {
19
+ readonly code: string
20
+ readonly redirectUri: string
21
+ readonly nonce: string
22
+ readonly codeVerifier: string
23
+ }
24
+
25
+ export interface IdentityProvider {
26
+ readonly id: ProviderKind
27
+ readonly label: string
28
+ authorizationUrl(input: AuthorizeInput): Promise<string>
29
+ exchange(input: ExchangeInput): Promise<ProviderProfile>
30
+ }
31
+
32
+ export type Fetcher = typeof fetch
33
+
34
+ export interface ProviderCredentials {
35
+ readonly enabled: boolean
36
+ readonly clientId: string
37
+ readonly clientSecret: string
38
+ }
39
+
40
+ export interface OidcCredentials extends ProviderCredentials {
41
+ readonly issuer: string
42
+ readonly label: string
43
+ readonly scopes: string
44
+ }
45
+
46
+ export interface FederationOptions {
47
+ readonly github: ProviderCredentials
48
+ readonly google: ProviderCredentials
49
+ readonly oidc: OidcCredentials
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,223 @@
1
+ export {
2
+ assertUsableFilter,
3
+ BAN_FILTER_PATTERN_MAX,
4
+ BAN_FILTER_TYPES,
5
+ BAN_FILTER_WILDCARD_MAX,
6
+ type BanFilter,
7
+ type BanFilterSubject,
8
+ type BanFilterType,
9
+ matchBanFilter,
10
+ } from './ban-filter'
11
+ export { type BanInput, BanService, type BanServiceDeps } from './ban-service'
12
+ export { foldIdentifier } from './case-fold'
13
+ export {
14
+ CREDENTIAL_PROOF_TTL_MS,
15
+ hasFreshCredentialProof,
16
+ } from './credential-proof'
17
+ export {
18
+ decodeBase64Url,
19
+ decodeBase64UrlText,
20
+ encodeBase64Url,
21
+ randomBase64Url,
22
+ } from './crypto/base64url'
23
+ export {
24
+ isLegacyHash,
25
+ type LegacyMybbHash,
26
+ MYBB_PREFIX,
27
+ PHPBB_PREFIX,
28
+ parseMybbHash,
29
+ verifyLegacyPassword,
30
+ verifyMybbPassword,
31
+ verifyPhpbbPassword,
32
+ } from './crypto/legacy'
33
+ export {
34
+ type Argon2Params,
35
+ CURRENT_PASSWORD_POLICY,
36
+ hashPassword,
37
+ needsRehash,
38
+ type PasswordPolicy,
39
+ parseArgon2Params,
40
+ verifyPassword,
41
+ } from './crypto/password'
42
+ export {
43
+ generateToken,
44
+ hashToken,
45
+ timingSafeEqual,
46
+ } from './crypto/tokens'
47
+ export {
48
+ configuredProviders,
49
+ isProviderKind,
50
+ PROVIDER_KINDS,
51
+ type ProviderBuildDeps,
52
+ parseScopes,
53
+ providerFor,
54
+ providerLabel,
55
+ } from './federation/catalog'
56
+ export { type GithubProviderConfig, githubProvider } from './federation/github'
57
+ export {
58
+ DEFAULT_OIDC_SCOPES,
59
+ type OidcProviderConfig,
60
+ oidcProvider,
61
+ } from './federation/oidc'
62
+ export { codeChallenge, type HandshakeSecrets, newHandshake } from './federation/pkce'
63
+ export {
64
+ type CompleteSignInInput,
65
+ type FederationDeps,
66
+ type FederationOutcome,
67
+ FederationService,
68
+ UNLINK_LAST_CREDENTIAL,
69
+ } from './federation/service'
70
+ export type {
71
+ AuthorizeInput,
72
+ ExchangeInput,
73
+ FederationOptions,
74
+ IdentityProvider,
75
+ OidcCredentials,
76
+ ProviderCredentials,
77
+ ProviderKind,
78
+ ProviderProfile,
79
+ } from './federation/types'
80
+ export {
81
+ AUTOMATIC_LOCALE,
82
+ AUTOMATIC_TIMEZONE,
83
+ BIO_MAX,
84
+ EMAIL_CHANGE_TTL_MINUTES,
85
+ isKnownTimezone,
86
+ isLocalePreference,
87
+ isTimezonePreference,
88
+ LOCATION_MAX,
89
+ type MemberGroupChoice,
90
+ type MemberSettings,
91
+ type MemberSettingsRepository,
92
+ MemberSettingsService,
93
+ PAGE_SIZE_MAX,
94
+ PAGE_SIZE_MIN,
95
+ WEBSITE_MAX,
96
+ } from './member-settings'
97
+ export { MemoryBanFilters, MemoryBans } from './memory-bans'
98
+ export { createMemoryStore } from './memory-repos'
99
+ export {
100
+ AUTH_SETTING_KEYS,
101
+ type AuthPolicy,
102
+ DEFAULT_AUTH_POLICY,
103
+ type ResolvedAuthSettings,
104
+ resolveAuthPolicy,
105
+ type SettingReader,
106
+ } from './policy'
107
+ export type {
108
+ AccountRecord,
109
+ AccountRepository,
110
+ AccountState,
111
+ AccountStore,
112
+ ActiveSessionRecord,
113
+ AuthConfig,
114
+ AuthEventKind,
115
+ AuthEventRecord,
116
+ AuthEventRepository,
117
+ BanFilterRepository,
118
+ BanRecord,
119
+ BanRepository,
120
+ Clock,
121
+ CreateBanInput,
122
+ CredentialPurpose,
123
+ CredentialTokenRepository,
124
+ LinkIdentityInput,
125
+ LoginAttemptRepository,
126
+ LoginBucket,
127
+ MemberProfileRecord,
128
+ MemberProfileRepository,
129
+ MemberSuggestion,
130
+ NewAccount,
131
+ NewAuthEvent,
132
+ NewPasskey,
133
+ PasskeyRecord,
134
+ PasskeyRepository,
135
+ RecoveryCodeRepository,
136
+ RememberRotation,
137
+ RememberTokenRepository,
138
+ SessionLocation,
139
+ SessionRecord,
140
+ SessionRepository,
141
+ TwoFactorRecord,
142
+ TwoFactorRepository,
143
+ UserIdentityRecord,
144
+ UserIdentityRepository,
145
+ } from './ports'
146
+ export { AUTH_EVENT_KINDS, REMEMBER_ROTATION_GRACE_SECONDS, withinRotationGrace } from './ports'
147
+ export {
148
+ REGISTER_FIELD,
149
+ type RegisterField,
150
+ rejectedField,
151
+ } from './register-fields'
152
+ export {
153
+ type ActivationOutcome,
154
+ type ActivationResult,
155
+ type BanLookup,
156
+ type FederatedProvision,
157
+ type IdentityDeps,
158
+ IdentityService,
159
+ type LoginOutcome,
160
+ type LoginResult,
161
+ type PendingSecondFactor,
162
+ REGISTRATION_CLOSED,
163
+ type RegisterInput,
164
+ type RegisterResult,
165
+ type RequestContext,
166
+ type ResendVerification,
167
+ type ResetRequest,
168
+ SECOND_FACTOR_TTL_MINUTES,
169
+ type SecondFactorLookup,
170
+ VERIFICATION_TTL_HOURS,
171
+ } from './service'
172
+ export {
173
+ type RememberedLogin,
174
+ type ResumeOutcome,
175
+ SessionService,
176
+ type SessionServiceDeps,
177
+ } from './session-service'
178
+ export { decodeBase32, encodeBase32, isBase32 } from './totp/base32'
179
+ export { assertSealingKey, openSecret, sealSecret } from './totp/secret-box'
180
+ export {
181
+ clearSecondFactor,
182
+ type Enrolment,
183
+ enrolmentLookup,
184
+ holdsSecondFactor,
185
+ newRecoveryCode,
186
+ normaliseRecoveryCode,
187
+ RECOVERY_CODE_COUNT,
188
+ REPLAYED_CODE,
189
+ type SecondFactorOutcome,
190
+ type TwoFactorDeps,
191
+ TwoFactorService,
192
+ type TwoFactorState,
193
+ WRONG_CODE,
194
+ } from './totp/service'
195
+ export {
196
+ generateTotpSecret,
197
+ matchTotp,
198
+ otpauthUri,
199
+ stepAt,
200
+ TOTP_DIGITS,
201
+ TOTP_PERIOD_SECONDS,
202
+ totpCode,
203
+ } from './totp/totp'
204
+ export {
205
+ CHALLENGE_BYTES,
206
+ newChallenge,
207
+ PASSKEY_LABEL_MAX,
208
+ PASSKEY_LIMIT,
209
+ type PasskeyAssertionOptions,
210
+ type PasskeyDeps,
211
+ type PasskeyRegistrationOptions,
212
+ PasskeyService,
213
+ passkeyLabel,
214
+ REMOVE_LAST_CREDENTIAL,
215
+ } from './webauthn/service'
216
+ export {
217
+ type AssertionResponse,
218
+ type RegisteredCredential,
219
+ type RegistrationResponse,
220
+ type RelyingParty,
221
+ verifyAssertion,
222
+ verifyRegistration,
223
+ } from './webauthn/verify'