@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,69 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
2
|
+
import type { TokensResponseDto } from '@/domain/auth/dto/tokens-response.dto'
|
|
3
|
+
import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
|
|
4
|
+
import type { Env } from '@/infra/config/env'
|
|
5
|
+
import { ACCESS_COOKIE } from '@/infra/http/hooks/auth.hook'
|
|
6
|
+
|
|
7
|
+
export const REFRESH_COOKIE = 'refresh_token'
|
|
8
|
+
|
|
9
|
+
// The refresh cookie travels only to the one route that reads it, never with an ordinary request.
|
|
10
|
+
const REFRESH_PATH = '/api/auth/refresh'
|
|
11
|
+
|
|
12
|
+
// A native client asks for its tokens in the body. The web never does: a token JavaScript can read is a token an
|
|
13
|
+
// injected script can take.
|
|
14
|
+
export function wantsBearer(request: FastifyRequest): boolean {
|
|
15
|
+
return request.headers['x-auth-transport'] === 'bearer'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function asBody(tokens: SessionTokens): TokensResponseDto {
|
|
19
|
+
return {
|
|
20
|
+
accessToken: tokens.accessToken.token,
|
|
21
|
+
refreshToken: tokens.refreshToken,
|
|
22
|
+
expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// How a started session reaches the client, wherever it was started: sign-in, a confirmed email, a provider.
|
|
27
|
+
export function sessionDelivery(env: Pick<Env, 'API_URL'>) {
|
|
28
|
+
const cookie = {
|
|
29
|
+
httpOnly: true,
|
|
30
|
+
sameSite: 'lax',
|
|
31
|
+
secure: env.API_URL.startsWith('https://'),
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
function clearCookies(reply: FastifyReply): void {
|
|
35
|
+
reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
|
|
36
|
+
reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
|
|
40
|
+
reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
|
|
41
|
+
...cookie,
|
|
42
|
+
path: '/',
|
|
43
|
+
expires: tokens.accessToken.expiresAt,
|
|
44
|
+
})
|
|
45
|
+
reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
|
|
46
|
+
...cookie,
|
|
47
|
+
path: REFRESH_PATH,
|
|
48
|
+
expires: tokens.refreshExpiresAt,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The web gets cookies and an empty body; a native client gets the tokens and no cookie.
|
|
53
|
+
function deliver(
|
|
54
|
+
request: FastifyRequest,
|
|
55
|
+
reply: FastifyReply,
|
|
56
|
+
tokens: SessionTokens,
|
|
57
|
+
status: 200 | 201,
|
|
58
|
+
) {
|
|
59
|
+
if (wantsBearer(request)) {
|
|
60
|
+
return reply.code(status).send(asBody(tokens))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setCookies(reply, tokens)
|
|
64
|
+
|
|
65
|
+
return reply.code(status === 201 ? 201 : 204).send()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { deliver, setCookies, clearCookies }
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { USER_TOOL_SCOPES } from './tools/users.tool'
|
|
2
|
+
|
|
3
|
+
// Every scope this server offers, as the consent page words it. A module adds its read and write here, with its
|
|
4
|
+
// tools' scopes below.
|
|
5
|
+
export const SCOPES: Record<string, string> = {
|
|
6
|
+
'profile:read': 'Read your profile',
|
|
7
|
+
'profile:write': 'Change your display name, language and timezone',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TOOL_SCOPES: Record<string, string> = { ...USER_TOOL_SCOPES }
|
|
11
|
+
|
|
12
|
+
// The scopes a batch of JSON-RPC messages needs and the token lacks, all of them at once: MCP asks for one
|
|
13
|
+
// challenge per operation, not one per retry.
|
|
14
|
+
export function missingScopes(messages: unknown, granted: readonly string[]): string[] {
|
|
15
|
+
const needed = [messages]
|
|
16
|
+
.flat()
|
|
17
|
+
.flatMap((message) => {
|
|
18
|
+
const call = message as { method?: unknown; params?: { name?: unknown } } | null
|
|
19
|
+
|
|
20
|
+
return call?.method === 'tools/call' && typeof call.params?.name === 'string'
|
|
21
|
+
? [TOOL_SCOPES[call.params.name]]
|
|
22
|
+
: []
|
|
23
|
+
})
|
|
24
|
+
.filter((scope): scope is string => scope !== undefined)
|
|
25
|
+
|
|
26
|
+
return [...new Set(needed)].filter((scope) => !granted.includes(scope))
|
|
27
|
+
}
|
|
@@ -16,6 +16,12 @@ function asResult(profile: Profile) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// The scope each tool needs: reading a module's data is `<module>:read`, changing it `<module>:write`.
|
|
20
|
+
export const USER_TOOL_SCOPES = {
|
|
21
|
+
get_profile: 'profile:read',
|
|
22
|
+
update_profile: 'profile:write',
|
|
23
|
+
} as const
|
|
24
|
+
|
|
19
25
|
export function registerUserTools(
|
|
20
26
|
server: McpServer,
|
|
21
27
|
container: DependencyContainer,
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { importPKCS8, SignJWT } from 'jose'
|
|
2
|
+
import * as oidc from 'openid-client'
|
|
3
|
+
import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
|
|
4
|
+
import type {
|
|
5
|
+
IdentityProviders,
|
|
6
|
+
ProviderCallback,
|
|
7
|
+
ProviderName,
|
|
8
|
+
ProviderProfile,
|
|
9
|
+
ProviderRedirect,
|
|
10
|
+
} from '@/domain/auth/ports/identity-providers.port'
|
|
11
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
12
|
+
import type { Env } from '@/infra/config/env'
|
|
13
|
+
|
|
14
|
+
type Settings = {
|
|
15
|
+
issuer: URL
|
|
16
|
+
clientId: string
|
|
17
|
+
scope: string
|
|
18
|
+
// Apple answers with a POST when the email is asked for; Google redirects back with a GET.
|
|
19
|
+
formPost: boolean
|
|
20
|
+
secret: () => Promise<string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Options = {
|
|
24
|
+
// A test points a provider at a local issuer, over plain HTTP.
|
|
25
|
+
issuers?: Partial<Record<ProviderName, URL>>
|
|
26
|
+
insecure?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Apple accepts a client secret valid for up to six months; one hour keeps a leaked one short-lived.
|
|
30
|
+
const SECRET_LIFETIME_MS = 60 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
export class OpenIdIdentityProviders implements IdentityProviders {
|
|
33
|
+
private readonly configurations = new Map<
|
|
34
|
+
ProviderName,
|
|
35
|
+
{ configuration: oidc.Configuration; until: number }
|
|
36
|
+
>()
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly env: Env,
|
|
40
|
+
private readonly options: Options = {},
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
private settings(provider: ProviderName): Settings | null {
|
|
44
|
+
const { env } = this
|
|
45
|
+
const issuer = (fallback: string) => this.options.issuers?.[provider] ?? new URL(fallback)
|
|
46
|
+
|
|
47
|
+
switch (provider) {
|
|
48
|
+
// prumo:google
|
|
49
|
+
case 'google': {
|
|
50
|
+
const secret = env.GOOGLE_CLIENT_SECRET
|
|
51
|
+
|
|
52
|
+
return env.GOOGLE_CLIENT_ID === undefined || secret === undefined
|
|
53
|
+
? null
|
|
54
|
+
: {
|
|
55
|
+
issuer: issuer('https://accounts.google.com'),
|
|
56
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
57
|
+
scope: 'openid email profile',
|
|
58
|
+
formPost: false,
|
|
59
|
+
secret: async () => secret,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// prumo:end-google
|
|
63
|
+
// prumo:apple
|
|
64
|
+
case 'apple': {
|
|
65
|
+
const { APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY } = env
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
APPLE_CLIENT_ID === undefined ||
|
|
69
|
+
APPLE_TEAM_ID === undefined ||
|
|
70
|
+
APPLE_KEY_ID === undefined ||
|
|
71
|
+
APPLE_PRIVATE_KEY === undefined
|
|
72
|
+
) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
issuer: issuer('https://appleid.apple.com'),
|
|
78
|
+
clientId: APPLE_CLIENT_ID,
|
|
79
|
+
scope: 'openid email name',
|
|
80
|
+
formPost: true,
|
|
81
|
+
// Apple's client secret is a JWT signed with the team's .p8 key, not a string it hands out.
|
|
82
|
+
secret: async () =>
|
|
83
|
+
new SignJWT({})
|
|
84
|
+
.setProtectedHeader({ alg: 'ES256', kid: APPLE_KEY_ID })
|
|
85
|
+
.setIssuer(APPLE_TEAM_ID)
|
|
86
|
+
.setSubject(APPLE_CLIENT_ID)
|
|
87
|
+
.setAudience('https://appleid.apple.com')
|
|
88
|
+
.setIssuedAt()
|
|
89
|
+
.setExpirationTime(Math.floor((Date.now() + SECRET_LIFETIME_MS) / 1000))
|
|
90
|
+
.sign(await importPKCS8(APPLE_PRIVATE_KEY.replaceAll('\\n', '\n'), 'ES256')),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// prumo:end-apple
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private redirectUri(provider: ProviderName): string {
|
|
100
|
+
return `${this.env.API_URL}/api/auth/social/${provider}/callback`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async configuration(provider: ProviderName, settings: Settings) {
|
|
104
|
+
const cached = this.configurations.get(provider)
|
|
105
|
+
|
|
106
|
+
if (cached !== undefined && cached.until > Date.now()) {
|
|
107
|
+
return cached.configuration
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const configuration = await oidc.discovery(
|
|
111
|
+
settings.issuer,
|
|
112
|
+
settings.clientId,
|
|
113
|
+
undefined,
|
|
114
|
+
oidc.ClientSecretPost(await settings.secret()),
|
|
115
|
+
{ execute: this.options.insecure === true ? [oidc.allowInsecureRequests] : [] },
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// Renewed before the secret it was built with expires.
|
|
119
|
+
this.configurations.set(provider, {
|
|
120
|
+
configuration,
|
|
121
|
+
until: Date.now() + SECRET_LIFETIME_MS / 2,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return configuration
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
isConfigured(provider: ProviderName): boolean {
|
|
128
|
+
return this.settings(provider) !== null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
|
|
132
|
+
const settings = this.settings(provider)
|
|
133
|
+
|
|
134
|
+
if (settings === null) {
|
|
135
|
+
throw new SocialSignInFailedError(`Sign-in with ${provider} is not configured`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const configuration = await this.configuration(provider, settings)
|
|
139
|
+
const nonce = oidc.randomNonce()
|
|
140
|
+
// PKCE where the provider says it takes it: Google does, Apple's metadata names none, and relies on state and nonce.
|
|
141
|
+
const codeVerifier = configuration.serverMetadata().supportsPKCE()
|
|
142
|
+
? oidc.randomPKCECodeVerifier()
|
|
143
|
+
: null
|
|
144
|
+
const url = oidc.buildAuthorizationUrl(configuration, {
|
|
145
|
+
redirect_uri: this.redirectUri(provider),
|
|
146
|
+
scope: settings.scope,
|
|
147
|
+
state,
|
|
148
|
+
nonce,
|
|
149
|
+
...(codeVerifier !== null && {
|
|
150
|
+
code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
|
|
151
|
+
code_challenge_method: 'S256',
|
|
152
|
+
}),
|
|
153
|
+
...(settings.formPost && { response_mode: 'form_post' }),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
return { url: url.href, codeVerifier, nonce }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async profile(provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
|
|
160
|
+
const settings = this.settings(provider)
|
|
161
|
+
|
|
162
|
+
if (settings === null) {
|
|
163
|
+
throw new SocialSignInFailedError(`Sign-in with ${provider} is not configured`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const tokens = await oidc.authorizationCodeGrant(
|
|
168
|
+
await this.configuration(provider, settings),
|
|
169
|
+
callback.callbackUrl,
|
|
170
|
+
{
|
|
171
|
+
expectedState: callback.state,
|
|
172
|
+
expectedNonce: callback.nonce,
|
|
173
|
+
idTokenExpected: true,
|
|
174
|
+
...(callback.codeVerifier !== null && { pkceCodeVerifier: callback.codeVerifier }),
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
// Read only after openid-client checked the ID token's signature, issuer, audience, expiry and nonce.
|
|
178
|
+
const claims = tokens.claims()
|
|
179
|
+
|
|
180
|
+
if (claims === undefined || typeof claims.email !== 'string') {
|
|
181
|
+
throw new SocialSignInFailedError('The provider did not share an email address')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
subject: claims.sub,
|
|
186
|
+
email: claims.email,
|
|
187
|
+
// Apple writes it as the string "true".
|
|
188
|
+
emailVerified: claims.email_verified === true || claims.email_verified === 'true',
|
|
189
|
+
name: typeof claims.name === 'string' ? claims.name : callback.name,
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof DomainError) {
|
|
193
|
+
throw error
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new SocialSignInFailedError()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { authFakes } from '@test/support/fakes/auth-fakes'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { EMAIL_CODE_ATTEMPTS } from '@/domain/auth/entities/email-code.entity'
|
|
4
|
+
import { EmailNotVerifiedError } from '@/domain/auth/errors/email-not-verified.error'
|
|
5
|
+
import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
|
|
6
|
+
|
|
7
|
+
const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
|
|
8
|
+
|
|
9
|
+
describe('email verification', () => {
|
|
10
|
+
it('refuses to sign in an unconfirmed account, and lets it in once confirmed', async () => {
|
|
11
|
+
const { signUp, signIn, verifyEmail, mailer } = authFakes()
|
|
12
|
+
await signUp.execute(ANA)
|
|
13
|
+
|
|
14
|
+
await expect(signIn.execute(ANA)).rejects.toBeInstanceOf(EmailNotVerifiedError)
|
|
15
|
+
|
|
16
|
+
await verifyEmail.execute({ email: ANA.email, code: mailer.lastCode(ANA.email) })
|
|
17
|
+
|
|
18
|
+
await expect(signIn.execute(ANA)).resolves.toBeDefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('kills a code after five wrong guesses, even if the right one comes next', async () => {
|
|
22
|
+
const { signUp, verifyEmail, mailer } = authFakes()
|
|
23
|
+
await signUp.execute(ANA)
|
|
24
|
+
const code = mailer.lastCode(ANA.email)
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < EMAIL_CODE_ATTEMPTS; i += 1) {
|
|
27
|
+
await expect(
|
|
28
|
+
verifyEmail.execute({ email: ANA.email, code: '000000' }),
|
|
29
|
+
).rejects.toBeInstanceOf(InvalidCodeError)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await expect(verifyEmail.execute({ email: ANA.email, code })).rejects.toBeInstanceOf(
|
|
33
|
+
InvalidCodeError,
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('answers an unknown email as it answers a wrong code', async () => {
|
|
38
|
+
const { verifyEmail } = authFakes()
|
|
39
|
+
|
|
40
|
+
await expect(
|
|
41
|
+
verifyEmail.execute({ email: 'nobody@example.com', code: '123456' }),
|
|
42
|
+
).rejects.toBeInstanceOf(InvalidCodeError)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('password reset', () => {
|
|
47
|
+
it('sets the new password, ends every old session, and confirms the email it reached', async () => {
|
|
48
|
+
const { register, signIn, sessions, users, requestPasswordReset, resetPassword, mailer } =
|
|
49
|
+
authFakes()
|
|
50
|
+
await register(ANA)
|
|
51
|
+
await signIn.execute(ANA)
|
|
52
|
+
|
|
53
|
+
await requestPasswordReset.execute(ANA.email)
|
|
54
|
+
await resetPassword.execute({
|
|
55
|
+
email: ANA.email,
|
|
56
|
+
code: mailer.lastCode(ANA.email),
|
|
57
|
+
password: 'another-good-password',
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const revoked = [...sessions.rows.values()].filter((session) => session.revokedAt !== null)
|
|
61
|
+
const [user] = [...users.rows.values()]
|
|
62
|
+
|
|
63
|
+
expect(revoked).toHaveLength(2)
|
|
64
|
+
expect(user?.passwordHash).toBe('hashed:another-good-password')
|
|
65
|
+
await expect(signIn.execute(ANA)).rejects.toThrow()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('sends nothing for an unknown email, and says nothing about it either', async () => {
|
|
69
|
+
const { requestPasswordReset, mailer } = authFakes()
|
|
70
|
+
|
|
71
|
+
await expect(requestPasswordReset.execute('nobody@example.com')).resolves.toBeUndefined()
|
|
72
|
+
expect(mailer.sent).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('voids the previous code when a new one is sent', async () => {
|
|
76
|
+
const { register, requestPasswordReset, resetPassword, mailer } = authFakes()
|
|
77
|
+
await register(ANA)
|
|
78
|
+
|
|
79
|
+
await requestPasswordReset.execute(ANA.email)
|
|
80
|
+
const first = mailer.lastCode(ANA.email)
|
|
81
|
+
await requestPasswordReset.execute(ANA.email)
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
resetPassword.execute({ email: ANA.email, code: first, password: 'another-good-password' }),
|
|
85
|
+
).rejects.toBeInstanceOf(InvalidCodeError)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -12,8 +12,8 @@ afterEach(() => {
|
|
|
12
12
|
|
|
13
13
|
describe('RefreshSessionUseCase', () => {
|
|
14
14
|
it('rotates the refresh token on every use', async () => {
|
|
15
|
-
const {
|
|
16
|
-
const first = await
|
|
15
|
+
const { register, refresh } = authFakes()
|
|
16
|
+
const first = await register(ANA)
|
|
17
17
|
|
|
18
18
|
const second = await refresh.execute(first.refreshToken)
|
|
19
19
|
const third = await refresh.execute(second.refreshToken)
|
|
@@ -22,8 +22,8 @@ describe('RefreshSessionUseCase', () => {
|
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
it('asks a client that lost a race to retry, and revokes nothing', async () => {
|
|
25
|
-
const {
|
|
26
|
-
const first = await
|
|
25
|
+
const { register, refresh, sessions } = authFakes()
|
|
26
|
+
const first = await register(ANA)
|
|
27
27
|
|
|
28
28
|
const second = await refresh.execute(first.refreshToken)
|
|
29
29
|
|
|
@@ -34,8 +34,8 @@ describe('RefreshSessionUseCase', () => {
|
|
|
34
34
|
|
|
35
35
|
it('ends the session when a token comes back after the grace period', async () => {
|
|
36
36
|
vi.useFakeTimers()
|
|
37
|
-
const {
|
|
38
|
-
const first = await
|
|
37
|
+
const { register, refresh, sessions } = authFakes()
|
|
38
|
+
const first = await register(ANA)
|
|
39
39
|
const second = await refresh.execute(first.refreshToken)
|
|
40
40
|
|
|
41
41
|
vi.advanceTimersByTime(ROTATION_GRACE_MS)
|
|
@@ -55,8 +55,8 @@ describe('RefreshSessionUseCase', () => {
|
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
it('refuses every token of a session signed out of', async () => {
|
|
58
|
-
const {
|
|
59
|
-
const tokens = await
|
|
58
|
+
const { register, signOut, refresh, sessions } = authFakes()
|
|
59
|
+
const tokens = await register(ANA)
|
|
60
60
|
const session = [...sessions.rows.values()][0]
|
|
61
61
|
|
|
62
62
|
await signOut.execute(session?.userId ?? '', session?.id ?? '')
|
|
@@ -6,8 +6,8 @@ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-ba
|
|
|
6
6
|
|
|
7
7
|
describe('SignInUseCase', () => {
|
|
8
8
|
it('starts a session for the right password, whatever the case of the email', async () => {
|
|
9
|
-
const {
|
|
10
|
-
await
|
|
9
|
+
const { register, signIn, sessions } = authFakes()
|
|
10
|
+
await register(ANA)
|
|
11
11
|
|
|
12
12
|
await signIn.execute({ email: 'ANA@example.com', password: ANA.password })
|
|
13
13
|
|
|
@@ -15,8 +15,8 @@ describe('SignInUseCase', () => {
|
|
|
15
15
|
})
|
|
16
16
|
|
|
17
17
|
it('answers a wrong password and an unknown email alike, after checking a password both times', async () => {
|
|
18
|
-
const {
|
|
19
|
-
await
|
|
18
|
+
const { register, signIn, hasher } = authFakes()
|
|
19
|
+
await register(ANA)
|
|
20
20
|
|
|
21
21
|
await expect(signIn.execute({ email: ANA.email, password: 'wrong' })).rejects.toBeInstanceOf(
|
|
22
22
|
InvalidCredentialsError,
|
|
@@ -5,10 +5,10 @@ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
|
|
|
5
5
|
const ANA = { name: 'Ana', email: ' Ana@Example.com', password: 'correct-horse-battery' }
|
|
6
6
|
|
|
7
7
|
describe('SignUpUseCase', () => {
|
|
8
|
-
it('creates the user and the profile together
|
|
9
|
-
const { signUp, users, profiles
|
|
8
|
+
it('creates the user and the profile together', async () => {
|
|
9
|
+
const { signUp, users, profiles } = authFakes()
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
await signUp.execute(ANA)
|
|
12
12
|
const [user] = [...users.rows.values()]
|
|
13
13
|
|
|
14
14
|
expect(user).toMatchObject({
|
|
@@ -16,8 +16,6 @@ describe('SignUpUseCase', () => {
|
|
|
16
16
|
passwordHash: 'hashed:correct-horse-battery',
|
|
17
17
|
})
|
|
18
18
|
expect(profiles.rows.get(user?.id ?? '')).toMatchObject({ displayName: 'Ana' })
|
|
19
|
-
expect(sessions.rows.size).toBe(1)
|
|
20
|
-
expect(tokens.refreshToken.startsWith(`${[...sessions.rows.keys()][0]}.`)).toBe(true)
|
|
21
19
|
})
|
|
22
20
|
|
|
23
21
|
it('refuses an email already taken, whatever its case', async () => {
|
|
@@ -30,4 +28,14 @@ describe('SignUpUseCase', () => {
|
|
|
30
28
|
)
|
|
31
29
|
expect(profiles.rows.size).toBe(1)
|
|
32
30
|
})
|
|
31
|
+
|
|
32
|
+
// prumo:email
|
|
33
|
+
it('sends a code and opens no session until the email is confirmed', async () => {
|
|
34
|
+
const { signUp, sessions, mailer } = authFakes()
|
|
35
|
+
|
|
36
|
+
expect(await signUp.execute(ANA)).toBeNull()
|
|
37
|
+
expect(sessions.rows.size).toBe(0)
|
|
38
|
+
expect(mailer.lastCode('ana@example.com')).toMatch(/^\d{6}$/)
|
|
39
|
+
})
|
|
40
|
+
// prumo:end-email
|
|
33
41
|
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { socialFakes } from '@test/support/fakes/social-fakes'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ProviderEmailUnverifiedError } from '@/domain/auth/errors/provider-email-unverified.error'
|
|
4
|
+
import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.error'
|
|
5
|
+
import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
|
|
6
|
+
|
|
7
|
+
const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
|
|
8
|
+
const GOOGLE = {
|
|
9
|
+
subject: 'google-sub-1',
|
|
10
|
+
email: 'Ana@Example.com',
|
|
11
|
+
emailVerified: true,
|
|
12
|
+
name: 'Ana G',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('LinkIdentityUseCase', () => {
|
|
16
|
+
it('creates a verified, password-less account with its profile for someone new', async () => {
|
|
17
|
+
const { linkIdentity, users, profiles, identities } = socialFakes()
|
|
18
|
+
|
|
19
|
+
const userId = await linkIdentity.execute('google', GOOGLE)
|
|
20
|
+
const user = users.rows.get(userId)
|
|
21
|
+
|
|
22
|
+
expect(user).toMatchObject({ email: 'ana@example.com', passwordHash: null })
|
|
23
|
+
expect(user?.emailVerifiedAt).not.toBeNull()
|
|
24
|
+
expect(profiles.rows.get(userId)).toMatchObject({ displayName: 'Ana G' })
|
|
25
|
+
expect(identities.rows).toEqual([expect.objectContaining({ provider: 'google', userId })])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('recognises the provider account the next time, by subject even if the email changed', async () => {
|
|
29
|
+
const { linkIdentity } = socialFakes()
|
|
30
|
+
|
|
31
|
+
const first = await linkIdentity.execute('google', GOOGLE)
|
|
32
|
+
const again = await linkIdentity.execute('google', { ...GOOGLE, email: 'new@example.com' })
|
|
33
|
+
|
|
34
|
+
expect(again).toBe(first)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('links to a confirmed account and leaves its password alone', async () => {
|
|
38
|
+
const { register, linkIdentity, users, signIn } = socialFakes()
|
|
39
|
+
await register(ANA)
|
|
40
|
+
// Confirmed by a code where the project verifies emails; marked here so the rule is proved either way.
|
|
41
|
+
for (const user of users.rows.values()) {
|
|
42
|
+
user.verifyEmail(new Date())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const userId = await linkIdentity.execute('google', GOOGLE)
|
|
46
|
+
|
|
47
|
+
expect(users.rows.get(userId)?.passwordHash).toBe('hashed:correct-horse-battery')
|
|
48
|
+
await expect(signIn.execute(ANA)).resolves.toBeDefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// The attack: someone signs up with another person's email first, and waits for them to arrive by Google.
|
|
52
|
+
it('takes an unconfirmed account from whoever chose its password, ending their sessions', async () => {
|
|
53
|
+
const { signUp, linkIdentity, users, sessions } = socialFakes()
|
|
54
|
+
await signUp.execute(ANA)
|
|
55
|
+
const squatter = [...users.rows.values()][0]
|
|
56
|
+
|
|
57
|
+
const userId = await linkIdentity.execute('google', GOOGLE)
|
|
58
|
+
|
|
59
|
+
expect(userId).toBe(squatter?.id)
|
|
60
|
+
expect(users.rows.get(userId)?.passwordHash).toBeNull()
|
|
61
|
+
expect([...sessions.rows.values()].every((session) => session.revokedAt !== null)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('refuses an email the provider has not verified', async () => {
|
|
65
|
+
const { linkIdentity } = socialFakes()
|
|
66
|
+
|
|
67
|
+
await expect(
|
|
68
|
+
linkIdentity.execute('google', { ...GOOGLE, emailVerified: false }),
|
|
69
|
+
).rejects.toBeInstanceOf(ProviderEmailUnverifiedError)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('a social sign-in, start to session', () => {
|
|
74
|
+
it('signs the web in on return, and only the browser that started', async () => {
|
|
75
|
+
const { start, complete } = socialFakes()
|
|
76
|
+
const { url, browserToken } = await start.execute({
|
|
77
|
+
provider: 'google',
|
|
78
|
+
client: 'web',
|
|
79
|
+
returnTo: '/orders',
|
|
80
|
+
})
|
|
81
|
+
const state = new URL(url).searchParams.get('state') ?? ''
|
|
82
|
+
const callback = {
|
|
83
|
+
provider: 'google' as const,
|
|
84
|
+
state,
|
|
85
|
+
callbackUrl: new URL('http://localhost:3000/cb'),
|
|
86
|
+
name: null,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await expect(complete.execute({ ...callback, browserToken: undefined })).rejects.toBeInstanceOf(
|
|
90
|
+
SocialSignInFailedError,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const outcome = await complete.execute({ ...callback, browserToken })
|
|
94
|
+
|
|
95
|
+
expect(outcome).toMatchObject({ client: 'web', returnTo: '/orders' })
|
|
96
|
+
await expect(complete.execute({ ...callback, browserToken })).rejects.toBeInstanceOf(
|
|
97
|
+
SocialSignInFailedError,
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('gives a mobile app a code it can exchange once', async () => {
|
|
102
|
+
const { start, complete, exchange } = socialFakes()
|
|
103
|
+
const { url, browserToken } = await start.execute({
|
|
104
|
+
provider: 'apple',
|
|
105
|
+
client: 'mobile',
|
|
106
|
+
returnTo: '/',
|
|
107
|
+
})
|
|
108
|
+
const outcome = await complete.execute({
|
|
109
|
+
provider: 'apple',
|
|
110
|
+
state: new URL(url).searchParams.get('state') ?? '',
|
|
111
|
+
browserToken,
|
|
112
|
+
callbackUrl: new URL('http://localhost:3000/cb'),
|
|
113
|
+
name: 'Ana',
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (outcome.client !== 'mobile') {
|
|
117
|
+
throw new Error('expected a code for the mobile app')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await expect(exchange.execute(outcome.code)).resolves.toBeDefined()
|
|
121
|
+
await expect(exchange.execute(outcome.code)).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('refuses a provider without credentials, and a callback for another provider', async () => {
|
|
125
|
+
const { start, complete, providers } = socialFakes()
|
|
126
|
+
const { url, browserToken } = await start.execute({
|
|
127
|
+
provider: 'google',
|
|
128
|
+
client: 'web',
|
|
129
|
+
returnTo: '/',
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await expect(
|
|
133
|
+
complete.execute({
|
|
134
|
+
provider: 'apple',
|
|
135
|
+
state: new URL(url).searchParams.get('state') ?? '',
|
|
136
|
+
browserToken,
|
|
137
|
+
callbackUrl: new URL('http://localhost:3000/cb'),
|
|
138
|
+
name: null,
|
|
139
|
+
}),
|
|
140
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
141
|
+
|
|
142
|
+
Object.assign(providers, { configured: [] })
|
|
143
|
+
|
|
144
|
+
await expect(
|
|
145
|
+
start.execute({ provider: 'google', client: 'web', returnTo: '/' }),
|
|
146
|
+
).rejects.toBeInstanceOf(ProviderNotConfiguredError)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { HmacOneTimeCodes } from '@/infra/email/hmac-one-time-codes.adapter'
|
|
3
|
+
|
|
4
|
+
describe('HmacOneTimeCodes', () => {
|
|
5
|
+
it('makes six digits, and a digest that depends on the secret', () => {
|
|
6
|
+
const codes = new HmacOneTimeCodes({ JWT_SECRET: 'a'.repeat(40) })
|
|
7
|
+
const other = new HmacOneTimeCodes({ JWT_SECRET: 'b'.repeat(40) })
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < 50; i += 1) {
|
|
10
|
+
expect(codes.create()).toMatch(/^\d{6}$/)
|
|
11
|
+
}
|
|
12
|
+
expect(codes.digest('123456')).toBe(codes.digest('123456'))
|
|
13
|
+
expect(codes.digest('123456')).not.toBe(other.digest('123456'))
|
|
14
|
+
})
|
|
15
|
+
})
|