@starci/hfs 5.1.0 → 5.2.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 +20 -0
- package/README.md +10 -1
- package/db/postgres.mjs +9 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +30 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +7 -7
- package/runtime/knowledge/hfs/fork-images.yaml +7 -0
- package/runtime/knowledge/hfs/rules.yaml +55 -7
- package/runtime/knowledge/hfs/slots.yaml +75 -24
- package/runtime/modules/kernel/failure-codes.yaml +62 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
- package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
- package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
- package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
- package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
- package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
- package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
- package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
- package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
- package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
- package/runtime/scripts/hfs/secret.mjs +10 -7
- package/runtime/scripts/hfs/slots.mjs +1 -1
- package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
- package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-module.d.mts +16 -0
- package/scaffold/add-module.mjs +232 -0
- package/scaffold/add-queue-lite.mjs +49 -0
- package/scaffold/add.mjs +5 -0
- package/scaffold/app.mjs +7 -7
- package/scaffold/module-registry.d.mts +19 -0
- package/scaffold/module-registry.mjs +84 -0
- package/src/main.mjs +23 -2
- package/stack/cli.mjs +6 -1
- package/stack/context.d.mts +15 -0
- package/stack/context.mjs +1 -0
- package/stack/local-cli.d.mts +25 -0
- package/stack/local-cli.mjs +89 -0
- package/stack/local-config.d.mts +17 -0
- package/stack/local-config.mjs +37 -0
- package/stack/local-custody.d.mts +24 -0
- package/stack/local-custody.mjs +109 -0
- package/stack/local-doctor.d.mts +6 -0
- package/stack/local-doctor.mjs +49 -0
- package/stack/local-down.d.mts +8 -0
- package/stack/local-down.mjs +20 -0
- package/stack/local-host.d.mts +42 -0
- package/stack/local-host.mjs +163 -0
- package/stack/local-logs.d.mts +8 -0
- package/stack/local-logs.mjs +32 -0
- package/stack/local-ports.d.mts +7 -0
- package/stack/local-ports.mjs +39 -0
- package/stack/local-prepare.d.mts +22 -0
- package/stack/local-prepare.mjs +62 -0
- package/stack/local-run.d.mts +71 -0
- package/stack/local-run.mjs +200 -0
- package/stack/local-state.d.mts +35 -0
- package/stack/local-state.mjs +29 -0
- package/stack/local-status.d.mts +6 -0
- package/stack/local-status.mjs +36 -0
- package/stack/local-up.d.mts +11 -0
- package/stack/local-up.mjs +166 -0
- package/stack/local-verify.d.mts +6 -0
- package/stack/local-verify.mjs +21 -0
- package/sync/index.d.mts +13 -12
- package/sync/index.mjs +83 -28
- package/templates/app/module/jest.config.js.tpl +8 -0
- package/templates/app/module/src/index.ts.tpl +2 -0
- package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
- package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
- package/templates/app/module/src/manifest/index.ts.tpl +1 -0
- package/templates/app/module/src/public/index.ts.tpl +2 -0
- package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
- package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
- package/templates/app/module/src/template/index.ts.tpl +1 -0
- package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
- package/templates/app/module/src/workbench/index.ts.tpl +2 -0
- 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 +76 -70
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +23 -13
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +25 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -26
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +2 -1
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +57 -14
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +13 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +13 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +90 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +28 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +145 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +95 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +38 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +54 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +17 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +78 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +87 -0
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +44 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +10 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +34 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +47 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
- package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -4
- package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
- package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
- package/templates/fe/common/site-shell.tsx.partial +3 -3
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
- package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
- package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { CommandHandler } from "@nestjs/cqrs"
|
|
3
|
+
import { IdentitySignInService } from "@modules/domain/identity"
|
|
4
|
+
import type { AccessGrant } from "@modules/integrations/passport"
|
|
5
|
+
import { ICQRSHandler } from "@modules/platform/cqrs"
|
|
6
|
+
import { InjectLogger } from "@modules/platform/logging"
|
|
7
|
+
import type { Logger } from "@modules/platform/logging"
|
|
8
|
+
import { RefreshCommand } from "./refresh.command"
|
|
9
|
+
|
|
10
|
+
@CommandHandler(RefreshCommand)
|
|
11
|
+
/** Rotates the refresh cookie and answers a new access token; a bad cookie is cleared. */
|
|
12
|
+
export class RefreshHandler extends ICQRSHandler<RefreshCommand, AccessGrant> {
|
|
13
|
+
constructor(
|
|
14
|
+
@InjectLogger() logger: Logger,
|
|
15
|
+
private readonly signIn: IdentitySignInService,
|
|
16
|
+
) {
|
|
17
|
+
super(logger)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected override process(command: RefreshCommand): Promise<AccessGrant> {
|
|
21
|
+
return this.signIn.refresh(command.params.request.exchange)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Command } from "@nestjs/cqrs"
|
|
3
|
+
import type { AccessGrant } from "@modules/integrations/passport"
|
|
4
|
+
import type { PublicExecuteParams } from "@modules/platform/cqrs"
|
|
5
|
+
import type { ExchangeRequest } from "./auth.contracts"
|
|
6
|
+
|
|
7
|
+
/** Answers a fresh access token for the cookie session without rotating the cookie. */
|
|
8
|
+
export class SessionCommand extends Command<AccessGrant> {
|
|
9
|
+
constructor(readonly params: PublicExecuteParams<ExchangeRequest>) {
|
|
10
|
+
super()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { CommandHandler } from "@nestjs/cqrs"
|
|
3
|
+
import { IdentitySignInService } from "@modules/domain/identity"
|
|
4
|
+
import type { AccessGrant } from "@modules/integrations/passport"
|
|
5
|
+
import { ICQRSHandler } from "@modules/platform/cqrs"
|
|
6
|
+
import { InjectLogger } from "@modules/platform/logging"
|
|
7
|
+
import type { Logger } from "@modules/platform/logging"
|
|
8
|
+
import { SessionCommand } from "./session.command"
|
|
9
|
+
|
|
10
|
+
@CommandHandler(SessionCommand)
|
|
11
|
+
/** Answers a fresh access token for the cookie session without rotating the cookie. */
|
|
12
|
+
export class SessionHandler extends ICQRSHandler<SessionCommand, AccessGrant> {
|
|
13
|
+
constructor(
|
|
14
|
+
@InjectLogger() logger: Logger,
|
|
15
|
+
private readonly signIn: IdentitySignInService,
|
|
16
|
+
) {
|
|
17
|
+
super(logger)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected override process(command: SessionCommand): Promise<AccessGrant> {
|
|
21
|
+
return this.signIn.session(command.params.request.exchange)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
|
|
3
|
+
import type { AuthApiOptions } from "./auth.options"
|
|
4
|
+
|
|
5
|
+
const built = new ConfigurableModuleBuilder<AuthApiOptions>().setClassMethodName("register").build()
|
|
6
|
+
type Host = ConfigurableModuleHost<AuthApiOptions, "register", "create", {}>
|
|
7
|
+
|
|
8
|
+
/** The configurable-module base of the auth feature module. */
|
|
9
|
+
export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
|
|
10
|
+
/** The injection token of the registered options. */
|
|
11
|
+
export const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"] = built.MODULE_OPTIONS_TOKEN
|
|
12
|
+
/** The options of a synchronous registration, for the register signature. */
|
|
13
|
+
export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Module } from "@nestjs/common"
|
|
3
|
+
import { LoginHandler } from "./application/login.handler"
|
|
4
|
+
import { RefreshHandler } from "./application/refresh.handler"
|
|
5
|
+
import { SessionHandler } from "./application/session.handler"
|
|
6
|
+
import { LogoutHandler } from "./application/logout.handler"
|
|
7
|
+
import { GoogleBeginHandler } from "./application/google-begin.handler"
|
|
8
|
+
import { GoogleCompleteHandler } from "./application/google-complete.handler"
|
|
9
|
+
import { GetJwksHandler } from "./application/get-jwks.handler"
|
|
10
|
+
import { ConfigurableModuleClass } from "./auth.module-definition"
|
|
11
|
+
|
|
12
|
+
@Module({
|
|
13
|
+
providers: [LoginHandler, RefreshHandler, SessionHandler, LogoutHandler, GoogleBeginHandler, GoogleCompleteHandler, GetJwksHandler],
|
|
14
|
+
})
|
|
15
|
+
/** The auth feature: the handlers behind the sign-in doors. */
|
|
16
|
+
export class AuthApiModule extends ConfigurableModuleClass {}
|
package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { ConfigurableModuleBuilder, type ConfigurableModuleHost } from "@nestjs/common"
|
|
3
|
+
import type { AuthHttpOptions } from "./auth-http.options"
|
|
4
|
+
|
|
5
|
+
const built = new ConfigurableModuleBuilder<AuthHttpOptions>().setClassMethodName("register").build()
|
|
6
|
+
type Host = ConfigurableModuleHost<AuthHttpOptions, "register", "create", {}>
|
|
7
|
+
|
|
8
|
+
/** The configurable-module base of the auth-http module. */
|
|
9
|
+
export const ConfigurableModuleClass: Host["ConfigurableModuleClass"] = built.ConfigurableModuleClass
|
|
10
|
+
/** The injection token of the registered options. */
|
|
11
|
+
export const MODULE_OPTIONS_TOKEN: Host["MODULE_OPTIONS_TOKEN"] = built.MODULE_OPTIONS_TOKEN
|
|
12
|
+
/** The options of a synchronous registration, for the register signature. */
|
|
13
|
+
export const OPTIONS_TYPE: Host["OPTIONS_TYPE"] = built.OPTIONS_TYPE
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Module } from "@nestjs/common"
|
|
3
|
+
import { AuthApiModule } from "../../auth.module"
|
|
4
|
+
import { AuthController } from "./auth.controller"
|
|
5
|
+
import { ConfigurableModuleClass } from "./auth-http.module-definition"
|
|
6
|
+
|
|
7
|
+
@Module({ imports: [AuthApiModule.register({})], controllers: [AuthController] })
|
|
8
|
+
/** The HTTP transport of the auth feature: the REST doors under /auth. */
|
|
9
|
+
export class AuthHttpModule extends ConfigurableModuleClass {}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Body, Controller, Get, HttpCode, Post, Query, Redirect, Req, Res } from "@nestjs/common"
|
|
3
|
+
import type { CommandBus, QueryBus } from "@nestjs/cqrs"
|
|
4
|
+
import type { Request, Response } from "express"
|
|
5
|
+
import { Public, PublicReason } from "@modules/domain/identity"
|
|
6
|
+
import type { AccessGrant, GoogleRedirect, JwksDocument } from "@modules/integrations/passport"
|
|
7
|
+
import { InjectCommandBus, InjectQueryBus } from "@modules/platform/cqrs"
|
|
8
|
+
import { RateLimit, RateTier } from "@modules/platform/http-security"
|
|
9
|
+
import { GetJwksQuery } from "../../application/get-jwks.query"
|
|
10
|
+
import { GoogleBeginCommand } from "../../application/google-begin.command"
|
|
11
|
+
import { GoogleCompleteCommand } from "../../application/google-complete.command"
|
|
12
|
+
import { LoginCommand } from "../../application/login.command"
|
|
13
|
+
import { LogoutCommand } from "../../application/logout.command"
|
|
14
|
+
import { RefreshCommand } from "../../application/refresh.command"
|
|
15
|
+
import { SessionCommand } from "../../application/session.command"
|
|
16
|
+
import { GoogleStartRequest } from "./dto/google-start.request"
|
|
17
|
+
import { LoginRequest } from "./dto/login.request"
|
|
18
|
+
|
|
19
|
+
@Controller("auth")
|
|
20
|
+
/** The cookie doors of sign-in: email and password, Google, refresh, session, sign-out and the public keys. */
|
|
21
|
+
export class AuthController {
|
|
22
|
+
constructor(
|
|
23
|
+
@InjectCommandBus() private readonly commandBus: CommandBus,
|
|
24
|
+
@InjectQueryBus() private readonly queryBus: QueryBus,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
/** Signs in with email and password; the refresh token is set as an httpOnly cookie. */
|
|
28
|
+
@Post("login")
|
|
29
|
+
@HttpCode(200)
|
|
30
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
31
|
+
@RateLimit(RateTier.Strict)
|
|
32
|
+
login(
|
|
33
|
+
@Body() _body: LoginRequest,
|
|
34
|
+
@Req() req: Request,
|
|
35
|
+
@Res({ passthrough: true }) res: Response,
|
|
36
|
+
): Promise<AccessGrant> {
|
|
37
|
+
return this.commandBus.execute(new LoginCommand({ request: { exchange: { req, res } } }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rotates the refresh cookie and answers a new access token; a bad cookie is cleared. */
|
|
41
|
+
@Post("refresh")
|
|
42
|
+
@HttpCode(200)
|
|
43
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
44
|
+
@RateLimit(RateTier.Strict)
|
|
45
|
+
refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<AccessGrant> {
|
|
46
|
+
return this.commandBus.execute(new RefreshCommand({ request: { exchange: { req, res } } }))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Answers a fresh access token for the cookie session WITHOUT rotating the cookie, so parallel requests of one browser never race. */
|
|
50
|
+
@Get("session")
|
|
51
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
52
|
+
@RateLimit(RateTier.Strict)
|
|
53
|
+
session(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<AccessGrant> {
|
|
54
|
+
return this.commandBus.execute(new SessionCommand({ request: { exchange: { req, res } } }))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ends the session of the refresh cookie and clears it. */
|
|
58
|
+
@Post("logout")
|
|
59
|
+
@HttpCode(204)
|
|
60
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
61
|
+
@RateLimit(RateTier.Strict)
|
|
62
|
+
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
|
|
63
|
+
return this.commandBus.execute(new LogoutCommand({ request: { exchange: { req, res } } }))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sends the browser to Google (authorization code with state and PKCE); `returnTo` is an absolute address on the web origin or a path. */
|
|
67
|
+
@Get("google")
|
|
68
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
69
|
+
@RateLimit(RateTier.Strict)
|
|
70
|
+
google(@Query() _query: GoogleStartRequest, @Req() req: Request, @Res() res: Response): Promise<void> {
|
|
71
|
+
return this.commandBus.execute(new GoogleBeginCommand({ request: { exchange: { req, res } } }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Google sends the browser back here; the session cookie is set and the browser lands on the web app (or its sign-in page on failure). */
|
|
75
|
+
@Get("google/callback")
|
|
76
|
+
@Redirect()
|
|
77
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
78
|
+
@RateLimit(RateTier.Strict)
|
|
79
|
+
googleCallback(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<GoogleRedirect> {
|
|
80
|
+
return this.commandBus.execute(new GoogleCompleteCommand({ request: { exchange: { req, res } } }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The public keys access tokens verify against. */
|
|
84
|
+
@Get("jwks")
|
|
85
|
+
@Public({ reason: PublicReason.AuthHandshake })
|
|
86
|
+
@RateLimit(RateTier.Strict)
|
|
87
|
+
jwks(): Promise<JwksDocument> {
|
|
88
|
+
return this.queryBus.execute(new GetJwksQuery({ request: {} }))
|
|
89
|
+
}
|
|
90
|
+
}
|
package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { IsOptional, IsString, MaxLength } from "class-validator"
|
|
3
|
+
|
|
4
|
+
/** The query that starts a Google sign-in. */
|
|
5
|
+
export class GoogleStartRequest {
|
|
6
|
+
/** An absolute address on the web origin, or a path, to land on afterwards. */
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@IsString()
|
|
9
|
+
@MaxLength(1024)
|
|
10
|
+
returnTo?: string
|
|
11
|
+
|
|
12
|
+
/** A path to land on afterwards (the older spelling of returnTo). */
|
|
13
|
+
@IsOptional()
|
|
14
|
+
@IsString()
|
|
15
|
+
@MaxLength(512)
|
|
16
|
+
next?: string
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.feature.auth); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { IsEmail, IsString, MaxLength, MinLength } from "class-validator"
|
|
3
|
+
|
|
4
|
+
/** The posted sign-in pair. */
|
|
5
|
+
export class LoginRequest {
|
|
6
|
+
/** The sign-in address. */
|
|
7
|
+
@IsEmail()
|
|
8
|
+
@MaxLength(254)
|
|
9
|
+
email!: string
|
|
10
|
+
|
|
11
|
+
/** The password. */
|
|
12
|
+
@IsString()
|
|
13
|
+
@MinLength(1)
|
|
14
|
+
@MaxLength(128)
|
|
15
|
+
password!: string
|
|
16
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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, mockEntityManager } from "@starci/jest-preset"
|
|
5
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
6
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
7
|
+
import { authUserRow as user } from "@tests/fixtures/builders/identity.builder"
|
|
8
|
+
import { AuthUserEntity } from "./persistence/entities"
|
|
9
|
+
import { IdentityAccountDirectoryService, isDisabled } from "./account-directory.service"
|
|
10
|
+
|
|
11
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
12
|
+
|
|
13
|
+
const build = async (rows: ReadonlyArray<AuthUserEntity | null>) => {
|
|
14
|
+
const entityManager = mockEntityManager({ findOne: rows.map((result) => [AuthUserEntity, result] as const) })
|
|
15
|
+
const moduleRef = await Test.createTestingModule({
|
|
16
|
+
providers: [
|
|
17
|
+
IdentityAccountDirectoryService,
|
|
18
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
19
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
20
|
+
],
|
|
21
|
+
}).compile()
|
|
22
|
+
return moduleRef.get(IdentityAccountDirectoryService)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("IdentityAccountDirectoryService.find", () => {
|
|
26
|
+
it("answers the standing of a live account, a banned one, a deleted one and a missing one", async () => {
|
|
27
|
+
const subject = await build([user("live"), user("banned"), user("ban-ended"), user("deleted"), null])
|
|
28
|
+
|
|
29
|
+
expect(await subject.find("u-1")).toEqual({ id: "u-1", email: "a@example.test", disabled: false })
|
|
30
|
+
expect((await subject.find("u-1"))?.disabled).toBe(true)
|
|
31
|
+
expect((await subject.find("u-1"))?.disabled).toBe(false)
|
|
32
|
+
expect((await subject.find("u-1"))?.disabled).toBe(true)
|
|
33
|
+
expect(await subject.find("u-2")).toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe("isDisabled", () => {
|
|
38
|
+
it("is true for a deleted account and for a ban that has not ended", () => {
|
|
39
|
+
const now = new Date(AT)
|
|
40
|
+
|
|
41
|
+
expect(isDisabled(user("deleted"), now)).toBe(true)
|
|
42
|
+
expect(isDisabled(user("banned"), now)).toBe(true)
|
|
43
|
+
expect(isDisabled(user("ban-ended"), now)).toBe(false)
|
|
44
|
+
expect(isDisabled(user("live"), now)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
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 type { EntityManager } from "typeorm"
|
|
4
|
+
import type { AccountDirectory, AccountStanding } from "@modules/integrations/passport"
|
|
5
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
6
|
+
import type { Clock } from "@modules/platform/clock"
|
|
7
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
8
|
+
import { AuthUserEntity } from "./persistence/entities"
|
|
9
|
+
|
|
10
|
+
/** True when the account is deleted or banned at this instant. */
|
|
11
|
+
export const isDisabled = (user: AuthUserEntity, now: Date): boolean =>
|
|
12
|
+
user.deletedAt !== null || (user.bannedUntil !== null && user.bannedUntil > now)
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
/** The package's account directory over auth.users: whether an account still stands, for a refresh to go on. */
|
|
16
|
+
export class IdentityAccountDirectoryService implements AccountDirectory {
|
|
17
|
+
constructor(
|
|
18
|
+
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
19
|
+
@InjectClock() private readonly clock: Clock,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/** The standing of one account, or undefined when it does not exist. */
|
|
23
|
+
async find(userId: string): Promise<AccountStanding | undefined> {
|
|
24
|
+
const user = await this.entityManager.findOne(AuthUserEntity, { where: { id: userId } })
|
|
25
|
+
if (user === null) return undefined
|
|
26
|
+
return { id: user.id, email: user.email, disabled: isDisabled(user, this.clock.now()) }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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 { PassportErrorCode } from "@modules/integrations/passport"
|
|
7
|
+
import type { AuthPasswords, GoogleProfile, SignInBudget } from "@modules/integrations/passport"
|
|
8
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
9
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
10
|
+
import { IDS } from "@modules/platform/ids"
|
|
11
|
+
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
12
|
+
import { IDENTITY_AUTH_PASSWORDS, IDENTITY_SIGN_IN_BUDGET } from "./identity.decorators"
|
|
13
|
+
import { authUserRow as user } from "@tests/fixtures/builders/identity.builder"
|
|
14
|
+
import { IdentityAuthenticatorService } from "./authenticator.service"
|
|
15
|
+
import { AuthIdentityEntity, AuthUserEntity } from "./persistence/entities"
|
|
16
|
+
|
|
17
|
+
const NEW_ID = "00000000-0000-4000-8000-000000000001"
|
|
18
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
19
|
+
const NOW = new Date(AT)
|
|
20
|
+
const BEFORE = new Date("2026-02-03T04:00:00.000Z")
|
|
21
|
+
|
|
22
|
+
const profile = (patch: Partial<GoogleProfile> = {}): GoogleProfile => ({
|
|
23
|
+
subject: "g-1",
|
|
24
|
+
email: "A@Example.test",
|
|
25
|
+
emailVerified: true,
|
|
26
|
+
name: "A",
|
|
27
|
+
picture: null,
|
|
28
|
+
...patch,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const build = async (entityManager: MockEntityManager, matches = true) => {
|
|
32
|
+
const passwords = mock<AuthPasswords>()
|
|
33
|
+
passwords.verify.mockResolvedValue(matches)
|
|
34
|
+
const budget = mock<SignInBudget>()
|
|
35
|
+
budget.counted.mockImplementation(async (_kind, _address, check) => (await check()).value)
|
|
36
|
+
const moduleRef = await Test.createTestingModule({
|
|
37
|
+
providers: [
|
|
38
|
+
IdentityAuthenticatorService,
|
|
39
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
40
|
+
{ provide: IDENTITY_AUTH_PASSWORDS, useValue: passwords },
|
|
41
|
+
{ provide: IDENTITY_SIGN_IN_BUDGET, useValue: budget },
|
|
42
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
43
|
+
{ provide: IDS, useValue: fakeIds() },
|
|
44
|
+
],
|
|
45
|
+
}).compile()
|
|
46
|
+
return { subject: moduleRef.get(IdentityAuthenticatorService), passwords, budget }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const refusal = (code: string): unknown => expect.objectContaining({ code })
|
|
50
|
+
|
|
51
|
+
describe("IdentityAuthenticatorService.verifyPassword", () => {
|
|
52
|
+
it("answers the person of a right pair, normalising the address, and records the sign-in", async () => {
|
|
53
|
+
const entityManager = mockEntityManager({ findOne: [AuthUserEntity, user()], update: [AuthUserEntity, {}] })
|
|
54
|
+
const { subject, passwords, budget } = await build(entityManager)
|
|
55
|
+
|
|
56
|
+
expect(await subject.verifyPassword(" A@Example.test ", "correct horse")).toEqual({ userId: "u-1", email: "a@example.test" })
|
|
57
|
+
|
|
58
|
+
expect(budget.counted).toHaveBeenCalledWith("login", "a@example.test", expect.any(Function))
|
|
59
|
+
expect(passwords.verify).toHaveBeenCalledWith("correct horse", "hash")
|
|
60
|
+
expect(entityManager.update).toHaveBeenCalledWith(AuthUserEntity, { id: "u-1" }, { lastSignInAt: NOW })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("refuses an unknown address and a wrong password with the same code, and still spends the check on a decoy", async () => {
|
|
64
|
+
const unknown = await build(mockEntityManager({ findOne: [AuthUserEntity, null] }))
|
|
65
|
+
const wrong = await build(mockEntityManager({ findOne: [AuthUserEntity, user()] }), false)
|
|
66
|
+
|
|
67
|
+
await expect(unknown.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.PairRefused))
|
|
68
|
+
await expect(wrong.subject.verifyPassword("a@example.test", "pw")).rejects.toBeInstanceOf(IdentityError)
|
|
69
|
+
expect(unknown.passwords.verify).toHaveBeenCalledWith("pw", "")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("compares an empty password for one bcrypt cannot read, an account without a hash against the decoy", async () => {
|
|
73
|
+
const { subject, passwords } = await build(mockEntityManager({ findOne: [AuthUserEntity, user("passwordless")] }), false)
|
|
74
|
+
|
|
75
|
+
await expect(subject.verifyPassword("a@example.test", "x".repeat(100))).rejects.toBeInstanceOf(IdentityError)
|
|
76
|
+
|
|
77
|
+
expect(passwords.verify).toHaveBeenCalledWith("", "")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("refuses a banned or deleted account and an unconfirmed one after the password was right", async () => {
|
|
81
|
+
const banned = await build(mockEntityManager({ findOne: [AuthUserEntity, user("banned")] }))
|
|
82
|
+
const unconfirmed = await build(mockEntityManager({ findOne: [AuthUserEntity, user("unconfirmed")] }))
|
|
83
|
+
const addressless = await build(mockEntityManager({ findOne: [AuthUserEntity, user("addressless")] }))
|
|
84
|
+
|
|
85
|
+
await expect(banned.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
|
|
86
|
+
await expect(unconfirmed.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.EmailNotConfirmed))
|
|
87
|
+
await expect(addressless.subject.verifyPassword("a@example.test", "pw")).rejects.toEqual(refusal(IdentityErrorCode.EmailNotConfirmed))
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("IdentityAuthenticatorService.signInWithGoogle", () => {
|
|
92
|
+
it("refuses a profile without a verified address", async () => {
|
|
93
|
+
const { subject } = await build(mockEntityManager())
|
|
94
|
+
|
|
95
|
+
await expect(subject.signInWithGoogle(profile({ emailVerified: false }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
|
|
96
|
+
await expect(subject.signInWithGoogle(profile({ email: null }))).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("signs in the account a Google subject is already linked to", async () => {
|
|
100
|
+
const linked: AuthIdentityEntity = { id: "i-1", providerId: "g-1", userId: "u-1", identityData: {}, provider: "google", lastSignInAt: null, createdAt: BEFORE, updatedAt: BEFORE }
|
|
101
|
+
const entityManager = mockEntityManager({ findOne: [[AuthIdentityEntity, linked], [AuthUserEntity, user("unconfirmed")]], update: [AuthUserEntity, {}] })
|
|
102
|
+
fakeTransaction(entityManager)
|
|
103
|
+
const { subject } = await build(entityManager)
|
|
104
|
+
|
|
105
|
+
expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
|
|
106
|
+
|
|
107
|
+
expect(entityManager.insert).not.toHaveBeenCalled()
|
|
108
|
+
expect(entityManager.update).toHaveBeenCalledWith(AuthUserEntity, { id: "u-1" }, { lastSignInAt: NOW, emailConfirmedAt: NOW })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("links an existing account by its verified address and keeps its confirmation date", async () => {
|
|
112
|
+
const entityManager = mockEntityManager({ findOne: [[AuthIdentityEntity, null], [AuthUserEntity, user()]], insert: [AuthIdentityEntity, {}], update: [AuthUserEntity, {}] })
|
|
113
|
+
fakeTransaction(entityManager)
|
|
114
|
+
const { subject } = await build(entityManager)
|
|
115
|
+
|
|
116
|
+
expect(await subject.signInWithGoogle(profile())).toEqual({ userId: "u-1", email: "a@example.test" })
|
|
117
|
+
|
|
118
|
+
expect(entityManager.insert).toHaveBeenCalledWith(AuthIdentityEntity, expect.objectContaining({ id: NEW_ID, providerId: "g-1", userId: "u-1", provider: "google" }))
|
|
119
|
+
expect(entityManager.update).toHaveBeenCalledWith(AuthUserEntity, { id: "u-1" }, { lastSignInAt: NOW, emailConfirmedAt: BEFORE })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("creates the account of a new person and links their Google identity", async () => {
|
|
123
|
+
const entityManager = mockEntityManager({
|
|
124
|
+
findOne: [[AuthIdentityEntity, null], [AuthUserEntity, null], [AuthUserEntity, user("created")]],
|
|
125
|
+
insert: [[AuthUserEntity, {}], [AuthIdentityEntity, {}]],
|
|
126
|
+
update: [AuthUserEntity, {}],
|
|
127
|
+
})
|
|
128
|
+
fakeTransaction(entityManager)
|
|
129
|
+
const { subject } = await build(entityManager)
|
|
130
|
+
|
|
131
|
+
expect(await subject.signInWithGoogle(profile())).toEqual({ userId: NEW_ID, email: "a@example.test" })
|
|
132
|
+
|
|
133
|
+
expect(entityManager.insert).toHaveBeenCalledWith(AuthUserEntity, expect.objectContaining({ id: NEW_ID, email: "a@example.test", encryptedPassword: null, emailConfirmedAt: NOW }))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("refuses a disabled account and an account that cannot be read back", async () => {
|
|
137
|
+
const disabled = mockEntityManager({ findOne: [[AuthIdentityEntity, null], [AuthUserEntity, user("deleted")]], insert: [AuthIdentityEntity, {}], update: [AuthUserEntity, {}] })
|
|
138
|
+
fakeTransaction(disabled)
|
|
139
|
+
const missing = mockEntityManager({ findOne: [[AuthIdentityEntity, null], [AuthUserEntity, null]], insert: [[AuthUserEntity, {}], [AuthIdentityEntity, {}]], update: [AuthUserEntity, {}] })
|
|
140
|
+
fakeTransaction(missing)
|
|
141
|
+
|
|
142
|
+
await expect((await build(disabled)).subject.signInWithGoogle(profile())).rejects.toEqual(refusal(IdentityErrorCode.AccountDisabled))
|
|
143
|
+
await expect((await build(missing)).subject.signInWithGoogle(profile())).rejects.toEqual(refusal(PassportErrorCode.GoogleRejected))
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
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 type { EntityManager } from "typeorm"
|
|
4
|
+
import { PassportError, PassportErrorCode, isPresentablePassword, normalizeEmail } from "@modules/integrations/passport"
|
|
5
|
+
import type { AuthAuthenticator, AuthPasswords, AuthSubject, GoogleProfile, SignInBudget } from "@modules/integrations/passport"
|
|
6
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
7
|
+
import type { Clock } from "@modules/platform/clock"
|
|
8
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
9
|
+
import { InjectIds } from "@modules/platform/ids"
|
|
10
|
+
import type { Ids } from "@modules/platform/ids"
|
|
11
|
+
import { isDisabled } from "./account-directory.service"
|
|
12
|
+
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
13
|
+
import { InjectAuthPasswords, InjectSignInBudget } from "./identity.decorators"
|
|
14
|
+
import { AuthIdentityEntity, AuthUserEntity } from "./persistence/entities"
|
|
15
|
+
|
|
16
|
+
const GOOGLE = "google"
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
/** The two sign-in decisions: is this email and password right, and which account does this Google profile stand for. */
|
|
20
|
+
export class IdentityAuthenticatorService implements AuthAuthenticator {
|
|
21
|
+
constructor(
|
|
22
|
+
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
23
|
+
@InjectAuthPasswords() private readonly passwords: AuthPasswords,
|
|
24
|
+
@InjectSignInBudget() private readonly budget: SignInBudget,
|
|
25
|
+
@InjectClock() private readonly clock: Clock,
|
|
26
|
+
@InjectIds() private readonly ids: Ids,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
/** Verifies the pair. An unknown address and a wrong password answer the same refusal; only wrong passwords count against the budget. */
|
|
30
|
+
async verifyPassword(email: string, password: string): Promise<AuthSubject> {
|
|
31
|
+
const address = normalizeEmail(email)
|
|
32
|
+
const user = await this.budget.counted("login", address, async () => {
|
|
33
|
+
const found = await this.entityManager.findOne(AuthUserEntity, { where: { email: address, isAnonymous: false } })
|
|
34
|
+
const matches = await this.passwords.verify(isPresentablePassword(password) ? password : "", found?.encryptedPassword ?? "")
|
|
35
|
+
return { passed: found !== null && matches, value: found !== null && matches ? found : null }
|
|
36
|
+
})
|
|
37
|
+
if (user === null) throw new IdentityError({ code: IdentityErrorCode.PairRefused })
|
|
38
|
+
const now = this.clock.now()
|
|
39
|
+
if (isDisabled(user, now)) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
|
|
40
|
+
if (user.emailConfirmedAt === null || user.email === null) throw new IdentityError({ code: IdentityErrorCode.EmailNotConfirmed })
|
|
41
|
+
await this.entityManager.update(AuthUserEntity, { id: user.id }, { lastSignInAt: now })
|
|
42
|
+
return { userId: user.id, email: user.email }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Finds the account a Google subject is linked to, else links by a verified email, else creates the account. */
|
|
46
|
+
async signInWithGoogle(profile: GoogleProfile): Promise<AuthSubject> {
|
|
47
|
+
if (profile.email === null || !profile.emailVerified) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
|
|
48
|
+
const address = normalizeEmail(profile.email)
|
|
49
|
+
const now = this.clock.now()
|
|
50
|
+
const user = await this.entityManager.transaction(async (scoped) => {
|
|
51
|
+
const linked = await scoped.findOne(AuthIdentityEntity, { where: { provider: GOOGLE, providerId: profile.subject } })
|
|
52
|
+
const known =
|
|
53
|
+
(linked === null ? null : await scoped.findOne(AuthUserEntity, { where: { id: linked.userId } })) ??
|
|
54
|
+
(await scoped.findOne(AuthUserEntity, { where: { email: address, isAnonymous: false } }))
|
|
55
|
+
const person = known ?? (await this.create(scoped, address, now))
|
|
56
|
+
if (linked === null) {
|
|
57
|
+
await scoped.insert(AuthIdentityEntity, {
|
|
58
|
+
id: this.ids.next(),
|
|
59
|
+
providerId: profile.subject,
|
|
60
|
+
userId: person.id,
|
|
61
|
+
provider: GOOGLE,
|
|
62
|
+
identityData: { sub: profile.subject, email: address, name: profile.name, picture: profile.picture, email_verified: true },
|
|
63
|
+
lastSignInAt: now,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
await scoped.update(AuthUserEntity, { id: person.id }, { lastSignInAt: now, emailConfirmedAt: person.emailConfirmedAt ?? now })
|
|
69
|
+
return person
|
|
70
|
+
})
|
|
71
|
+
if (isDisabled(user, now)) throw new IdentityError({ code: IdentityErrorCode.AccountDisabled })
|
|
72
|
+
return { userId: user.id, email: address }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async create(scoped: EntityManager, address: string, now: Date): Promise<AuthUserEntity> {
|
|
76
|
+
const id = this.ids.next()
|
|
77
|
+
await scoped.insert(AuthUserEntity, {
|
|
78
|
+
id,
|
|
79
|
+
aud: "authenticated",
|
|
80
|
+
role: "authenticated",
|
|
81
|
+
email: address,
|
|
82
|
+
encryptedPassword: null,
|
|
83
|
+
emailConfirmedAt: now,
|
|
84
|
+
lastSignInAt: now,
|
|
85
|
+
bannedUntil: null,
|
|
86
|
+
deletedAt: null,
|
|
87
|
+
isAnonymous: false,
|
|
88
|
+
createdAt: now,
|
|
89
|
+
updatedAt: now,
|
|
90
|
+
})
|
|
91
|
+
const created = await scoped.findOne(AuthUserEntity, { where: { id } })
|
|
92
|
+
if (created === null) throw new PassportError({ code: PassportErrorCode.GoogleRejected })
|
|
93
|
+
return created
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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: "auth", name: "identities" })
|
|
5
|
+
/** One external identity of an account (a Google subject), mapped from auth.identities of db/bootstrap.sql. */
|
|
6
|
+
export class AuthIdentityEntity {
|
|
7
|
+
/** The row id. */
|
|
8
|
+
@PrimaryColumn({ name: "id", type: "uuid" })
|
|
9
|
+
id!: string
|
|
10
|
+
|
|
11
|
+
/** The provider's id of the person (the Google subject). */
|
|
12
|
+
@Column({ name: "provider_id", type: "text" })
|
|
13
|
+
providerId!: string
|
|
14
|
+
|
|
15
|
+
/** The account the identity belongs to. */
|
|
16
|
+
@Column({ name: "user_id", type: "uuid" })
|
|
17
|
+
userId!: string
|
|
18
|
+
|
|
19
|
+
/** What the provider told us, as JSON. */
|
|
20
|
+
@Column({ name: "identity_data", type: "jsonb" })
|
|
21
|
+
identityData!: Record<string, string | boolean | null>
|
|
22
|
+
|
|
23
|
+
/** The provider name. */
|
|
24
|
+
@Column({ name: "provider", type: "text" })
|
|
25
|
+
provider!: string
|
|
26
|
+
|
|
27
|
+
/** The last sign-in through this identity, or null. */
|
|
28
|
+
@Column({ name: "last_sign_in_at", type: "timestamptz", nullable: true })
|
|
29
|
+
lastSignInAt!: Date | null
|
|
30
|
+
|
|
31
|
+
/** When the identity was linked. */
|
|
32
|
+
@Column({ name: "created_at", type: "timestamptz", nullable: true })
|
|
33
|
+
createdAt!: Date | null
|
|
34
|
+
|
|
35
|
+
/** When the identity last changed. */
|
|
36
|
+
@Column({ name: "updated_at", type: "timestamptz", nullable: true })
|
|
37
|
+
updatedAt!: Date | null
|
|
38
|
+
}
|