@stjoseph/prumo 0.3.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 +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +6 -4
- package/dist/asker.js +13 -0
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -3
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +17 -3
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- 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 +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -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 +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -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/ports/access-tokens.port.ts +1 -1
- 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/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 +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.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/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 +2 -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 +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- 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-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -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/session.schema.ts +1 -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 +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -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 +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -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 +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- 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/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -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 +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- 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 +1 -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 +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- 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/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- 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/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 +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -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 +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- 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 +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- 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/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- package/dist/mcp.js +0 -107
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export class User {
|
|
2
2
|
id: string
|
|
3
3
|
email: string
|
|
4
|
-
|
|
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
|
|
5
7
|
createdAt: Date
|
|
6
8
|
updatedAt: Date
|
|
7
9
|
|
|
8
|
-
constructor(email: string, passwordHash: string) {
|
|
10
|
+
constructor(email: string, passwordHash: string | null) {
|
|
9
11
|
this.email = User.normalizeEmail(email)
|
|
10
12
|
this.passwordHash = passwordHash
|
|
11
13
|
}
|
|
@@ -14,4 +16,19 @@ export class User {
|
|
|
14
16
|
static normalizeEmail(email: string): string {
|
|
15
17
|
return email.trim().toLowerCase()
|
|
16
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
|
|
17
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,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,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,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')
|
|
@@ -5,6 +5,8 @@ export interface SessionRepository {
|
|
|
5
5
|
// Reads the row and holds it until the surrounding transaction ends, so two rotations cannot interleave.
|
|
6
6
|
lockById(id: string): Promise<Session | null>
|
|
7
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>
|
|
8
10
|
}
|
|
9
11
|
|
|
10
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,103 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
3
|
+
import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
|
|
4
|
+
import {
|
|
5
|
+
IDENTITY_PROVIDERS,
|
|
6
|
+
type IdentityProviders,
|
|
7
|
+
type ProviderName,
|
|
8
|
+
} from '@/domain/auth/ports/identity-providers.port'
|
|
9
|
+
import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
10
|
+
import {
|
|
11
|
+
SOCIAL_SIGN_IN_REPOSITORY,
|
|
12
|
+
type SocialSignInRepository,
|
|
13
|
+
} from '@/domain/auth/repositories/social-sign-in.repository'
|
|
14
|
+
import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
|
|
15
|
+
import {
|
|
16
|
+
type SessionTokens,
|
|
17
|
+
StartSessionUseCase,
|
|
18
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
19
|
+
import {
|
|
20
|
+
TRANSACTION_MANAGER,
|
|
21
|
+
type TransactionManager,
|
|
22
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
23
|
+
|
|
24
|
+
export type SocialCallback = {
|
|
25
|
+
provider: ProviderName
|
|
26
|
+
state: string
|
|
27
|
+
browserToken: string | undefined
|
|
28
|
+
callbackUrl: URL
|
|
29
|
+
name: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SocialOutcome =
|
|
33
|
+
| { client: 'web'; returnTo: string; tokens: SessionTokens }
|
|
34
|
+
| { client: 'mobile'; code: string }
|
|
35
|
+
|
|
36
|
+
@injectable()
|
|
37
|
+
export class CompleteSocialSignInUseCase {
|
|
38
|
+
constructor(
|
|
39
|
+
@inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
|
|
40
|
+
@inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
|
|
41
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
42
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
43
|
+
private readonly linkIdentity: LinkIdentityUseCase,
|
|
44
|
+
private readonly startSession: StartSessionUseCase,
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
async execute(callback: SocialCallback): Promise<SocialOutcome> {
|
|
48
|
+
const browser =
|
|
49
|
+
callback.browserToken === undefined ? null : SocialSignIn.parseToken(callback.browserToken)
|
|
50
|
+
|
|
51
|
+
// The browser that comes back must be the one that left: a callback forwarded from elsewhere carries no token.
|
|
52
|
+
if (browser === null || browser.id !== callback.state) {
|
|
53
|
+
throw new SocialSignInFailedError('The sign-in was started in another browser. Start again.')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const started = await this.transactions.run(async () => {
|
|
57
|
+
const signIn = await this.signIns.lockById(browser.id)
|
|
58
|
+
|
|
59
|
+
return signIn !== null &&
|
|
60
|
+
signIn.provider === callback.provider &&
|
|
61
|
+
signIn.isStarted(new Date()) &&
|
|
62
|
+
signIn.browserHash === this.opaque.digest(browser.secret)
|
|
63
|
+
? signIn
|
|
64
|
+
: null
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (started === null) {
|
|
68
|
+
throw new SocialSignInFailedError()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const profile = await this.providers.profile(callback.provider, {
|
|
72
|
+
callbackUrl: callback.callbackUrl,
|
|
73
|
+
state: started.id,
|
|
74
|
+
codeVerifier: started.codeVerifier,
|
|
75
|
+
nonce: started.nonce,
|
|
76
|
+
name: callback.name,
|
|
77
|
+
})
|
|
78
|
+
const userId = await this.linkIdentity.execute(callback.provider, profile)
|
|
79
|
+
const exchange = started.client === 'mobile' ? this.opaque.create() : null
|
|
80
|
+
|
|
81
|
+
await this.transactions.run(async () => {
|
|
82
|
+
const signIn = await this.signIns.lockById(started.id)
|
|
83
|
+
|
|
84
|
+
// Another callback for the same trip got here first.
|
|
85
|
+
if (signIn === null || !signIn.isStarted(new Date())) {
|
|
86
|
+
throw new SocialSignInFailedError()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
signIn.signIn(userId, exchange === null ? null : this.opaque.digest(exchange), new Date())
|
|
90
|
+
await this.signIns.save(signIn)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (exchange !== null) {
|
|
94
|
+
return { client: 'mobile', code: started.token(exchange) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
client: 'web',
|
|
99
|
+
returnTo: started.returnTo,
|
|
100
|
+
tokens: await this.startSession.execute(userId),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
3
|
+
import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
|
|
4
|
+
import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
|
|
5
|
+
import {
|
|
6
|
+
SOCIAL_SIGN_IN_REPOSITORY,
|
|
7
|
+
type SocialSignInRepository,
|
|
8
|
+
} from '@/domain/auth/repositories/social-sign-in.repository'
|
|
9
|
+
import {
|
|
10
|
+
type SessionTokens,
|
|
11
|
+
StartSessionUseCase,
|
|
12
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
13
|
+
import {
|
|
14
|
+
TRANSACTION_MANAGER,
|
|
15
|
+
type TransactionManager,
|
|
16
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
17
|
+
|
|
18
|
+
// The mobile app's half: the browser brought back a code, the app trades it once for its own tokens.
|
|
19
|
+
@injectable()
|
|
20
|
+
export class ExchangeSocialCodeUseCase {
|
|
21
|
+
constructor(
|
|
22
|
+
@inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
|
|
23
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
24
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
25
|
+
private readonly startSession: StartSessionUseCase,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async execute(code: string): Promise<SessionTokens> {
|
|
29
|
+
const parsed = SocialSignIn.parseToken(code)
|
|
30
|
+
|
|
31
|
+
const userId = await this.transactions.run(async () => {
|
|
32
|
+
const signIn = parsed === null ? null : await this.signIns.lockById(parsed.id)
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
parsed === null ||
|
|
36
|
+
signIn === null ||
|
|
37
|
+
signIn.userId === null ||
|
|
38
|
+
!signIn.isExchangeable(new Date()) ||
|
|
39
|
+
signIn.exchangeHash !== this.opaque.digest(parsed.secret)
|
|
40
|
+
) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
signIn.exchange()
|
|
45
|
+
await this.signIns.save(signIn)
|
|
46
|
+
|
|
47
|
+
return signIn.userId
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (userId === null) {
|
|
51
|
+
throw new SocialSignInFailedError()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return this.startSession.execute(userId)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { EmailCode, type EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
|
|
3
|
+
import type { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { MAILER, type Mail, type Mailer } from '@/domain/auth/ports/mailer.port'
|
|
5
|
+
import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
|
|
6
|
+
import {
|
|
7
|
+
EMAIL_CODE_REPOSITORY,
|
|
8
|
+
type EmailCodeRepository,
|
|
9
|
+
} from '@/domain/auth/repositories/email-code.repository'
|
|
10
|
+
import {
|
|
11
|
+
TRANSACTION_MANAGER,
|
|
12
|
+
type TransactionManager,
|
|
13
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
14
|
+
|
|
15
|
+
const MAILS: Record<EmailCodePurpose, (to: string, code: string) => Mail> = {
|
|
16
|
+
'verify-email': (to, code) => ({
|
|
17
|
+
to,
|
|
18
|
+
subject: `${code} is your verification code`,
|
|
19
|
+
text: `Enter ${code} to confirm this email address. The code expires in 15 minutes.`,
|
|
20
|
+
}),
|
|
21
|
+
'reset-password': (to, code) => ({
|
|
22
|
+
to,
|
|
23
|
+
subject: `${code} is your password reset code`,
|
|
24
|
+
text: `Enter ${code} to choose a new password. The code expires in 15 minutes. If you did not ask for it, ignore this email: your password stays as it is.`,
|
|
25
|
+
}),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@injectable()
|
|
29
|
+
export class IssueEmailCodeUseCase {
|
|
30
|
+
constructor(
|
|
31
|
+
@inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
|
|
32
|
+
@inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
|
|
33
|
+
@inject(MAILER) private readonly mailer: Mailer,
|
|
34
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
async execute(user: User, purpose: EmailCodePurpose): Promise<void> {
|
|
38
|
+
const code = this.oneTimeCodes.create()
|
|
39
|
+
const codeHash = this.oneTimeCodes.digest(code)
|
|
40
|
+
|
|
41
|
+
await this.transactions.run(async () => {
|
|
42
|
+
const now = new Date()
|
|
43
|
+
const existing = await this.codes.lockFor(user.id, purpose)
|
|
44
|
+
|
|
45
|
+
if (existing === null) {
|
|
46
|
+
await this.codes.save(new EmailCode(user.id, purpose, codeHash, now))
|
|
47
|
+
} else {
|
|
48
|
+
existing.renew(codeHash, now)
|
|
49
|
+
await this.codes.save(existing)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await this.mailer.send(MAILS[purpose](user.email, code))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { Identity } from '@/domain/auth/entities/identity.entity'
|
|
3
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { ProviderEmailUnverifiedError } from '@/domain/auth/errors/provider-email-unverified.error'
|
|
5
|
+
import type { ProviderName, ProviderProfile } from '@/domain/auth/ports/identity-providers.port'
|
|
6
|
+
import {
|
|
7
|
+
IDENTITY_REPOSITORY,
|
|
8
|
+
type IdentityRepository,
|
|
9
|
+
} from '@/domain/auth/repositories/identity.repository'
|
|
10
|
+
import {
|
|
11
|
+
SESSION_REPOSITORY,
|
|
12
|
+
type SessionRepository,
|
|
13
|
+
} from '@/domain/auth/repositories/session.repository'
|
|
14
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
15
|
+
import {
|
|
16
|
+
TRANSACTION_MANAGER,
|
|
17
|
+
type TransactionManager,
|
|
18
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
19
|
+
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
20
|
+
|
|
21
|
+
const DISPLAY_NAME_MAX = 80
|
|
22
|
+
|
|
23
|
+
@injectable()
|
|
24
|
+
export class LinkIdentityUseCase {
|
|
25
|
+
constructor(
|
|
26
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
27
|
+
@inject(IDENTITY_REPOSITORY) private readonly identities: IdentityRepository,
|
|
28
|
+
@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
|
|
29
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
30
|
+
private readonly createProfile: CreateProfileUseCase,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
// Returns the id of the user the provider's account belongs to, creating or linking it as the rules say.
|
|
34
|
+
execute(provider: ProviderName, profile: ProviderProfile): Promise<string> {
|
|
35
|
+
return this.transactions.run(async () => {
|
|
36
|
+
const known = await this.identities.findBySubject(provider, profile.subject)
|
|
37
|
+
|
|
38
|
+
if (known !== null) {
|
|
39
|
+
return known.userId
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!profile.emailVerified) {
|
|
43
|
+
throw new ProviderEmailUnverifiedError()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const now = new Date()
|
|
47
|
+
const existing = await this.users.findByEmail(User.normalizeEmail(profile.email))
|
|
48
|
+
|
|
49
|
+
if (existing !== null) {
|
|
50
|
+
// Nobody had shown this inbox was theirs, and the provider just did: whoever chose the password, possibly
|
|
51
|
+
// to wait for the real owner, loses it and every session it opened.
|
|
52
|
+
if (existing.emailVerifiedAt === null) {
|
|
53
|
+
existing.removePassword()
|
|
54
|
+
existing.verifyEmail(now)
|
|
55
|
+
await this.users.save(existing)
|
|
56
|
+
await this.sessions.revokeAllFor(existing.id, now)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await this.identities.save(new Identity(existing.id, provider, profile.subject))
|
|
60
|
+
|
|
61
|
+
return existing.id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const user = new User(profile.email, null)
|
|
65
|
+
|
|
66
|
+
user.verifyEmail(now)
|
|
67
|
+
await this.users.save(user)
|
|
68
|
+
await this.createProfile.execute(
|
|
69
|
+
user.id,
|
|
70
|
+
(profile.name ?? profile.email.split('@')[0] ?? profile.email).slice(0, DISPLAY_NAME_MAX),
|
|
71
|
+
)
|
|
72
|
+
await this.identities.save(new Identity(user.id, provider, profile.subject))
|
|
73
|
+
|
|
74
|
+
return user.id
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -81,12 +81,14 @@ export class RefreshSessionUseCase {
|
|
|
81
81
|
userId: session.userId,
|
|
82
82
|
sessionId: session.id,
|
|
83
83
|
...(session.clientId !== null && { clientId: session.clientId }),
|
|
84
|
+
...(session.scope !== null && { scope: session.scope }),
|
|
84
85
|
},
|
|
85
86
|
new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
|
|
86
87
|
session.resource ?? undefined,
|
|
87
88
|
),
|
|
88
89
|
refreshToken: session.refreshToken(next),
|
|
89
90
|
refreshExpiresAt: session.expiresAt,
|
|
91
|
+
scope: session.scope,
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
3
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
4
|
+
import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case'
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
export class RequestEmailVerificationUseCase {
|
|
8
|
+
constructor(
|
|
9
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
10
|
+
private readonly issueEmailCode: IssueEmailCodeUseCase,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
// Answers the same whether the account exists, is verified, or neither: the caller learns nothing about who signed up.
|
|
14
|
+
async execute(email: string): Promise<void> {
|
|
15
|
+
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
16
|
+
|
|
17
|
+
if (user !== null && user.emailVerifiedAt === null) {
|
|
18
|
+
await this.issueEmailCode.execute(user, 'verify-email')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
3
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
4
|
+
import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case'
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
export class RequestPasswordResetUseCase {
|
|
8
|
+
constructor(
|
|
9
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
10
|
+
private readonly issueEmailCode: IssueEmailCodeUseCase,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
// Answers the same for an unknown email: the caller learns nothing about who has an account.
|
|
14
|
+
async execute(email: string): Promise<void> {
|
|
15
|
+
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
16
|
+
|
|
17
|
+
if (user !== null) {
|
|
18
|
+
await this.issueEmailCode.execute(user, 'reset-password')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|