@stjoseph/prumo 0.2.0 → 0.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/.prumo-templates/api/auth.md +51 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -25,7 +25,7 @@ const APPS = {
|
|
|
25
25
|
// A port that moves takes these with it: the key to write, in which app, from which app's port.
|
|
26
26
|
const FOLLOWERS = {
|
|
27
27
|
api: [
|
|
28
|
-
{ app: 'api', key: '
|
|
28
|
+
{ app: 'api', key: 'API_URL' },
|
|
29
29
|
{ app: 'web', key: 'VITE_API_URL' },
|
|
30
30
|
{ app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
|
|
31
31
|
],
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// The web sends its refresh token as a cookie and no body, which Fastify hands over as null; a native client
|
|
4
|
+
// sends it here.
|
|
5
|
+
export const refreshSchema = z
|
|
6
|
+
.strictObject({ refreshToken: z.string().min(1).optional() })
|
|
7
|
+
.nullish()
|
|
8
|
+
|
|
9
|
+
export type RefreshDto = z.infer<typeof refreshSchema>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// What a native client receives instead of cookies. `expiresIn` is in seconds, as OAuth writes it.
|
|
4
|
+
export const tokensResponseSchema = z.object({
|
|
5
|
+
accessToken: z.string(),
|
|
6
|
+
refreshToken: z.string(),
|
|
7
|
+
expiresIn: z.number().int(),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export type TokensResponseDto = z.infer<typeof tokensResponseSchema>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Short enough that guessing is a race against the clock, long enough to switch to the mail app and back.
|
|
2
|
+
export const EMAIL_CODE_LIFETIME_MS = 15 * 60 * 1000
|
|
3
|
+
export const EMAIL_CODE_ATTEMPTS = 5
|
|
4
|
+
|
|
5
|
+
export type EmailCodePurpose = 'verify-email' | 'reset-password'
|
|
6
|
+
|
|
7
|
+
export type CodeOutcome = 'valid' | 'wrong' | 'expired'
|
|
8
|
+
|
|
9
|
+
// One live code per user and purpose: sending another replaces it, and the old one stops working.
|
|
10
|
+
export class EmailCode {
|
|
11
|
+
id: string
|
|
12
|
+
userId: string
|
|
13
|
+
purpose: EmailCodePurpose
|
|
14
|
+
codeHash: string
|
|
15
|
+
attemptsLeft: number
|
|
16
|
+
expiresAt: Date
|
|
17
|
+
createdAt: Date
|
|
18
|
+
updatedAt: Date
|
|
19
|
+
|
|
20
|
+
constructor(userId: string, purpose: EmailCodePurpose, codeHash: string, now: Date) {
|
|
21
|
+
this.userId = userId
|
|
22
|
+
this.purpose = purpose
|
|
23
|
+
this.renew(codeHash, now)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
renew(codeHash: string, now: Date): void {
|
|
27
|
+
this.codeHash = codeHash
|
|
28
|
+
this.attemptsLeft = EMAIL_CODE_ATTEMPTS
|
|
29
|
+
this.expiresAt = new Date(now.getTime() + EMAIL_CODE_LIFETIME_MS)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Five wrong guesses out of a million leave nothing to brute-force: the code dies, and a new one must be sent.
|
|
33
|
+
check(codeHash: string, now: Date): CodeOutcome {
|
|
34
|
+
if (this.attemptsLeft <= 0 || this.expiresAt <= now) {
|
|
35
|
+
return 'expired'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (codeHash === this.codeHash) {
|
|
39
|
+
this.attemptsLeft = 0
|
|
40
|
+
return 'valid'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.attemptsLeft -= 1
|
|
44
|
+
return this.attemptsLeft === 0 ? 'expired' : 'wrong'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
|
|
2
|
+
|
|
3
|
+
// A user as a provider knows them. The provider's subject, not the email, is what recognises them next time: an
|
|
4
|
+
// email can change at the provider, a subject cannot.
|
|
5
|
+
export class Identity {
|
|
6
|
+
id: string
|
|
7
|
+
userId: string
|
|
8
|
+
provider: ProviderName
|
|
9
|
+
subject: string
|
|
10
|
+
createdAt: Date
|
|
11
|
+
updatedAt: Date
|
|
12
|
+
|
|
13
|
+
constructor(userId: string, provider: ProviderName, subject: string) {
|
|
14
|
+
this.userId = userId
|
|
15
|
+
this.provider = provider
|
|
16
|
+
this.subject = subject
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// A refresh token is long-lived, so it rotates on every use and is kept only as a hash.
|
|
2
|
+
export const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000
|
|
3
|
+
|
|
4
|
+
// Two tabs refreshing at once both present the same token. The second, arriving within this window, is told to
|
|
5
|
+
// retry instead of being treated as a stolen token.
|
|
6
|
+
export const ROTATION_GRACE_MS = 10 * 1000
|
|
7
|
+
|
|
8
|
+
export type RefreshOutcome = 'valid' | 'superseded' | 'reused' | 'expired'
|
|
9
|
+
|
|
10
|
+
// A session the user started in this application has none of these. One an OAuth client obtained names the client,
|
|
11
|
+
// the resource its access tokens are for, and the scopes the user granted it, space-separated as OAuth writes them.
|
|
12
|
+
export type Grant = { clientId: string; resource: string; scope: string }
|
|
13
|
+
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
15
|
+
|
|
16
|
+
export class Session {
|
|
17
|
+
id: string
|
|
18
|
+
userId: string
|
|
19
|
+
tokenHash: string
|
|
20
|
+
clientId: string | null = null
|
|
21
|
+
resource: string | null = null
|
|
22
|
+
scope: string | null = null
|
|
23
|
+
previousTokenHash: string | null = null
|
|
24
|
+
rotatedAt: Date | null = null
|
|
25
|
+
expiresAt: Date
|
|
26
|
+
revokedAt: Date | null = null
|
|
27
|
+
createdAt: Date
|
|
28
|
+
updatedAt: Date
|
|
29
|
+
|
|
30
|
+
constructor(userId: string, tokenHash: string, now: Date, grant?: Grant) {
|
|
31
|
+
this.userId = userId
|
|
32
|
+
this.tokenHash = tokenHash
|
|
33
|
+
this.clientId = grant?.clientId ?? null
|
|
34
|
+
this.resource = grant?.resource ?? null
|
|
35
|
+
this.scope = grant?.scope ?? null
|
|
36
|
+
this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The refresh token names its session, so a token from any earlier rotation is recognised as reuse.
|
|
40
|
+
static parseRefreshToken(token: string): { sessionId: string; secret: string } | null {
|
|
41
|
+
const dot = token.indexOf('.')
|
|
42
|
+
const sessionId = token.slice(0, dot)
|
|
43
|
+
const secret = token.slice(dot + 1)
|
|
44
|
+
|
|
45
|
+
return dot === -1 || !UUID.test(sessionId) || secret === '' ? null : { sessionId, secret }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
refreshToken(secret: string): string {
|
|
49
|
+
return `${this.id}.${secret}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A token that is neither the current one nor the one just replaced was replaced long ago: someone kept a copy.
|
|
53
|
+
check(tokenHash: string, now: Date): RefreshOutcome {
|
|
54
|
+
if (this.revokedAt !== null || this.expiresAt <= now) {
|
|
55
|
+
return 'expired'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (tokenHash === this.tokenHash) {
|
|
59
|
+
return 'valid'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const justRotated =
|
|
63
|
+
this.rotatedAt !== null && now.getTime() - this.rotatedAt.getTime() < ROTATION_GRACE_MS
|
|
64
|
+
|
|
65
|
+
return tokenHash === this.previousTokenHash && justRotated ? 'superseded' : 'reused'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
rotate(tokenHash: string, now: Date): void {
|
|
69
|
+
this.previousTokenHash = this.tokenHash
|
|
70
|
+
this.tokenHash = tokenHash
|
|
71
|
+
this.rotatedAt = now
|
|
72
|
+
this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
revoke(now: Date): void {
|
|
76
|
+
this.revokedAt ??= now
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
|
|
2
|
+
|
|
3
|
+
// Long enough to sign in at the provider; the code a mobile app exchanges afterwards is spent within two minutes.
|
|
4
|
+
export const SOCIAL_SIGN_IN_LIFETIME_MS = 10 * 60 * 1000
|
|
5
|
+
export const SOCIAL_EXCHANGE_LIFETIME_MS = 2 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
|
|
9
|
+
export type SocialClient = 'web' | 'mobile'
|
|
10
|
+
|
|
11
|
+
export type SocialSignInStatus = 'started' | 'signed-in' | 'exchanged'
|
|
12
|
+
|
|
13
|
+
// One trip to a provider and back. Its id travels as OAuth's state; the browser that started it holds a secret the
|
|
14
|
+
// callback must present, so a callback carrying someone else's code cannot sign this browser into their account.
|
|
15
|
+
export class SocialSignIn {
|
|
16
|
+
id: string
|
|
17
|
+
provider: ProviderName
|
|
18
|
+
client: SocialClient
|
|
19
|
+
returnTo: string
|
|
20
|
+
browserHash: string
|
|
21
|
+
codeVerifier: string | null = null
|
|
22
|
+
nonce = ''
|
|
23
|
+
status: SocialSignInStatus = 'started'
|
|
24
|
+
userId: string | null = null
|
|
25
|
+
exchangeHash: string | null = null
|
|
26
|
+
expiresAt: Date
|
|
27
|
+
createdAt: Date
|
|
28
|
+
updatedAt: Date
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
provider: ProviderName,
|
|
32
|
+
client: SocialClient,
|
|
33
|
+
returnTo: string,
|
|
34
|
+
browserHash: string,
|
|
35
|
+
now: Date,
|
|
36
|
+
) {
|
|
37
|
+
this.provider = provider
|
|
38
|
+
this.client = client
|
|
39
|
+
this.returnTo = returnTo
|
|
40
|
+
this.browserHash = browserHash
|
|
41
|
+
this.expiresAt = new Date(now.getTime() + SOCIAL_SIGN_IN_LIFETIME_MS)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `<id>.<secret>`, as refresh tokens and authorization codes are written.
|
|
45
|
+
static parseToken(token: string): { id: string; secret: string } | null {
|
|
46
|
+
const dot = token.indexOf('.')
|
|
47
|
+
const id = token.slice(0, dot)
|
|
48
|
+
const secret = token.slice(dot + 1)
|
|
49
|
+
|
|
50
|
+
return dot === -1 || !UUID.test(id) || secret === '' ? null : { id, secret }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
token(secret: string): string {
|
|
54
|
+
return `${this.id}.${secret}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// What the provider's redirect requires back: the PKCE verifier, when the provider takes PKCE, and the nonce.
|
|
58
|
+
prepare(codeVerifier: string | null, nonce: string): void {
|
|
59
|
+
this.codeVerifier = codeVerifier
|
|
60
|
+
this.nonce = nonce
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isStarted(now: Date): boolean {
|
|
64
|
+
return this.status === 'started' && this.expiresAt > now
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The web is signed in at once; a mobile app gets a code to exchange, so its tokens never travel in a URL.
|
|
68
|
+
signIn(userId: string, exchangeHash: string | null, now: Date): void {
|
|
69
|
+
this.status = 'signed-in'
|
|
70
|
+
this.userId = userId
|
|
71
|
+
this.exchangeHash = exchangeHash
|
|
72
|
+
this.expiresAt = new Date(now.getTime() + SOCIAL_EXCHANGE_LIFETIME_MS)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isExchangeable(now: Date): boolean {
|
|
76
|
+
return this.status === 'signed-in' && this.exchangeHash !== null && this.expiresAt > now
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
exchange(): void {
|
|
80
|
+
this.status = 'exchanged'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class User {
|
|
2
|
+
id: string
|
|
3
|
+
email: string
|
|
4
|
+
// Null for an account that only ever signed in through a provider, or whose password a provider's proof voided.
|
|
5
|
+
passwordHash: string | null
|
|
6
|
+
emailVerifiedAt: Date | null = null
|
|
7
|
+
createdAt: Date
|
|
8
|
+
updatedAt: Date
|
|
9
|
+
|
|
10
|
+
constructor(email: string, passwordHash: string | null) {
|
|
11
|
+
this.email = User.normalizeEmail(email)
|
|
12
|
+
this.passwordHash = passwordHash
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// One address, one account: `Ana@Example.com ` and `ana@example.com` are the same person.
|
|
16
|
+
static normalizeEmail(email: string): string {
|
|
17
|
+
return email.trim().toLowerCase()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
verifyEmail(now: Date): void {
|
|
21
|
+
this.emailVerifiedAt ??= now
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
changePassword(passwordHash: string): void {
|
|
25
|
+
this.passwordHash = passwordHash
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// prumo:social
|
|
29
|
+
// A provider proved the address belongs to someone else than whoever chose this password.
|
|
30
|
+
removePassword(): void {
|
|
31
|
+
this.passwordHash = null
|
|
32
|
+
}
|
|
33
|
+
// prumo:end-social
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
export class EmailNotVerifiedError extends DomainError {
|
|
4
|
+
readonly kind = 'forbidden'
|
|
5
|
+
override readonly code = 'email_not_verified'
|
|
6
|
+
|
|
7
|
+
constructor() {
|
|
8
|
+
super('Confirm your email before signing in')
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// One answer for a wrong code, a dead one and an unknown email, so the form does not say which accounts exist.
|
|
4
|
+
export class InvalidCodeError extends DomainError {
|
|
5
|
+
readonly kind = 'invalid'
|
|
6
|
+
override readonly code = 'invalid_code'
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super('The code is wrong or has expired')
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// One message for an unknown email and a wrong password, so the answer does not say which accounts exist.
|
|
4
|
+
export class InvalidCredentialsError extends DomainError {
|
|
5
|
+
readonly kind = 'unauthorized'
|
|
6
|
+
|
|
7
|
+
constructor() {
|
|
8
|
+
super('Invalid email or password')
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// Without the provider's word that the address is theirs, an email cannot be trusted to name an account.
|
|
4
|
+
export class ProviderEmailUnverifiedError extends DomainError {
|
|
5
|
+
readonly kind = 'forbidden'
|
|
6
|
+
override readonly code = 'provider_email_unverified'
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super('The provider has not verified this email address')
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
export class ProviderNotConfiguredError extends DomainError {
|
|
4
|
+
readonly kind = 'invalid'
|
|
5
|
+
override readonly code = 'provider_not_configured'
|
|
6
|
+
|
|
7
|
+
constructor(provider: string) {
|
|
8
|
+
super(`Sign-in with ${provider} is not configured: set its variables in .env`)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// Expired, already used, started in another browser, or refused by the provider: the user starts again.
|
|
4
|
+
export class SocialSignInFailedError extends DomainError {
|
|
5
|
+
readonly kind = 'unauthorized'
|
|
6
|
+
override readonly code = 'social_sign_in_failed'
|
|
7
|
+
|
|
8
|
+
constructor(message = 'The sign-in did not complete. Start again.') {
|
|
9
|
+
super(message)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
2
|
+
|
|
3
|
+
// Another request rotated this token a moment ago. The client already holds the new one, so it retries instead of
|
|
4
|
+
// signing out.
|
|
5
|
+
export class SupersededTokenError extends DomainError {
|
|
6
|
+
readonly kind = 'conflict'
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super('Refresh token was just rotated; retry with the current one')
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type AccessClaims = { userId: string; sessionId: string; clientId?: string; scope?: string }
|
|
2
|
+
|
|
3
|
+
export type IssuedToken = { token: string; expiresAt: Date }
|
|
4
|
+
|
|
5
|
+
// Short-lived and checked by signature alone: nothing is looked up to accept one, so nothing can revoke one.
|
|
6
|
+
export const ACCESS_TOKEN_LIFETIME_MS = 15 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
// Without an audience a token is for this API's own routes; with one, for that resource alone. Neither is accepted
|
|
9
|
+
// where the other is expected.
|
|
10
|
+
export interface AccessTokens {
|
|
11
|
+
issue(claims: AccessClaims, expiresAt: Date, audience?: string): Promise<IssuedToken>
|
|
12
|
+
verify(token: string, audience?: string): Promise<AccessClaims | null>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ACCESS_TOKENS = Symbol('AccessTokens')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type ProviderName = 'google' | 'apple'
|
|
2
|
+
|
|
3
|
+
export type ProviderProfile = {
|
|
4
|
+
subject: string
|
|
5
|
+
email: string
|
|
6
|
+
emailVerified: boolean
|
|
7
|
+
name: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ProviderRedirect = { url: string; codeVerifier: string | null; nonce: string }
|
|
11
|
+
|
|
12
|
+
export type ProviderCallback = {
|
|
13
|
+
callbackUrl: URL
|
|
14
|
+
state: string
|
|
15
|
+
codeVerifier: string | null
|
|
16
|
+
nonce: string
|
|
17
|
+
// Apple sends the user's name once, beside the code, and never inside the ID token.
|
|
18
|
+
name: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Google and Apple speak OpenID Connect: the adapter builds the provider's URL and, on the way back, validates the
|
|
22
|
+
// ID token (signature, issuer, audience, nonce) before anything here reads it.
|
|
23
|
+
export interface IdentityProviders {
|
|
24
|
+
isConfigured(provider: ProviderName): boolean
|
|
25
|
+
redirect(provider: ProviderName, state: string): Promise<ProviderRedirect>
|
|
26
|
+
profile(provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const IDENTITY_PROVIDERS = Symbol('IdentityProviders')
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Mail = { to: string; subject: string; text: string }
|
|
2
|
+
|
|
3
|
+
// The template writes mail to the log; a project that sends real mail replaces the adapter, not the callers.
|
|
4
|
+
export interface Mailer {
|
|
5
|
+
send(mail: Mail): Promise<void>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const MAILER = Symbol('Mailer')
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PasswordHasher {
|
|
2
|
+
hash(password: string): Promise<string>
|
|
3
|
+
// Without a hash it still spends the time of a real check and answers false, so an unknown email and a wrong
|
|
4
|
+
// password take as long as each other.
|
|
5
|
+
verify(hash: string | undefined, password: string): Promise<boolean>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const PASSWORD_HASHER = Symbol('PasswordHasher')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EmailCode, EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
|
|
2
|
+
|
|
3
|
+
export interface EmailCodeRepository {
|
|
4
|
+
// Reads the row and holds it until the surrounding transaction ends, so two guesses cannot both spend one attempt.
|
|
5
|
+
lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null>
|
|
6
|
+
save(code: EmailCode): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const EMAIL_CODE_REPOSITORY = Symbol('EmailCodeRepository')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Identity } from '@/domain/auth/entities/identity.entity'
|
|
2
|
+
import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
|
|
3
|
+
|
|
4
|
+
export interface IdentityRepository {
|
|
5
|
+
findBySubject(provider: ProviderName, subject: string): Promise<Identity | null>
|
|
6
|
+
save(identity: Identity): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const IDENTITY_REPOSITORY = Symbol('IdentityRepository')
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Session } from '@/domain/auth/entities/session.entity'
|
|
2
|
+
|
|
3
|
+
export interface SessionRepository {
|
|
4
|
+
findById(id: string): Promise<Session | null>
|
|
5
|
+
// Reads the row and holds it until the surrounding transaction ends, so two rotations cannot interleave.
|
|
6
|
+
lockById(id: string): Promise<Session | null>
|
|
7
|
+
save(session: Session): Promise<void>
|
|
8
|
+
// Every session of the user, at once: a new password ends whatever the old one opened.
|
|
9
|
+
revokeAllFor(userId: string, now: Date): Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const SESSION_REPOSITORY = Symbol('SessionRepository')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
2
|
+
|
|
3
|
+
export interface SocialSignInRepository {
|
|
4
|
+
// Reads the row and holds it until the surrounding transaction ends, so a callback or a code is used once.
|
|
5
|
+
lockById(id: string): Promise<SocialSignIn | null>
|
|
6
|
+
save(signIn: SocialSignIn): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const SOCIAL_SIGN_IN_REPOSITORY = Symbol('SocialSignInRepository')
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { User } from '@/domain/auth/entities/user.entity'
|
|
2
|
+
|
|
3
|
+
export interface UserRepository {
|
|
4
|
+
findById(id: string): Promise<User | null>
|
|
5
|
+
findByEmail(email: string): Promise<User | null>
|
|
6
|
+
save(user: User): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const USER_REPOSITORY = Symbol('UserRepository')
|