@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,73 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { ResetPasswordDto } from '@/domain/auth/dto/reset-password.dto'
|
|
3
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
|
|
5
|
+
import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
|
|
6
|
+
import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
7
|
+
import {
|
|
8
|
+
EMAIL_CODE_REPOSITORY,
|
|
9
|
+
type EmailCodeRepository,
|
|
10
|
+
} from '@/domain/auth/repositories/email-code.repository'
|
|
11
|
+
import {
|
|
12
|
+
SESSION_REPOSITORY,
|
|
13
|
+
type SessionRepository,
|
|
14
|
+
} from '@/domain/auth/repositories/session.repository'
|
|
15
|
+
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
16
|
+
import {
|
|
17
|
+
type SessionTokens,
|
|
18
|
+
StartSessionUseCase,
|
|
19
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
20
|
+
import {
|
|
21
|
+
TRANSACTION_MANAGER,
|
|
22
|
+
type TransactionManager,
|
|
23
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
24
|
+
|
|
25
|
+
@injectable()
|
|
26
|
+
export class ResetPasswordUseCase {
|
|
27
|
+
constructor(
|
|
28
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
29
|
+
@inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
|
|
30
|
+
@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
|
|
31
|
+
@inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
|
|
32
|
+
@inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
|
|
33
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
34
|
+
private readonly startSession: StartSessionUseCase,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
async execute({ email, code, password }: ResetPasswordDto): Promise<SessionTokens> {
|
|
38
|
+
const passwordHash = await this.hasher.hash(password)
|
|
39
|
+
|
|
40
|
+
const reset = await this.transactions.run(async () => {
|
|
41
|
+
const now = new Date()
|
|
42
|
+
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
43
|
+
const stored = user === null ? null : await this.codes.lockFor(user.id, 'reset-password')
|
|
44
|
+
|
|
45
|
+
if (user === null || stored === null) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const outcome = stored.check(this.oneTimeCodes.digest(code), now)
|
|
50
|
+
|
|
51
|
+
await this.codes.save(stored)
|
|
52
|
+
|
|
53
|
+
if (outcome !== 'valid') {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The code arrived in the inbox, which is the proof verification asks for.
|
|
58
|
+
user.changePassword(passwordHash)
|
|
59
|
+
user.verifyEmail(now)
|
|
60
|
+
await this.users.save(user)
|
|
61
|
+
// Whoever knew the old password may still hold a session it opened.
|
|
62
|
+
await this.sessions.revokeAllFor(user.id, now)
|
|
63
|
+
|
|
64
|
+
return user
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (reset === null) {
|
|
68
|
+
throw new InvalidCodeError()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return this.startSession.execute(reset.id)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { inject, injectable } from 'tsyringe'
|
|
2
2
|
import type { SignInDto } from '@/domain/auth/dto/sign-in.dto'
|
|
3
3
|
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { EmailNotVerifiedError } from '@/domain/auth/errors/email-not-verified.error' // prumo:email
|
|
4
5
|
import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
|
|
5
6
|
import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
6
7
|
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
@@ -19,12 +20,19 @@ export class SignInUseCase {
|
|
|
19
20
|
|
|
20
21
|
async execute({ email, password }: SignInDto): Promise<SessionTokens> {
|
|
21
22
|
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
22
|
-
const valid = await this.hasher.verify(user?.passwordHash, password)
|
|
23
|
+
const valid = await this.hasher.verify(user?.passwordHash ?? undefined, password)
|
|
23
24
|
|
|
24
25
|
if (user === null || !valid) {
|
|
25
26
|
throw new InvalidCredentialsError()
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// prumo:email
|
|
30
|
+
// Asked only after the password matched, so the answer says nothing to someone who does not know it.
|
|
31
|
+
if (user.emailVerifiedAt === null) {
|
|
32
|
+
throw new EmailNotVerifiedError()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// prumo:end-email
|
|
28
36
|
return this.startSession.execute(user.id)
|
|
29
37
|
}
|
|
30
38
|
}
|
|
@@ -4,6 +4,7 @@ import { User } from '@/domain/auth/entities/user.entity'
|
|
|
4
4
|
import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
|
|
5
5
|
import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
|
|
6
6
|
import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
7
|
+
import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case' // prumo:email
|
|
7
8
|
import {
|
|
8
9
|
type SessionTokens,
|
|
9
10
|
StartSessionUseCase,
|
|
@@ -22,9 +23,11 @@ export class SignUpUseCase {
|
|
|
22
23
|
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
23
24
|
private readonly createProfile: CreateProfileUseCase,
|
|
24
25
|
private readonly startSession: StartSessionUseCase,
|
|
26
|
+
private readonly issueEmailCode: IssueEmailCodeUseCase, // prumo:email
|
|
25
27
|
) {}
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
// Null when the account must confirm its email before it may sign in.
|
|
30
|
+
async execute({ name, email, password }: SignUpDto): Promise<SessionTokens | null> {
|
|
28
31
|
const passwordHash = await this.hasher.hash(password)
|
|
29
32
|
|
|
30
33
|
// The account and its profile exist together or not at all.
|
|
@@ -41,6 +44,14 @@ export class SignUpUseCase {
|
|
|
41
44
|
return user
|
|
42
45
|
})
|
|
43
46
|
|
|
47
|
+
// prumo:email
|
|
48
|
+
// Until the address is confirmed the account cannot sign in, so it opens no session either.
|
|
49
|
+
if (user.emailVerifiedAt === null) {
|
|
50
|
+
await this.issueEmailCode.execute(user, 'verify-email')
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// prumo:end-email
|
|
44
55
|
return this.startSession.execute(user.id)
|
|
45
56
|
}
|
|
46
57
|
}
|
|
@@ -17,6 +17,8 @@ export type SessionTokens = {
|
|
|
17
17
|
accessToken: IssuedToken
|
|
18
18
|
refreshToken: string
|
|
19
19
|
refreshExpiresAt: Date
|
|
20
|
+
// What an OAuth client's tokens may do; null for the application's own sessions, which may do everything.
|
|
21
|
+
scope: string | null
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
@injectable()
|
|
@@ -37,12 +39,17 @@ export class StartSessionUseCase {
|
|
|
37
39
|
return {
|
|
38
40
|
sessionId: session.id,
|
|
39
41
|
accessToken: await this.tokens.issue(
|
|
40
|
-
{
|
|
42
|
+
{
|
|
43
|
+
userId,
|
|
44
|
+
sessionId: session.id,
|
|
45
|
+
...(grant && { clientId: grant.clientId, scope: grant.scope }),
|
|
46
|
+
},
|
|
41
47
|
new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
|
|
42
48
|
grant?.resource,
|
|
43
49
|
),
|
|
44
50
|
refreshToken: session.refreshToken(secret),
|
|
45
51
|
refreshExpiresAt: session.expiresAt,
|
|
52
|
+
scope: session.scope,
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import { type SocialClient, SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
3
|
+
import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.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
|
+
|
|
15
|
+
export type SocialStart = { provider: ProviderName; client: SocialClient; returnTo: string }
|
|
16
|
+
|
|
17
|
+
@injectable()
|
|
18
|
+
export class StartSocialSignInUseCase {
|
|
19
|
+
constructor(
|
|
20
|
+
@inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
|
|
21
|
+
@inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
|
|
22
|
+
@inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
// Returns where to send the browser, and the token the browser keeps to prove, on the way back, that it started.
|
|
26
|
+
async execute({
|
|
27
|
+
provider,
|
|
28
|
+
client,
|
|
29
|
+
returnTo,
|
|
30
|
+
}: SocialStart): Promise<{ url: string; browserToken: string }> {
|
|
31
|
+
if (!this.providers.isConfigured(provider)) {
|
|
32
|
+
throw new ProviderNotConfiguredError(provider)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const secret = this.opaque.create()
|
|
36
|
+
const signIn = new SocialSignIn(
|
|
37
|
+
provider,
|
|
38
|
+
client,
|
|
39
|
+
returnTo,
|
|
40
|
+
this.opaque.digest(secret),
|
|
41
|
+
new Date(),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Saved first: its id is the state the provider sends back.
|
|
45
|
+
await this.signIns.save(signIn)
|
|
46
|
+
|
|
47
|
+
const redirect = await this.providers.redirect(provider, signIn.id)
|
|
48
|
+
|
|
49
|
+
signIn.prepare(redirect.codeVerifier, redirect.nonce)
|
|
50
|
+
await this.signIns.save(signIn)
|
|
51
|
+
|
|
52
|
+
return { url: redirect.url, browserToken: signIn.token(secret) }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { inject, injectable } from 'tsyringe'
|
|
2
|
+
import type { VerifyEmailDto } from '@/domain/auth/dto/verify-email.dto'
|
|
3
|
+
import { User } from '@/domain/auth/entities/user.entity'
|
|
4
|
+
import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
|
|
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 { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
|
|
11
|
+
import {
|
|
12
|
+
type SessionTokens,
|
|
13
|
+
StartSessionUseCase,
|
|
14
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
15
|
+
import {
|
|
16
|
+
TRANSACTION_MANAGER,
|
|
17
|
+
type TransactionManager,
|
|
18
|
+
} from '@/domain/shared/transactions/transaction-manager'
|
|
19
|
+
|
|
20
|
+
@injectable()
|
|
21
|
+
export class VerifyEmailUseCase {
|
|
22
|
+
constructor(
|
|
23
|
+
@inject(USER_REPOSITORY) private readonly users: UserRepository,
|
|
24
|
+
@inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
|
|
25
|
+
@inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
|
|
26
|
+
@inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
|
|
27
|
+
private readonly startSession: StartSessionUseCase,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
// A confirmed address signs the user in: they proved the inbox and already chose a password.
|
|
31
|
+
async execute({ email, code }: VerifyEmailDto): Promise<SessionTokens> {
|
|
32
|
+
// The spent attempt is committed before the error is thrown, or a wrong guess would cost nothing.
|
|
33
|
+
const verified = await this.transactions.run(async () => {
|
|
34
|
+
const now = new Date()
|
|
35
|
+
const user = await this.users.findByEmail(User.normalizeEmail(email))
|
|
36
|
+
const stored = user === null ? null : await this.codes.lockFor(user.id, 'verify-email')
|
|
37
|
+
|
|
38
|
+
if (user === null || stored === null) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const outcome = stored.check(this.oneTimeCodes.digest(code), now)
|
|
43
|
+
|
|
44
|
+
await this.codes.save(stored)
|
|
45
|
+
|
|
46
|
+
if (outcome !== 'valid') {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
user.verifyEmail(now)
|
|
51
|
+
await this.users.save(user)
|
|
52
|
+
|
|
53
|
+
return user
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (verified === null) {
|
|
57
|
+
throw new InvalidCodeError()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return this.startSession.execute(verified.id)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -8,6 +8,7 @@ export const authorizationViewSchema = z.object({
|
|
|
8
8
|
redirectHost: z.string(),
|
|
9
9
|
// A localhost redirect cannot prove which program is listening there, so the page warns about it.
|
|
10
10
|
redirectsToThisDevice: z.boolean(),
|
|
11
|
+
scopes: z.array(z.object({ scope: z.string(), description: z.string() })),
|
|
11
12
|
})
|
|
12
13
|
|
|
13
14
|
export type AuthorizationViewDto = z.infer<typeof authorizationViewSchema>
|
|
@@ -13,6 +13,8 @@ export type AuthorizationRequest = {
|
|
|
13
13
|
codeChallenge: string
|
|
14
14
|
clientState: string | null
|
|
15
15
|
resource: string
|
|
16
|
+
// The scopes the client asked for and the user is shown, space-separated.
|
|
17
|
+
scope: string
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
// One OAuth authorization, from the client's request through the user's answer to the code it is exchanged for.
|
|
@@ -24,6 +26,7 @@ export class Authorization {
|
|
|
24
26
|
codeChallenge: string
|
|
25
27
|
clientState: string | null
|
|
26
28
|
resource: string
|
|
29
|
+
scope: string
|
|
27
30
|
status: AuthorizationStatus = 'pending'
|
|
28
31
|
userId: string | null = null
|
|
29
32
|
codeHash: string | null = null
|
|
@@ -39,6 +42,7 @@ export class Authorization {
|
|
|
39
42
|
this.codeChallenge = request.codeChallenge
|
|
40
43
|
this.clientState = request.clientState
|
|
41
44
|
this.resource = request.resource
|
|
45
|
+
this.scope = request.scope
|
|
42
46
|
this.expiresAt = new Date(now.getTime() + AUTHORIZATION_REQUEST_LIFETIME_MS)
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -6,7 +6,11 @@ export class AuthorizationRequestError extends DomainError {
|
|
|
6
6
|
readonly kind = 'invalid'
|
|
7
7
|
|
|
8
8
|
constructor(
|
|
9
|
-
readonly code:
|
|
9
|
+
readonly code:
|
|
10
|
+
| 'invalid_request'
|
|
11
|
+
| 'unsupported_response_type'
|
|
12
|
+
| 'invalid_target'
|
|
13
|
+
| 'invalid_scope',
|
|
10
14
|
message: string,
|
|
11
15
|
) {
|
|
12
16
|
super(message)
|
|
@@ -78,6 +78,7 @@ export class ExchangeCodeUseCase {
|
|
|
78
78
|
const tokens = await this.startSession.execute(authorization.userId, {
|
|
79
79
|
clientId: authorization.clientId,
|
|
80
80
|
resource: authorization.resource,
|
|
81
|
+
scope: authorization.scope,
|
|
81
82
|
})
|
|
82
83
|
|
|
83
84
|
authorization.exchange(tokens.sessionId)
|
|
@@ -22,6 +22,7 @@ export type AuthorizeRequest = {
|
|
|
22
22
|
codeChallengeMethod: string | undefined
|
|
23
23
|
clientState: string | null
|
|
24
24
|
resource: string | undefined
|
|
25
|
+
scope: string | undefined
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
@injectable()
|
|
@@ -32,7 +33,11 @@ export class StartAuthorizationUseCase {
|
|
|
32
33
|
) {}
|
|
33
34
|
|
|
34
35
|
// The client and its redirect URI are checked first: until both are, no error may be sent anywhere.
|
|
35
|
-
async execute(
|
|
36
|
+
async execute(
|
|
37
|
+
request: AuthorizeRequest,
|
|
38
|
+
expectedResource: string,
|
|
39
|
+
supportedScopes: readonly string[],
|
|
40
|
+
): Promise<Authorization> {
|
|
36
41
|
const client = await this.clients.read(request.clientId)
|
|
37
42
|
|
|
38
43
|
// Exact match only: a prefix or a pattern is how an authorization code ends up somewhere else.
|
|
@@ -56,6 +61,14 @@ export class StartAuthorizationUseCase {
|
|
|
56
61
|
throw new AuthorizationRequestError('invalid_target', `resource must be ${expectedResource}`)
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
// A client that asks for nothing gets everything this server offers, and the consent page says so.
|
|
65
|
+
const requested = (request.scope ?? '').split(' ').filter(Boolean)
|
|
66
|
+
const unknown = requested.filter((scope) => !supportedScopes.includes(scope))
|
|
67
|
+
|
|
68
|
+
if (unknown.length > 0) {
|
|
69
|
+
throw new AuthorizationRequestError('invalid_scope', `Unknown scope: ${unknown.join(' ')}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
const authorization = new Authorization(
|
|
60
73
|
{
|
|
61
74
|
clientId: request.clientId,
|
|
@@ -64,6 +77,7 @@ export class StartAuthorizationUseCase {
|
|
|
64
77
|
codeChallenge: request.codeChallenge as string,
|
|
65
78
|
clientState: request.clientState,
|
|
66
79
|
resource: expectedResource,
|
|
80
|
+
scope: (requested.length > 0 ? requested : supportedScopes).join(' '),
|
|
67
81
|
},
|
|
68
82
|
new Date(),
|
|
69
83
|
)
|
|
@@ -2,4 +2,6 @@ export type DomainErrorKind = 'not_found' | 'conflict' | 'invalid' | 'forbidden'
|
|
|
2
2
|
|
|
3
3
|
export abstract class DomainError extends Error {
|
|
4
4
|
abstract readonly kind: DomainErrorKind
|
|
5
|
+
// A stable name a client can act on, when the status alone does not say what to do next.
|
|
6
|
+
readonly code?: string
|
|
5
7
|
}
|
|
@@ -19,13 +19,14 @@ export class JoseAccessTokens implements AccessTokens {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async issue(
|
|
22
|
-
{ userId, sessionId, clientId }: AccessClaims,
|
|
22
|
+
{ userId, sessionId, clientId, scope }: AccessClaims,
|
|
23
23
|
expiresAt: Date,
|
|
24
24
|
audience = this.issuer,
|
|
25
25
|
): Promise<IssuedToken> {
|
|
26
26
|
const token = await new SignJWT({
|
|
27
27
|
sid: sessionId,
|
|
28
28
|
...(clientId !== undefined && { client_id: clientId }),
|
|
29
|
+
...(scope !== undefined && { scope }),
|
|
29
30
|
})
|
|
30
31
|
.setProtectedHeader({ alg: ALGORITHM })
|
|
31
32
|
.setSubject(userId)
|
|
@@ -54,6 +55,7 @@ export class JoseAccessTokens implements AccessTokens {
|
|
|
54
55
|
userId: payload.sub,
|
|
55
56
|
sessionId: payload.sid,
|
|
56
57
|
...(typeof payload.client_id === 'string' && { clientId: payload.client_id }),
|
|
58
|
+
...(typeof payload.scope === 'string' && { scope: payload.scope }),
|
|
57
59
|
}
|
|
58
60
|
} catch {
|
|
59
61
|
return null
|
|
@@ -9,6 +9,24 @@ const envSchema = z.object({
|
|
|
9
9
|
// Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
|
|
10
10
|
API_URL: z.url(),
|
|
11
11
|
WEB_ORIGIN: z.string().min(1),
|
|
12
|
+
// prumo:social
|
|
13
|
+
// Where a mobile app is sent back after signing in with a provider: its own scheme, as in app.json.
|
|
14
|
+
MOBILE_APP_SCHEME: z
|
|
15
|
+
.string()
|
|
16
|
+
.regex(/^[a-z][a-z0-9+.-]*$/)
|
|
17
|
+
.optional(),
|
|
18
|
+
// prumo:end-social
|
|
19
|
+
// prumo:google
|
|
20
|
+
GOOGLE_CLIENT_ID: z.string().min(1).optional(),
|
|
21
|
+
GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
|
|
22
|
+
// prumo:end-google
|
|
23
|
+
// prumo:apple
|
|
24
|
+
APPLE_CLIENT_ID: z.string().min(1).optional(),
|
|
25
|
+
APPLE_TEAM_ID: z.string().min(1).optional(),
|
|
26
|
+
APPLE_KEY_ID: z.string().min(1).optional(),
|
|
27
|
+
// The .p8 key's contents; on one line, with its line breaks written as \n.
|
|
28
|
+
APPLE_PRIVATE_KEY: z.string().min(1).optional(),
|
|
29
|
+
// prumo:end-apple
|
|
12
30
|
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']),
|
|
13
31
|
})
|
|
14
32
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { EntitySchema } from '@mikro-orm/core'
|
|
2
|
+
import { EmailCode } from '@/domain/auth/entities/email-code.entity'
|
|
3
|
+
|
|
4
|
+
export const EmailCodeSchema = new EntitySchema({
|
|
5
|
+
class: EmailCode,
|
|
6
|
+
uniques: [{ properties: ['userId', 'purpose'] }],
|
|
7
|
+
properties: {
|
|
8
|
+
id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
|
|
9
|
+
userId: {
|
|
10
|
+
kind: 'm:1',
|
|
11
|
+
// EntitySchema cannot type a to-one relation held as its primary key on a string property.
|
|
12
|
+
entity: () => 'User' as never,
|
|
13
|
+
mapToPk: true,
|
|
14
|
+
fieldName: 'user_id',
|
|
15
|
+
// A code means nothing without its user.
|
|
16
|
+
deleteRule: 'cascade',
|
|
17
|
+
},
|
|
18
|
+
purpose: { type: 'text' },
|
|
19
|
+
codeHash: { type: 'text' },
|
|
20
|
+
attemptsLeft: { type: 'integer' },
|
|
21
|
+
expiresAt: { type: 'datetime' },
|
|
22
|
+
createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
|
|
23
|
+
updatedAt: {
|
|
24
|
+
type: 'datetime',
|
|
25
|
+
onCreate: () => new Date(),
|
|
26
|
+
onUpdate: () => new Date(),
|
|
27
|
+
defaultRaw: 'now()',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EntitySchema } from '@mikro-orm/core'
|
|
2
|
+
import { Identity } from '@/domain/auth/entities/identity.entity'
|
|
3
|
+
|
|
4
|
+
export const IdentitySchema = new EntitySchema({
|
|
5
|
+
class: Identity,
|
|
6
|
+
uniques: [{ properties: ['provider', 'subject'] }],
|
|
7
|
+
properties: {
|
|
8
|
+
id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
|
|
9
|
+
userId: {
|
|
10
|
+
kind: 'm:1',
|
|
11
|
+
// EntitySchema cannot type a to-one relation held as its primary key on a string property.
|
|
12
|
+
entity: () => 'User' as never,
|
|
13
|
+
mapToPk: true,
|
|
14
|
+
fieldName: 'user_id',
|
|
15
|
+
index: true,
|
|
16
|
+
// An identity means nothing without its user.
|
|
17
|
+
deleteRule: 'cascade',
|
|
18
|
+
},
|
|
19
|
+
provider: { type: 'text' },
|
|
20
|
+
subject: { type: 'text' },
|
|
21
|
+
createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
|
|
22
|
+
updatedAt: {
|
|
23
|
+
type: 'datetime',
|
|
24
|
+
onCreate: () => new Date(),
|
|
25
|
+
onUpdate: () => new Date(),
|
|
26
|
+
defaultRaw: 'now()',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
})
|
|
@@ -18,6 +18,7 @@ export const SessionSchema = new EntitySchema({
|
|
|
18
18
|
tokenHash: { type: 'text' },
|
|
19
19
|
clientId: { type: 'text', nullable: true },
|
|
20
20
|
resource: { type: 'text', nullable: true },
|
|
21
|
+
scope: { type: 'text', nullable: true },
|
|
21
22
|
previousTokenHash: { type: 'text', nullable: true },
|
|
22
23
|
rotatedAt: { type: 'datetime', nullable: true },
|
|
23
24
|
expiresAt: { type: 'datetime' },
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EntitySchema } from '@mikro-orm/core'
|
|
2
|
+
import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
3
|
+
|
|
4
|
+
export const SocialSignInSchema = new EntitySchema({
|
|
5
|
+
class: SocialSignIn,
|
|
6
|
+
properties: {
|
|
7
|
+
id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
|
|
8
|
+
provider: { type: 'text' },
|
|
9
|
+
client: { type: 'text' },
|
|
10
|
+
returnTo: { type: 'text' },
|
|
11
|
+
browserHash: { type: 'text' },
|
|
12
|
+
codeVerifier: { type: 'text', nullable: true },
|
|
13
|
+
nonce: { type: 'text' },
|
|
14
|
+
status: { type: 'text' },
|
|
15
|
+
userId: {
|
|
16
|
+
kind: 'm:1',
|
|
17
|
+
// EntitySchema cannot type a to-one relation held as its primary key on a string property.
|
|
18
|
+
entity: () => 'User' as never,
|
|
19
|
+
mapToPk: true,
|
|
20
|
+
fieldName: 'user_id',
|
|
21
|
+
nullable: true,
|
|
22
|
+
index: true,
|
|
23
|
+
deleteRule: 'cascade',
|
|
24
|
+
},
|
|
25
|
+
exchangeHash: { type: 'text', nullable: true },
|
|
26
|
+
expiresAt: { type: 'datetime' },
|
|
27
|
+
createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
|
|
28
|
+
updatedAt: {
|
|
29
|
+
type: 'datetime',
|
|
30
|
+
onCreate: () => new Date(),
|
|
31
|
+
onUpdate: () => new Date(),
|
|
32
|
+
defaultRaw: 'now()',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -6,7 +6,8 @@ export const UserSchema = new EntitySchema({
|
|
|
6
6
|
properties: {
|
|
7
7
|
id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
|
|
8
8
|
email: { type: 'text', unique: true },
|
|
9
|
-
passwordHash: { type: 'text' },
|
|
9
|
+
passwordHash: { type: 'text', nullable: true },
|
|
10
|
+
emailVerifiedAt: { type: 'datetime', nullable: true },
|
|
10
11
|
createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
|
|
11
12
|
updatedAt: {
|
|
12
13
|
type: 'datetime',
|
|
@@ -2,8 +2,11 @@ import { Migrator } from '@mikro-orm/migrations'
|
|
|
2
2
|
import { defineConfig } from '@mikro-orm/postgresql'
|
|
3
3
|
import type { Env } from '@/infra/config/env'
|
|
4
4
|
import { AuthorizationSchema } from './entities/authorization.schema' // prumo:mcp
|
|
5
|
+
import { EmailCodeSchema } from './entities/email-code.schema' // prumo:email
|
|
6
|
+
import { IdentitySchema } from './entities/identity.schema' // prumo:social
|
|
5
7
|
import { ProfileSchema } from './entities/profile.schema'
|
|
6
8
|
import { SessionSchema } from './entities/session.schema'
|
|
9
|
+
import { SocialSignInSchema } from './entities/social-sign-in.schema' // prumo:social
|
|
7
10
|
import { UserSchema } from './entities/user.schema'
|
|
8
11
|
|
|
9
12
|
export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
|
|
@@ -14,6 +17,9 @@ export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
|
|
|
14
17
|
ProfileSchema,
|
|
15
18
|
SessionSchema,
|
|
16
19
|
AuthorizationSchema, // prumo:mcp
|
|
20
|
+
EmailCodeSchema, // prumo:email
|
|
21
|
+
IdentitySchema, // prumo:social
|
|
22
|
+
SocialSignInSchema, // prumo:social
|
|
17
23
|
],
|
|
18
24
|
extensions: [Migrator],
|
|
19
25
|
migrations: { path: './migrations', snapshot: false },
|
package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EntityManager, LockMode } from '@mikro-orm/postgresql'
|
|
2
|
+
import { injectable } from 'tsyringe'
|
|
3
|
+
import { EmailCode, type EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
|
|
4
|
+
import type { EmailCodeRepository } from '@/domain/auth/repositories/email-code.repository'
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
export class MikroOrmEmailCodeRepository implements EmailCodeRepository {
|
|
8
|
+
constructor(private readonly em: EntityManager) {}
|
|
9
|
+
|
|
10
|
+
lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null> {
|
|
11
|
+
return this.em.findOne(EmailCode, { userId, purpose }, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async save(code: EmailCode): Promise<void> {
|
|
15
|
+
await this.em.persist(code).flush()
|
|
16
|
+
}
|
|
17
|
+
}
|
package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { injectable } from 'tsyringe'
|
|
3
|
+
import { Identity } from '@/domain/auth/entities/identity.entity'
|
|
4
|
+
import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
|
|
5
|
+
import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
|
|
6
|
+
|
|
7
|
+
@injectable()
|
|
8
|
+
export class MikroOrmIdentityRepository implements IdentityRepository {
|
|
9
|
+
constructor(private readonly em: EntityManager) {}
|
|
10
|
+
|
|
11
|
+
findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
|
|
12
|
+
return this.em.findOne(Identity, { provider, subject })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async save(identity: Identity): Promise<void> {
|
|
16
|
+
await this.em.persist(identity).flush()
|
|
17
|
+
}
|
|
18
|
+
}
|
package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts
CHANGED
|
@@ -18,4 +18,8 @@ export class MikroOrmSessionRepository implements SessionRepository {
|
|
|
18
18
|
async save(session: Session): Promise<void> {
|
|
19
19
|
await this.em.persist(session).flush()
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
async revokeAllFor(userId: string, now: Date): Promise<void> {
|
|
23
|
+
await this.em.nativeUpdate(Session, { userId, revokedAt: null }, { revokedAt: now })
|
|
24
|
+
}
|
|
21
25
|
}
|
package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EntityManager, LockMode } from '@mikro-orm/postgresql'
|
|
2
|
+
import { injectable } from 'tsyringe'
|
|
3
|
+
import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
4
|
+
import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
export class MikroOrmSocialSignInRepository implements SocialSignInRepository {
|
|
8
|
+
constructor(private readonly em: EntityManager) {}
|
|
9
|
+
|
|
10
|
+
lockById(id: string): Promise<SocialSignIn | null> {
|
|
11
|
+
return this.em.findOne(SocialSignIn, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async save(signIn: SocialSignIn): Promise<void> {
|
|
15
|
+
await this.em.persist(signIn).flush()
|
|
16
|
+
}
|
|
17
|
+
}
|