@starci/hfs 5.2.0 → 5.4.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/CHANGELOG.md +43 -0
- package/README.md +2 -1
- package/cache/hfs-family.d.mts +2 -3
- package/cache/hfs-family.mjs +4 -9
- package/cache/secret-records.d.mts +7 -0
- package/cache/secret-records.mjs +12 -0
- package/cache/session.mjs +4 -2
- package/codemod/account-doors.d.mts +36 -0
- package/codemod/account-doors.mjs +134 -0
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +35 -1
- package/codemod/module-definition.d.mts +59 -0
- package/codemod/module-definition.mjs +257 -0
- package/codemod/quality-binding.d.mts +33 -0
- package/codemod/quality-binding.mjs +38 -0
- package/codemod/secrets-cache.d.mts +35 -0
- package/codemod/secrets-cache.mjs +70 -0
- package/codemod/sops-rules.d.mts +34 -0
- package/codemod/sops-rules.mjs +69 -0
- package/codemod/theme-tokens.mjs +48 -2
- package/lint/run.d.mts +6 -0
- package/lint/run.mjs +63 -10
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
- package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
- package/runtime/knowledge/hfs/rules.yaml +13 -9
- package/runtime/knowledge/hfs/slots.yaml +105 -12
- package/runtime/modules/kernel/failure-codes.yaml +5 -4
- package/runtime/scripts/hfs/architecture/config.d.mts +4 -0
- package/runtime/scripts/hfs/architecture/config.mjs +25 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.d.mts +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
- package/runtime/scripts/hfs/architecture/root-packages.d.mts +26 -0
- package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
- package/runtime/scripts/hfs/architecture/type-context.d.mts +4 -1
- package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
- package/runtime/scripts/hfs/architecture/typescript.d.mts +7 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +39 -1
- package/runtime/scripts/hfs/rules/monorepo.d.mts +4 -0
- package/runtime/scripts/hfs/rules/monorepo.mjs +10 -5
- package/runtime/scripts/hfs/rules/secrets.mjs +11 -0
- package/runtime/scripts/hfs/secret-errors.d.mts +3 -0
- package/runtime/scripts/hfs/secret-errors.mjs +8 -0
- package/runtime/scripts/hfs/secret-init.d.mts +6 -0
- package/runtime/scripts/hfs/secret-init.mjs +23 -0
- package/runtime/scripts/hfs/secret-sops.mjs +9 -1
- package/runtime/scripts/hfs/secret-standard.d.mts +20 -0
- package/runtime/scripts/hfs/secret-standard.mjs +106 -0
- package/runtime/scripts/hfs/secret.mjs +5 -1
- package/runtime/scripts/hfs/view.d.mts +23 -0
- package/runtime/scripts/hfs/view.mjs +68 -0
- package/runtime/scripts/lib/declaration-surface.d.mts +4 -0
- package/runtime/scripts/lib/declaration-surface.mjs +55 -1
- package/runtime/scripts/lib/secret-records.d.mts +13 -0
- package/runtime/scripts/lib/secret-records.mjs +83 -0
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-queue-lite.mjs +1 -3
- package/scaffold/app.d.mts +2 -2
- package/scaffold/app.mjs +10 -12
- package/scaffold/lite-exports.mjs +1 -15
- package/scripts/sync-bundles.d.mts +8 -0
- package/stack/images/openclaw-runtime/Dockerfile +18 -0
- package/stack/images/openclaw-runtime/entrypoint.sh +23 -0
- package/stack/images/openclaw-runtime/openclaw-init.mjs +101 -0
- package/stack/images/openclaw-runtime/openclaw-relay.mjs +25 -0
- package/stack/local-up.mjs +3 -0
- package/stack/runtime-layers.d.mts +21 -0
- package/stack/runtime-layers.mjs +67 -0
- package/sync/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/hygiene.mjs +3 -1
- package/sync/index.d.mts +11 -1
- package/sync/index.mjs +61 -10
- package/templates/app/auth-doors.sql.partial +115 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
- package/templates/app/package-scripts/core.json +1 -1
- package/templates/app/package-scripts/package.json +1 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +9 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +3 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +19 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +3 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +5 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +4 -0
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +27 -5
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +33 -0
- package/templates/be/auth-lite/src/features/api/auth/application/confirm.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/confirm.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/forgot.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/forgot.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/resend.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/resend.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/reset.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/reset.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +3 -10
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +3 -10
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +56 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/forgot.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/resend.request.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +18 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/token.request.ts +11 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +30 -30
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +7 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +113 -49
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +42 -53
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.log-events.ts +6 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +233 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +176 -0
- package/templates/be/auth-lite/src/modules/domain/identity/identity-sign-in.log-events.ts +6 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +98 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +3 -10
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +6 -3
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +17 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +6 -0
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +29 -33
- package/templates/be/inbox/module-definition.ts.tpl +2 -9
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +2 -11
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/event-bus/platform/event-bus.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/jobs/platform/jobs.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/projections/projection.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +3 -10
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/realtime/channel.sse.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +3 -9
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +2 -9
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +3 -9
- package/templates/be/platform/graphql/graphql.module-definition.ts +2 -9
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +2 -9
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +3 -9
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/domain/note/note.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/database/database.module-definition.ts +3 -13
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +4 -11
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +4 -9
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +5 -11
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +4 -11
- package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +2 -9
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +2 -9
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +3 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity-mail.messages.ts +65 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/index.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.config.ts +33 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.decorators.ts +14 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module-definition.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.module.ts +27 -0
- package/templates/be/skeleton-lite/src/modules/integrations/mail/mail.options.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +2 -9
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +11 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -0
- package/templates/be/table/api/feature.module-definition.ts.tpl +3 -9
- package/templates/be/table/api/graphql.module-definition.ts.tpl +3 -9
- package/templates/be/table/module-definition.ts.tpl +2 -9
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +16 -0
- package/templates/fe/auth-lite/apps/web/src/modules/auth/index.ts +5 -0
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +30 -67
- package/templates/fe/skeleton/packages/__project__-i18n/src/proxy.ts +6 -21
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +10 -16
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/confirm/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/forgot/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/resend/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/reset/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/auth/signup/page.tsx +9 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +5 -9
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +51 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +84 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppConfirmPage/index.tsx +25 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppForgotPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +11 -3
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResendPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppResetPage/index.tsx +25 -0
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppSignupPage/index.tsx +21 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/emailAuth.shared.ts +12 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/index.ts +2 -0
- package/templates/fe/skeleton-lite/apps/web/src/hooks/emailAuth/useEmailDoor.ts +32 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +50 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +50 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +7 -30
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +13 -14
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +5 -0
- package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +4 -17
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +0 -38
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +0 -54
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import "reflect-metadata"
|
|
3
|
+
import { Test } from "@nestjs/testing"
|
|
4
|
+
import { FakeClock, fakeIds, fakeTransaction, mock, mockEntityManager } from "@starci/jest-preset"
|
|
5
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
6
|
+
import { MAILER } from "@modules/integrations/mail"
|
|
7
|
+
import type { Mailer, MailOptions } from "@modules/integrations/mail"
|
|
8
|
+
import { AuthError, digestOf } from "@modules/integrations/passport"
|
|
9
|
+
import type { AuthPasswords, SignInBudget } from "@modules/integrations/passport"
|
|
10
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
11
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
12
|
+
import { IDS } from "@modules/platform/ids"
|
|
13
|
+
import { LOGGER } from "@modules/platform/logging"
|
|
14
|
+
import type { Logger } from "@modules/platform/logging"
|
|
15
|
+
import { MAIL_SETTINGS } from "@modules/integrations/mail"
|
|
16
|
+
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
17
|
+
import { IDENTITY_AUTH_PASSWORDS, IDENTITY_SIGN_IN_BUDGET } from "./identity.decorators"
|
|
18
|
+
import { IdentityEmailService } from "./identity-email.service"
|
|
19
|
+
import { IdentityEmailTokenEntity } from "./persistence/entities"
|
|
20
|
+
import { IdentitySessionStoreService } from "./session-store.service"
|
|
21
|
+
import { accountByEmailRow as account } from "@tests/fixtures/builders/identity.builder"
|
|
22
|
+
|
|
23
|
+
/** The statements callDbFunction sends for the account doors the email doors use. */
|
|
24
|
+
const USER_BY_EMAIL = 'SELECT * FROM "public"."be_auth_user_by_email"($1)'
|
|
25
|
+
const CREATE_USER = 'SELECT * FROM "public"."be_auth_create_user"($1, $2, $3, $4, $5, $6)'
|
|
26
|
+
const CONFIRM_USER = 'SELECT * FROM "public"."be_auth_confirm_user"($1, $2)'
|
|
27
|
+
const SET_PASSWORD = 'SELECT * FROM "public"."be_auth_set_password"($1, $2, $3, $4)'
|
|
28
|
+
|
|
29
|
+
const NEW_ID = "00000000-0000-4000-8000-000000000001"
|
|
30
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
31
|
+
const NOW = new Date(AT)
|
|
32
|
+
const SETTINGS = {
|
|
33
|
+
webOrigin: "https://app.example.test",
|
|
34
|
+
brandName: "Acme",
|
|
35
|
+
defaultLanguage: "en",
|
|
36
|
+
confirmTtlSeconds: 86_400,
|
|
37
|
+
resetTtlSeconds: 3_600,
|
|
38
|
+
} as unknown as MailOptions
|
|
39
|
+
|
|
40
|
+
const refusal = (code: string): unknown => expect.objectContaining({ code })
|
|
41
|
+
const tokenRow = (purpose: string): IdentityEmailTokenEntity => ({ id: "t-1", userId: "u-1", purpose, tokenHash: "h", expiresAt: new Date("2026-02-04T00:00:00.000Z"), usedAt: null, createdAt: NOW })
|
|
42
|
+
|
|
43
|
+
const build = async (entityManager: MockEntityManager) => {
|
|
44
|
+
const passwords = mock<AuthPasswords>()
|
|
45
|
+
passwords.hash.mockResolvedValue("new-hash")
|
|
46
|
+
const budget = mock<SignInBudget>()
|
|
47
|
+
budget.admit.mockResolvedValue(undefined)
|
|
48
|
+
const mailer = mock<Mailer>()
|
|
49
|
+
mailer.send.mockResolvedValue({ messageId: "<m>", accepted: ["a@example.test"] })
|
|
50
|
+
const sessions = mock<IdentitySessionStoreService>()
|
|
51
|
+
const logger = mock<Logger>()
|
|
52
|
+
const moduleRef = await Test.createTestingModule({
|
|
53
|
+
providers: [
|
|
54
|
+
IdentityEmailService,
|
|
55
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
56
|
+
{ provide: IDENTITY_AUTH_PASSWORDS, useValue: passwords },
|
|
57
|
+
{ provide: IDENTITY_SIGN_IN_BUDGET, useValue: budget },
|
|
58
|
+
{ provide: MAILER, useValue: mailer },
|
|
59
|
+
{ provide: MAIL_SETTINGS, useValue: mock<MailOptions>(SETTINGS) },
|
|
60
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
61
|
+
{ provide: IDS, useValue: fakeIds() },
|
|
62
|
+
{ provide: LOGGER, useValue: logger },
|
|
63
|
+
{ provide: IdentitySessionStoreService, useValue: sessions },
|
|
64
|
+
],
|
|
65
|
+
}).compile()
|
|
66
|
+
return { subject: moduleRef.get(IdentityEmailService), passwords, budget, mailer, sessions, logger }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("IdentityEmailService.signup", () => {
|
|
70
|
+
it("creates an unconfirmed account through the door, stores only the digest of the link token and mails the link", async () => {
|
|
71
|
+
const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
72
|
+
fakeTransaction(entityManager)
|
|
73
|
+
const { subject, budget, mailer } = await build(entityManager)
|
|
74
|
+
|
|
75
|
+
await subject.signup(" New@Example.test ", "correct horse")
|
|
76
|
+
|
|
77
|
+
expect(budget.admit).toHaveBeenCalledWith("signup", "new@example.test")
|
|
78
|
+
expect(entityManager.query).toHaveBeenCalledWith(CREATE_USER, [NEW_ID, "new@example.test", "new-hash", false, "{}", NOW])
|
|
79
|
+
const sent = mailer.send.mock.calls[0]?.[0]
|
|
80
|
+
const url = /https:\/\/[^\s"<]+/u.exec(sent?.message.text ?? "")?.[0] ?? ""
|
|
81
|
+
const token = new URL(url).searchParams.get("token") ?? ""
|
|
82
|
+
expect(new URL(url).pathname).toBe("/auth/confirm")
|
|
83
|
+
expect(token).toHaveLength(64)
|
|
84
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ userId: NEW_ID, purpose: "confirm", tokenHash: digestOf(token), usedAt: null }))
|
|
85
|
+
expect(sent?.to).toBe("new@example.test")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("keeps the generated id when the create door answers no row", async () => {
|
|
89
|
+
const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, []]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
90
|
+
fakeTransaction(entityManager)
|
|
91
|
+
const { subject } = await build(entityManager)
|
|
92
|
+
|
|
93
|
+
await subject.signup("new@example.test", "correct horse")
|
|
94
|
+
|
|
95
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ userId: NEW_ID }))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("refuses a password the policy rejects before it touches an account", async () => {
|
|
99
|
+
const { subject, mailer } = await build(mockEntityManager())
|
|
100
|
+
|
|
101
|
+
await expect(subject.signup("a@example.test", "short")).rejects.toEqual(refusal(IdentityErrorCode.PasswordInvalid))
|
|
102
|
+
expect(mailer.send).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("mails a new link to an unconfirmed account and says nothing about a confirmed one", async () => {
|
|
106
|
+
const unconfirmed = mockEntityManager({ query: [USER_BY_EMAIL, [account("unconfirmed")]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
107
|
+
fakeTransaction(unconfirmed)
|
|
108
|
+
const confirmed = mockEntityManager({ query: [USER_BY_EMAIL, [account()]] })
|
|
109
|
+
const first = await build(unconfirmed)
|
|
110
|
+
const second = await build(confirmed)
|
|
111
|
+
|
|
112
|
+
await first.subject.signup("a@example.test", "correct horse")
|
|
113
|
+
await second.subject.signup("a@example.test", "correct horse")
|
|
114
|
+
|
|
115
|
+
expect(first.mailer.send).toHaveBeenCalledTimes(1)
|
|
116
|
+
expect(second.mailer.send).not.toHaveBeenCalled()
|
|
117
|
+
expect(confirmed.query).not.toHaveBeenCalledWith(CREATE_USER, expect.anything())
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("answers as if the mail was sent when the mail server fails, and logs the failure", async () => {
|
|
121
|
+
const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
122
|
+
fakeTransaction(entityManager)
|
|
123
|
+
const { subject, mailer, logger } = await build(entityManager)
|
|
124
|
+
mailer.send.mockRejectedValue(new Error("down"))
|
|
125
|
+
|
|
126
|
+
await expect(subject.signup("a@example.test", "correct horse", "vi")).resolves.toBeUndefined()
|
|
127
|
+
expect(logger.error).toHaveBeenCalledWith("identity.email.mail_failed", expect.any(Error), { kind: "confirm" })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("turns a spent budget into the too-many-attempts refusal", async () => {
|
|
131
|
+
const { subject, budget } = await build(mockEntityManager())
|
|
132
|
+
budget.admit.mockRejectedValue(new AuthError(IdentityErrorCode.TooManyAttempts))
|
|
133
|
+
|
|
134
|
+
await expect(subject.signup("a@example.test", "correct horse")).rejects.toEqual(refusal(IdentityErrorCode.TooManyAttempts))
|
|
135
|
+
budget.admit.mockRejectedValue(new Error("store down"))
|
|
136
|
+
await expect(subject.resend("a@example.test")).rejects.toThrow("store down")
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("IdentityEmailService.resend", () => {
|
|
141
|
+
it("mails only an unconfirmed password account", async () => {
|
|
142
|
+
const entityManager = mockEntityManager({ query: [USER_BY_EMAIL, [account("unconfirmed")]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
143
|
+
fakeTransaction(entityManager)
|
|
144
|
+
const { subject, mailer } = await build(entityManager)
|
|
145
|
+
await subject.resend("a@example.test")
|
|
146
|
+
expect(mailer.send).toHaveBeenCalledTimes(1)
|
|
147
|
+
|
|
148
|
+
for (const rows of [[], [account()], [account("invited")]]) {
|
|
149
|
+
const other = await build(mockEntityManager({ query: [USER_BY_EMAIL, rows] }))
|
|
150
|
+
await other.subject.resend("a@example.test")
|
|
151
|
+
expect(other.mailer.send).not.toHaveBeenCalled()
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("IdentityEmailService.confirm", () => {
|
|
157
|
+
it("spends a live link and stamps the confirmation through the door", async () => {
|
|
158
|
+
const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, tokenRow("confirm")], update: [IdentityEmailTokenEntity, {}], query: [CONFIRM_USER, []] })
|
|
159
|
+
fakeTransaction(entityManager)
|
|
160
|
+
const { subject } = await build(entityManager)
|
|
161
|
+
|
|
162
|
+
await subject.confirm("the-token-of-the-link")
|
|
163
|
+
|
|
164
|
+
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityEmailTokenEntity, expect.objectContaining({ lock: { mode: "pessimistic_write" } }))
|
|
165
|
+
expect(entityManager.query).toHaveBeenCalledWith(CONFIRM_USER, ["u-1", NOW])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("refuses an unknown, spent or expired link with the reset-token code", async () => {
|
|
169
|
+
const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, null] })
|
|
170
|
+
fakeTransaction(entityManager)
|
|
171
|
+
const { subject } = await build(entityManager)
|
|
172
|
+
|
|
173
|
+
await expect(subject.confirm("nothing")).rejects.toEqual(refusal(IdentityErrorCode.ResetTokenInvalid))
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe("IdentityEmailService.forgot", () => {
|
|
178
|
+
it("mails a reset link to a live account and to nobody else, with the same answer", async () => {
|
|
179
|
+
const entityManager = mockEntityManager({ query: [USER_BY_EMAIL, [account()]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
180
|
+
fakeTransaction(entityManager)
|
|
181
|
+
const known = await build(entityManager)
|
|
182
|
+
await known.subject.forgot("a@example.test", "vi")
|
|
183
|
+
expect(known.budget.admit).toHaveBeenCalledWith("reset", "a@example.test")
|
|
184
|
+
expect(known.mailer.send.mock.calls[0]?.[0].message.text).toContain("https://app.example.test/auth/reset?token=")
|
|
185
|
+
await known.subject.forgot("a@example.test")
|
|
186
|
+
expect(known.mailer.send).toHaveBeenCalledTimes(2)
|
|
187
|
+
|
|
188
|
+
for (const rows of [[], [account("disabled")]]) {
|
|
189
|
+
const other = await build(mockEntityManager({ query: [USER_BY_EMAIL, rows] }))
|
|
190
|
+
await expect(other.subject.forgot("a@example.test")).resolves.toBeUndefined()
|
|
191
|
+
expect(other.mailer.send).not.toHaveBeenCalled()
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe("IdentityEmailService.reset", () => {
|
|
197
|
+
it("sets the new password and confirms the mailbox its link proved through the door, then ends every session of the person", async () => {
|
|
198
|
+
const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, tokenRow("reset")], update: [IdentityEmailTokenEntity, {}], query: [SET_PASSWORD, []] })
|
|
199
|
+
fakeTransaction(entityManager)
|
|
200
|
+
const { subject, sessions } = await build(entityManager)
|
|
201
|
+
|
|
202
|
+
await subject.reset("the-token-of-the-link", "a better password")
|
|
203
|
+
|
|
204
|
+
expect(entityManager.query).toHaveBeenCalledWith(SET_PASSWORD, ["u-1", "new-hash", true, NOW])
|
|
205
|
+
expect(sessions.closeOtherSessions).toHaveBeenCalledWith("u-1", "00000000-0000-0000-0000-000000000000")
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("refuses a weak password and a link that is not live", async () => {
|
|
209
|
+
const weak = await build(mockEntityManager())
|
|
210
|
+
await expect(weak.subject.reset("t", "short")).rejects.toEqual(refusal(IdentityErrorCode.PasswordInvalid))
|
|
211
|
+
|
|
212
|
+
const entityManager = mockEntityManager({ findOne: [IdentityEmailTokenEntity, null] })
|
|
213
|
+
fakeTransaction(entityManager)
|
|
214
|
+
const dead = await build(entityManager)
|
|
215
|
+
await expect(dead.subject.reset("t", "a better password")).rejects.toBeInstanceOf(IdentityError)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe("IdentityEmailService language", () => {
|
|
220
|
+
it("writes the mail in the language the request names, and in the default one when it names none the mails are written in", async () => {
|
|
221
|
+
const subjectFor = async (language?: string): Promise<string | undefined> => {
|
|
222
|
+
const entityManager = mockEntityManager({ query: [[USER_BY_EMAIL, []], [CREATE_USER, [{ be_auth_create_user: NEW_ID }]]], insert: [IdentityEmailTokenEntity, {}], update: [IdentityEmailTokenEntity, {}] })
|
|
223
|
+
fakeTransaction(entityManager)
|
|
224
|
+
const { subject, mailer } = await build(entityManager)
|
|
225
|
+
await subject.signup("a@example.test", "correct horse", language)
|
|
226
|
+
return mailer.send.mock.calls[0]?.[0].message.subject
|
|
227
|
+
}
|
|
228
|
+
const english = await subjectFor("en")
|
|
229
|
+
expect(await subjectFor("vi")).not.toBe(english)
|
|
230
|
+
expect(await subjectFor("xx")).toBe(english)
|
|
231
|
+
expect(await subjectFor()).toBe(english)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Injectable } from "@nestjs/common"
|
|
3
|
+
import { IsNull, MoreThan } from "typeorm"
|
|
4
|
+
import type { EntityManager } from "typeorm"
|
|
5
|
+
import { AuthError, digestOf, isAcceptablePassword, normalizeEmail } from "@modules/integrations/passport"
|
|
6
|
+
import type { AuthPasswords, SignInBudget } from "@modules/integrations/passport"
|
|
7
|
+
import { InjectMailSettings, InjectMailer } from "@modules/integrations/mail"
|
|
8
|
+
import type { MailLanguage, MailOptions, Mailer } from "@modules/integrations/mail"
|
|
9
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
10
|
+
import type { Clock } from "@modules/platform/clock"
|
|
11
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
12
|
+
import { InjectIds } from "@modules/platform/ids"
|
|
13
|
+
import type { Ids } from "@modules/platform/ids"
|
|
14
|
+
import { InjectLogger } from "@modules/platform/logging"
|
|
15
|
+
import type { Logger } from "@modules/platform/logging"
|
|
16
|
+
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
17
|
+
import { InjectAuthPasswords, InjectSignInBudget } from "./identity.decorators"
|
|
18
|
+
import { IdentityEmailLogEvent } from "./identity-email.log-events"
|
|
19
|
+
import { confirmEmailMail, resetPasswordMail } from "./messages/identity-mail.messages"
|
|
20
|
+
import { accountDoors } from "./persistence/account-doors.port"
|
|
21
|
+
import { IdentityEmailTokenEntity } from "./persistence/entities"
|
|
22
|
+
import { IdentitySessionStoreService } from "./session-store.service"
|
|
23
|
+
|
|
24
|
+
const CONFIRM = "confirm"
|
|
25
|
+
const RESET = "reset"
|
|
26
|
+
const SECONDS_PER_HOUR = 3600
|
|
27
|
+
/** The session id no session has: closing every session "but" it closes them all. */
|
|
28
|
+
const NO_SESSION = "00000000-0000-0000-0000-000000000000"
|
|
29
|
+
|
|
30
|
+
type Purpose = typeof CONFIRM | typeof RESET
|
|
31
|
+
|
|
32
|
+
/** The languages the mails are written in. */
|
|
33
|
+
const LANGUAGES: readonly MailLanguage[] = ["vi", "en"]
|
|
34
|
+
|
|
35
|
+
@Injectable()
|
|
36
|
+
/**
|
|
37
|
+
* The email doors of sign-in: sign up, confirm the mailbox, resend the confirmation, forget and reset the password. A link is a
|
|
38
|
+
* random single-use token that expires and is kept only as its digest; every request counts against the address's budget and a
|
|
39
|
+
* request never says whether an address has an account.
|
|
40
|
+
*/
|
|
41
|
+
export class IdentityEmailService {
|
|
42
|
+
constructor(
|
|
43
|
+
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
44
|
+
@InjectAuthPasswords() private readonly passwords: AuthPasswords,
|
|
45
|
+
@InjectSignInBudget() private readonly budget: SignInBudget,
|
|
46
|
+
@InjectMailer() private readonly mailer: Mailer,
|
|
47
|
+
@InjectMailSettings() private readonly options: MailOptions,
|
|
48
|
+
@InjectClock() private readonly clock: Clock,
|
|
49
|
+
@InjectIds() private readonly ids: Ids,
|
|
50
|
+
@InjectLogger() private readonly logger: Logger,
|
|
51
|
+
private readonly sessions: IdentitySessionStoreService,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
/** Creates an unconfirmed password account and mails the confirmation link; an address that already has an account is answered the same way. */
|
|
55
|
+
async signup(email: string, password: string, language?: string): Promise<void> {
|
|
56
|
+
const address = normalizeEmail(email)
|
|
57
|
+
await this.admit("signup", address)
|
|
58
|
+
if (!isAcceptablePassword(password)) throw new IdentityError({ code: IdentityErrorCode.PasswordInvalid })
|
|
59
|
+
const known = await accountDoors.userByEmail(this.entityManager, address)
|
|
60
|
+
if (known !== undefined) {
|
|
61
|
+
if (known.email_confirmed_at === null) await this.sendConfirmation(known.id, address, language)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const now = this.clock.now()
|
|
65
|
+
const id = this.ids.next()
|
|
66
|
+
const hash = await this.passwords.hash(password)
|
|
67
|
+
const created = await accountDoors.createUser(this.entityManager, { id, email: address, hash, confirmed: false, meta: "{}", now })
|
|
68
|
+
await this.sendConfirmation(created ?? id, address, language)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Mails a new confirmation link to an unconfirmed account; any other address is answered the same way. */
|
|
72
|
+
async resend(email: string, language?: string): Promise<void> {
|
|
73
|
+
const address = normalizeEmail(email)
|
|
74
|
+
await this.admit("signup", address)
|
|
75
|
+
const user = await accountDoors.userByEmail(this.entityManager, address)
|
|
76
|
+
if (user === undefined || user.email_confirmed_at !== null || user.encrypted_password === null) return
|
|
77
|
+
await this.sendConfirmation(user.id, address, language)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Spends a confirmation link: the mailbox becomes confirmed. */
|
|
81
|
+
async confirm(token: string): Promise<void> {
|
|
82
|
+
const now = this.clock.now()
|
|
83
|
+
await this.entityManager.transaction(async (scoped) => {
|
|
84
|
+
const row = await this.spendable(scoped, token, CONFIRM, now)
|
|
85
|
+
await scoped.update(IdentityEmailTokenEntity, { userId: row.userId, purpose: CONFIRM, usedAt: IsNull() }, { usedAt: now })
|
|
86
|
+
await accountDoors.confirmUser(scoped, row.userId, now)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Mails a reset link when the address has a live password account; the answer never says whether it does. */
|
|
91
|
+
async forgot(email: string, language?: string): Promise<void> {
|
|
92
|
+
const address = normalizeEmail(email)
|
|
93
|
+
await this.admit("reset", address)
|
|
94
|
+
const user = await accountDoors.userByEmail(this.entityManager, address)
|
|
95
|
+
if (user === undefined || user.disabled) return
|
|
96
|
+
const token = await this.mint(user.id, RESET, this.options.resetTtlSeconds)
|
|
97
|
+
const url = `${this.options.webOrigin}/auth/reset?token=${encodeURIComponent(token)}`
|
|
98
|
+
const mail = resetPasswordMail(this.languageOf(language), { brand: this.options.brandName, url, hours: this.hoursOf(this.options.resetTtlSeconds) })
|
|
99
|
+
await this.deliver(address, mail, RESET)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Spends a reset link: sets the new password, confirms the mailbox (the link proved it) and ends every session of the person. */
|
|
103
|
+
async reset(token: string, password: string): Promise<void> {
|
|
104
|
+
if (!isAcceptablePassword(password)) throw new IdentityError({ code: IdentityErrorCode.PasswordInvalid })
|
|
105
|
+
const now = this.clock.now()
|
|
106
|
+
const hash = await this.passwords.hash(password)
|
|
107
|
+
const userId = await this.entityManager.transaction(async (scoped) => {
|
|
108
|
+
const row = await this.spendable(scoped, token, RESET, now)
|
|
109
|
+
await scoped.update(IdentityEmailTokenEntity, { userId: row.userId, usedAt: IsNull() }, { usedAt: now })
|
|
110
|
+
await accountDoors.setPassword(scoped, row.userId, hash, true, now)
|
|
111
|
+
return row.userId
|
|
112
|
+
})
|
|
113
|
+
await this.sessions.closeOtherSessions(userId, NO_SESSION)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async admit(kind: "signup" | "reset", address: string): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
await this.budget.admit(kind, address)
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
if (cause instanceof AuthError) throw new IdentityError({ code: IdentityErrorCode.TooManyAttempts, cause })
|
|
121
|
+
throw cause
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async spendable(scoped: EntityManager, token: string, purpose: Purpose, now: Date): Promise<IdentityEmailTokenEntity> {
|
|
126
|
+
const row = await scoped.findOne(IdentityEmailTokenEntity, {
|
|
127
|
+
where: { tokenHash: digestOf(token), purpose, usedAt: IsNull(), expiresAt: MoreThan(now) },
|
|
128
|
+
lock: { mode: "pessimistic_write" },
|
|
129
|
+
})
|
|
130
|
+
if (row === null) throw new IdentityError({ code: IdentityErrorCode.ResetTokenInvalid })
|
|
131
|
+
return row
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The language a request named when it is one the mails are written in, else the default of the settings. */
|
|
135
|
+
private languageOf(language: string | undefined): MailLanguage {
|
|
136
|
+
return LANGUAGES.find((candidate) => candidate === language) ?? this.options.defaultLanguage
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async sendConfirmation(userId: string, address: string, language: string | undefined): Promise<void> {
|
|
140
|
+
const token = await this.mint(userId, CONFIRM, this.options.confirmTtlSeconds)
|
|
141
|
+
const url = `${this.options.webOrigin}/auth/confirm?token=${encodeURIComponent(token)}`
|
|
142
|
+
const mail = confirmEmailMail(this.languageOf(language), { brand: this.options.brandName, url, hours: this.hoursOf(this.options.confirmTtlSeconds) })
|
|
143
|
+
await this.deliver(address, mail, CONFIRM)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stores a fresh token (older live ones of the same purpose stop working) and answers the plain token for the one link that carries it. */
|
|
147
|
+
private async mint(userId: string, purpose: Purpose, ttlSeconds: number): Promise<string> {
|
|
148
|
+
const now = this.clock.now()
|
|
149
|
+
const token = `${this.ids.next()}${this.ids.next()}`.replaceAll("-", "")
|
|
150
|
+
await this.entityManager.transaction(async (scoped) => {
|
|
151
|
+
await scoped.update(IdentityEmailTokenEntity, { userId, purpose, usedAt: IsNull() }, { usedAt: now })
|
|
152
|
+
await scoped.insert(IdentityEmailTokenEntity, {
|
|
153
|
+
id: this.ids.next(),
|
|
154
|
+
userId,
|
|
155
|
+
purpose,
|
|
156
|
+
tokenHash: digestOf(token),
|
|
157
|
+
expiresAt: new Date(now.getTime() + ttlSeconds * 1000),
|
|
158
|
+
usedAt: null,
|
|
159
|
+
createdAt: now,
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
return token
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async deliver(to: string, message: Parameters<Mailer["send"]>[0]["message"], kind: Purpose): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
await this.mailer.send({ to, message })
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
this.logger.error(IdentityEmailLogEvent.MailFailed, cause, { kind })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private hoursOf(seconds: number): number {
|
|
174
|
+
return Math.max(1, Math.ceil(seconds / SECONDS_PER_HOUR))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
/** Log events of the sign-in decisions of identity; a line names an account by id, never by address, password or token. */
|
|
3
|
+
export enum IdentitySignInLogEvent {
|
|
4
|
+
/** A first Google sign-in created an account. */
|
|
5
|
+
AccountCreated = "identity.sign_in.account_created",
|
|
6
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import type { EntityManager } from "typeorm"
|
|
3
|
+
import { callDbFunction, returnedRows, rowShape } from "@modules/platform/database"
|
|
4
|
+
|
|
5
|
+
/** The account an address stands for, with the stored hash the password is checked against. */
|
|
6
|
+
export interface AccountByEmail {
|
|
7
|
+
/** The account id. */
|
|
8
|
+
readonly id: string
|
|
9
|
+
/** The sign-in address. */
|
|
10
|
+
readonly email: string
|
|
11
|
+
/** The stored bcrypt hash, or null for an account that signs in with Google only. */
|
|
12
|
+
readonly encrypted_password: string | null
|
|
13
|
+
/** When the mailbox was confirmed, or null. */
|
|
14
|
+
readonly email_confirmed_at: Date | null
|
|
15
|
+
/** True when the account is deleted or banned at the database's now(). */
|
|
16
|
+
readonly disabled: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The standing of an account found by id. */
|
|
20
|
+
export interface AccountById {
|
|
21
|
+
/** The account id. */
|
|
22
|
+
readonly id: string
|
|
23
|
+
/** The sign-in address, or null. */
|
|
24
|
+
readonly email: string | null
|
|
25
|
+
/** True when the account is deleted or banned at the database's now(). */
|
|
26
|
+
readonly disabled: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An account to create: the id, the address, the profile keys as JSON text and the instant. */
|
|
30
|
+
export interface NewAccount {
|
|
31
|
+
/** The account id. */
|
|
32
|
+
readonly id: string
|
|
33
|
+
/** The sign-in address, lower-cased. */
|
|
34
|
+
readonly email: string
|
|
35
|
+
/** The bcrypt hash, or null for an account that signs in with Google only. */
|
|
36
|
+
readonly hash: string | null
|
|
37
|
+
/** Whether the mailbox counts as confirmed. */
|
|
38
|
+
readonly confirmed: boolean
|
|
39
|
+
/** The profile keys as JSON text. */
|
|
40
|
+
readonly meta: string
|
|
41
|
+
/** The instant of creation. */
|
|
42
|
+
readonly now: Date
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isAccountByEmail: (value: unknown) => value is AccountByEmail = rowShape({
|
|
46
|
+
id: "string",
|
|
47
|
+
email: "string",
|
|
48
|
+
encrypted_password: "string?",
|
|
49
|
+
email_confirmed_at: "date?",
|
|
50
|
+
disabled: "boolean",
|
|
51
|
+
})
|
|
52
|
+
const isAccountById: (value: unknown) => value is AccountById = rowShape({ id: "string", email: "string?", disabled: "boolean" })
|
|
53
|
+
const isLinkedAccount = rowShape({ be_auth_user_by_google: "string?" })
|
|
54
|
+
const isCreatedAccount = rowShape({ be_auth_create_user: "string" })
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The account doors: the SECURITY DEFINER functions of the baseline migration through which the application role reads and writes
|
|
58
|
+
* auth.users and auth.identities. It holds no grant on schema auth, so no other path reaches an account.
|
|
59
|
+
*/
|
|
60
|
+
export const accountDoors = {
|
|
61
|
+
/** The account of an exact address, or undefined. */
|
|
62
|
+
async userByEmail(manager: EntityManager, email: string): Promise<AccountByEmail | undefined> {
|
|
63
|
+
const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_user_by_email", [email]), isAccountByEmail)
|
|
64
|
+
return row
|
|
65
|
+
},
|
|
66
|
+
/** The standing of an account by id, or undefined. */
|
|
67
|
+
async userById(manager: EntityManager, userId: string): Promise<AccountById | undefined> {
|
|
68
|
+
const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_user_by_id", [userId]), isAccountById)
|
|
69
|
+
return row
|
|
70
|
+
},
|
|
71
|
+
/** The account a Google subject is linked to, or null. */
|
|
72
|
+
async userByGoogle(manager: EntityManager, subject: string): Promise<string | null> {
|
|
73
|
+
const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_user_by_google", [subject]), isLinkedAccount)
|
|
74
|
+
return row?.be_auth_user_by_google ?? null
|
|
75
|
+
},
|
|
76
|
+
/** Creates an account and answers its id. */
|
|
77
|
+
async createUser(manager: EntityManager, account: NewAccount): Promise<string | undefined> {
|
|
78
|
+
const args = [account.id, account.email, account.hash, account.confirmed, account.meta, account.now]
|
|
79
|
+
const [row] = returnedRows(await callDbFunction(manager, "public.be_auth_create_user", args), isCreatedAccount)
|
|
80
|
+
return row?.be_auth_create_user
|
|
81
|
+
},
|
|
82
|
+
/** Links a Google subject to an account, or refreshes the link. */
|
|
83
|
+
async linkGoogle(manager: EntityManager, userId: string, subject: string, data: string, now: Date): Promise<void> {
|
|
84
|
+
await callDbFunction(manager, "public.be_auth_link_google", [userId, subject, data, now])
|
|
85
|
+
},
|
|
86
|
+
/** Stamps the confirmation of a mailbox (a spent confirmation link) without recording a sign-in. */
|
|
87
|
+
async confirmUser(manager: EntityManager, userId: string, now: Date): Promise<void> {
|
|
88
|
+
await callDbFunction(manager, "public.be_auth_confirm_user", [userId, now])
|
|
89
|
+
},
|
|
90
|
+
/** Sets the password hash of an account; `confirm` also confirms the mailbox, as a spent reset link proves it. */
|
|
91
|
+
async setPassword(manager: EntityManager, userId: string, hash: string, confirm: boolean, now: Date): Promise<void> {
|
|
92
|
+
await callDbFunction(manager, "public.be_auth_set_password", [userId, hash, confirm, now])
|
|
93
|
+
},
|
|
94
|
+
/** Records a sign-in, confirms the mailbox when asked, and fills the profile keys the person has not set. */
|
|
95
|
+
async touchUser(manager: EntityManager, userId: string, now: Date, confirm: boolean, meta: string): Promise<void> {
|
|
96
|
+
await callDbFunction(manager, "public.be_auth_touch_user", [userId, now, confirm, meta])
|
|
97
|
+
},
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
3
|
+
|
|
4
|
+
@Entity({ schema: "public", name: "identity_email_tokens" })
|
|
5
|
+
/** One single-use link token that confirms a mailbox or resets a password, mapped from public.identity_email_tokens; only its digest is kept. */
|
|
6
|
+
export class IdentityEmailTokenEntity {
|
|
7
|
+
/** The token row id. */
|
|
8
|
+
@PrimaryColumn({ name: "id", type: "uuid" })
|
|
9
|
+
id!: string
|
|
10
|
+
|
|
11
|
+
/** The account the link is for. */
|
|
12
|
+
@Column({ name: "user_id", type: "uuid" })
|
|
13
|
+
userId!: string
|
|
14
|
+
|
|
15
|
+
/** What the link does: `confirm` or `reset`. */
|
|
16
|
+
@Column({ name: "purpose", type: "varchar", length: 16 })
|
|
17
|
+
purpose!: string
|
|
18
|
+
|
|
19
|
+
/** The SHA-256 digest of the token in the link; the token itself is never stored. */
|
|
20
|
+
@Column({ name: "token_hash", type: "varchar", length: 64 })
|
|
21
|
+
tokenHash!: string
|
|
22
|
+
|
|
23
|
+
/** When the link stops working. */
|
|
24
|
+
@Column({ name: "expires_at", type: "timestamptz" })
|
|
25
|
+
expiresAt!: Date
|
|
26
|
+
|
|
27
|
+
/** When the link was spent, or null while it is live. */
|
|
28
|
+
@Column({ name: "used_at", type: "timestamptz", nullable: true })
|
|
29
|
+
usedAt!: Date | null
|
|
30
|
+
|
|
31
|
+
/** When the link was issued. */
|
|
32
|
+
@Column({ name: "created_at", type: "timestamptz" })
|
|
33
|
+
createdAt!: Date
|
|
34
|
+
}
|
|
@@ -31,4 +31,8 @@ export class IdentityRefreshTokenEntity {
|
|
|
31
31
|
/** When the token was rotated away, or null while it is the live one. */
|
|
32
32
|
@Column({ name: "used_at", type: "timestamptz", nullable: true })
|
|
33
33
|
usedAt!: Date | null
|
|
34
|
+
|
|
35
|
+
/** How many successors replays of this token obtained within the grace window after its rotation (bounded). */
|
|
36
|
+
@Column({ name: "raced_count", type: "integer", default: 0 })
|
|
37
|
+
racedCount!: number
|
|
34
38
|
}
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
-
import { AuthIdentityEntity } from "./auth-identity.entity"
|
|
3
|
-
import { AuthUserEntity } from "./auth-user.entity"
|
|
4
2
|
import { IdentityAttemptEntity } from "./identity-attempt.entity"
|
|
3
|
+
import { IdentityEmailTokenEntity } from "./identity-email-token.entity"
|
|
5
4
|
import { IdentityRefreshTokenEntity } from "./identity-refresh-token.entity"
|
|
6
5
|
import { IdentitySessionEntity } from "./identity-session.entity"
|
|
7
6
|
|
|
8
|
-
export {
|
|
7
|
+
export { IdentityAttemptEntity, IdentityEmailTokenEntity, IdentityRefreshTokenEntity, IdentitySessionEntity }
|
|
9
8
|
|
|
10
9
|
/** The entities of the identity capability, named in PRIMARY_ENTITIES of platform/database/entities.ts. */
|
|
11
|
-
export const identityEntities: ReadonlyArray<new () => object> = [
|
|
12
|
-
AuthIdentityEntity,
|
|
13
|
-
AuthUserEntity,
|
|
14
|
-
IdentityAttemptEntity,
|
|
15
|
-
IdentityRefreshTokenEntity,
|
|
16
|
-
IdentitySessionEntity,
|
|
17
|
-
]
|
|
10
|
+
export const identityEntities: ReadonlyArray<new () => object> = [IdentityAttemptEntity, IdentityEmailTokenEntity, IdentityRefreshTokenEntity, IdentitySessionEntity]
|
|
@@ -19,6 +19,7 @@ const row = (usedAt: Date | null): IdentityRefreshTokenEntity => ({
|
|
|
19
19
|
createdAt: NOW,
|
|
20
20
|
expiresAt: LATER,
|
|
21
21
|
usedAt,
|
|
22
|
+
racedCount: 0,
|
|
22
23
|
})
|
|
23
24
|
|
|
24
25
|
const build = async (entityManager: MockEntityManager = mockEntityManager()) => {
|
|
@@ -39,11 +40,11 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
expect(entityManager.insert).toHaveBeenCalledWith(IdentitySessionEntity, { id: "s-1", userId: "u-1", createdAt: NOW, refreshedAt: NOW, userAgent: "agent", ip: "203.0.113.9" })
|
|
42
|
-
expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null })
|
|
43
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null, racedCount: 0 })
|
|
43
44
|
expect(transaction.commits).toBe(1)
|
|
44
45
|
})
|
|
45
46
|
|
|
46
|
-
it("reads the token for update, marks it used and drops the expired ones", async () => {
|
|
47
|
+
it("reads the token for update, marks it used or raced and drops the expired ones", async () => {
|
|
47
48
|
const { subject, entityManager } = await build(
|
|
48
49
|
mockEntityManager({
|
|
49
50
|
findOne: [[IdentityRefreshTokenEntity, row(NOW)], [IdentityRefreshTokenEntity, null]],
|
|
@@ -56,13 +57,15 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
56
57
|
const missing = await subject.transaction((tx) => tx.findTokenForUpdate("h-2"))
|
|
57
58
|
await subject.transaction(async (tx) => {
|
|
58
59
|
await tx.markTokenUsed("t-1", NOW)
|
|
60
|
+
await tx.markTokenRaced("t-1", 2)
|
|
59
61
|
await tx.deleteExpiredTokens("u-1", NOW)
|
|
60
62
|
})
|
|
61
63
|
|
|
62
|
-
expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW })
|
|
64
|
+
expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW, racedCount: 0 })
|
|
63
65
|
expect(missing).toBeNull()
|
|
64
66
|
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { where: { tokenHash: "h-1" }, lock: { mode: "pessimistic_write" } })
|
|
65
67
|
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
|
|
68
|
+
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { racedCount: 2 })
|
|
66
69
|
expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { userId: "u-1", expiresAt: LessThan(NOW) })
|
|
67
70
|
})
|
|
68
71
|
|
|
@@ -13,6 +13,7 @@ const recordOf = (row: IdentityRefreshTokenEntity): RefreshTokenRecord => ({
|
|
|
13
13
|
tokenHash: row.tokenHash,
|
|
14
14
|
expiresAt: row.expiresAt,
|
|
15
15
|
usedAt: row.usedAt,
|
|
16
|
+
racedCount: row.racedCount,
|
|
16
17
|
})
|
|
17
18
|
|
|
18
19
|
const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
@@ -34,6 +35,7 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
|
34
35
|
tokenHash: token.tokenHash,
|
|
35
36
|
expiresAt: token.expiresAt,
|
|
36
37
|
usedAt: null,
|
|
38
|
+
racedCount: 0,
|
|
37
39
|
})
|
|
38
40
|
},
|
|
39
41
|
findTokenForUpdate: async (tokenHash) => {
|
|
@@ -43,6 +45,9 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
|
43
45
|
markTokenUsed: async (tokenId, at) => {
|
|
44
46
|
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { usedAt: at })
|
|
45
47
|
},
|
|
48
|
+
markTokenRaced: async (tokenId, racedCount) => {
|
|
49
|
+
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { racedCount })
|
|
50
|
+
},
|
|
46
51
|
deleteExpiredTokens: async (userId, before) => {
|
|
47
52
|
await scoped.delete(IdentityRefreshTokenEntity, { userId, expiresAt: LessThan(before) })
|
|
48
53
|
},
|
|
@@ -69,7 +74,7 @@ export class IdentitySessionStoreService implements SessionStore {
|
|
|
69
74
|
await this.entityManager.update(IdentitySessionEntity, { id: sessionId }, { refreshedAt: now })
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
/** Ends one session
|
|
77
|
+
/** Ends one session and revokes every token of it (the foreign key cascades). */
|
|
73
78
|
async closeSession(sessionId: string): Promise<void> {
|
|
74
79
|
await this.entityManager.delete(IdentitySessionEntity, { id: sessionId })
|
|
75
80
|
}
|