@stjoseph/prumo 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +51 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { type LookupAddress, lookup } from 'node:dns'
|
|
2
|
+
import { request } from 'node:https'
|
|
3
|
+
import { BlockList, isIP } from 'node:net'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
|
|
6
|
+
import type {
|
|
7
|
+
ClientMetadata,
|
|
8
|
+
ClientMetadataSource,
|
|
9
|
+
} from '@/domain/oauth/ports/client-metadata.port'
|
|
10
|
+
|
|
11
|
+
// The draft's recommended ceiling: a description of a client has no reason to be larger.
|
|
12
|
+
const MAX_BYTES = 5 * 1024
|
|
13
|
+
const TIMEOUT_MS = 5000
|
|
14
|
+
const MAX_CACHE_MS = 60 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
// Anything that is not the public internet. The client chooses the URL, so without this list it could make the API
|
|
17
|
+
// read its own network.
|
|
18
|
+
const PRIVATE = new BlockList()
|
|
19
|
+
|
|
20
|
+
for (const [network, prefix] of [
|
|
21
|
+
['0.0.0.0', 8],
|
|
22
|
+
['10.0.0.0', 8],
|
|
23
|
+
['100.64.0.0', 10],
|
|
24
|
+
['127.0.0.0', 8],
|
|
25
|
+
['169.254.0.0', 16],
|
|
26
|
+
['172.16.0.0', 12],
|
|
27
|
+
['192.0.0.0', 24],
|
|
28
|
+
['192.0.2.0', 24],
|
|
29
|
+
['192.168.0.0', 16],
|
|
30
|
+
['198.18.0.0', 15],
|
|
31
|
+
['198.51.100.0', 24],
|
|
32
|
+
['203.0.113.0', 24],
|
|
33
|
+
['224.0.0.0', 4],
|
|
34
|
+
['240.0.0.0', 4],
|
|
35
|
+
] as const) {
|
|
36
|
+
PRIVATE.addSubnet(network, prefix, 'ipv4')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// An IPv4-mapped address (::ffff:127.0.0.1) is checked against the IPv4 rules by BlockList itself; a rule for the
|
|
40
|
+
// whole mapped range would match every IPv4 address, public ones included.
|
|
41
|
+
for (const [network, prefix] of [
|
|
42
|
+
['::', 127],
|
|
43
|
+
['64:ff9b::', 96],
|
|
44
|
+
['100::', 64],
|
|
45
|
+
['2001:db8::', 32],
|
|
46
|
+
['fc00::', 7],
|
|
47
|
+
['fe80::', 10],
|
|
48
|
+
['ff00::', 8],
|
|
49
|
+
] as const) {
|
|
50
|
+
PRIVATE.addSubnet(network, prefix, 'ipv6')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isPrivateAddress(address: string): boolean {
|
|
54
|
+
return PRIVATE.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const documentSchema = z.object({
|
|
58
|
+
client_id: z.string(),
|
|
59
|
+
client_name: z.string().trim().min(1).max(200),
|
|
60
|
+
redirect_uris: z.array(z.string()).min(1).max(20),
|
|
61
|
+
// A document is public by nature, so it cannot hold a secret, and a key-based method is not supported here.
|
|
62
|
+
token_endpoint_auth_method: z.literal('none').optional(),
|
|
63
|
+
client_secret: z.never().optional(),
|
|
64
|
+
client_secret_expires_at: z.never().optional(),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function parseJson(text: string): unknown {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(text)
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fail(message: string): never {
|
|
76
|
+
throw new InvalidClientError(message)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// https, a path, no credentials, no query, no fragment, no dot segments: and written exactly as the URL parser
|
|
80
|
+
// would write it, since the document's client_id is compared to it character by character.
|
|
81
|
+
function clientUrl(clientId: string): URL {
|
|
82
|
+
const url = URL.canParse(clientId) ? new URL(clientId) : fail('client_id is not a URL')
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
url.protocol !== 'https:' ||
|
|
86
|
+
url.username !== '' ||
|
|
87
|
+
url.password !== '' ||
|
|
88
|
+
url.search !== '' ||
|
|
89
|
+
url.hash !== '' ||
|
|
90
|
+
url.pathname === '/' ||
|
|
91
|
+
url.href !== clientId
|
|
92
|
+
) {
|
|
93
|
+
fail('client_id must be a canonical https URL with a path, and nothing else')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return url
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// HTTPS anywhere, or plain HTTP back to this device, as MCP allows for native clients.
|
|
100
|
+
function isAllowedRedirect(uri: string): boolean {
|
|
101
|
+
if (!URL.canParse(uri)) {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const url = new URL(uri)
|
|
106
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
107
|
+
|
|
108
|
+
return url.hash === '' && (url.protocol === 'https:' || (url.protocol === 'http:' && loopback))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The connection goes to the address checked here, so a name that resolves differently a second time cannot reach
|
|
112
|
+
// the private network after passing.
|
|
113
|
+
function guardedLookup(
|
|
114
|
+
hostname: string,
|
|
115
|
+
options: { all?: boolean },
|
|
116
|
+
callback: (error: Error | null, address: string | LookupAddress[], family?: number) => void,
|
|
117
|
+
): void {
|
|
118
|
+
lookup(hostname, { all: true }, (error, addresses) => {
|
|
119
|
+
if (error !== null) {
|
|
120
|
+
callback(error, [])
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) {
|
|
125
|
+
callback(new Error(`${hostname} resolves to a private address`), [])
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const [first] = addresses as [LookupAddress]
|
|
130
|
+
|
|
131
|
+
if (options.all === true) {
|
|
132
|
+
callback(null, addresses)
|
|
133
|
+
} else {
|
|
134
|
+
callback(null, first.address, first.family)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function download(url: URL): Promise<{ body: string; maxAgeMs: number }> {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const req = request(
|
|
142
|
+
url,
|
|
143
|
+
{ method: 'GET', headers: { accept: 'application/json' }, lookup: guardedLookup as never },
|
|
144
|
+
(res) => {
|
|
145
|
+
if (res.statusCode !== 200) {
|
|
146
|
+
res.destroy()
|
|
147
|
+
reject(new Error(`answered ${res.statusCode}`))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const chunks: Buffer[] = []
|
|
152
|
+
let size = 0
|
|
153
|
+
|
|
154
|
+
res.on('data', (chunk: Buffer) => {
|
|
155
|
+
size += chunk.length
|
|
156
|
+
|
|
157
|
+
if (size > MAX_BYTES) {
|
|
158
|
+
res.destroy(new Error(`is larger than ${MAX_BYTES} bytes`))
|
|
159
|
+
} else {
|
|
160
|
+
chunks.push(chunk)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
res.on('error', reject)
|
|
164
|
+
res.on('end', () => {
|
|
165
|
+
const maxAge = /max-age=(\d+)/.exec(res.headers['cache-control'] ?? '')?.[1]
|
|
166
|
+
const cacheable = !/no-store|no-cache/.test(res.headers['cache-control'] ?? '')
|
|
167
|
+
|
|
168
|
+
resolve({
|
|
169
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
170
|
+
maxAgeMs:
|
|
171
|
+
cacheable && maxAge !== undefined ? Math.min(Number(maxAge) * 1000, MAX_CACHE_MS) : 0,
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error('timed out')))
|
|
178
|
+
req.on('error', reject)
|
|
179
|
+
req.end()
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class HttpsClientMetadata implements ClientMetadataSource {
|
|
184
|
+
private readonly cache = new Map<string, { metadata: ClientMetadata; until: number }>()
|
|
185
|
+
|
|
186
|
+
async read(clientId: string): Promise<ClientMetadata> {
|
|
187
|
+
const cached = this.cache.get(clientId)
|
|
188
|
+
|
|
189
|
+
if (cached !== undefined && cached.until > Date.now()) {
|
|
190
|
+
return cached.metadata
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const url = clientUrl(clientId)
|
|
194
|
+
const host = url.hostname.replace(/^\[|\]$/g, '')
|
|
195
|
+
|
|
196
|
+
// A literal address is connected to directly, without the lookup that guards a name.
|
|
197
|
+
if (isIP(host) !== 0 && isPrivateAddress(host)) {
|
|
198
|
+
fail('client_id points at a private address')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { body, maxAgeMs } = await download(url).catch((error: Error) =>
|
|
202
|
+
fail(`The client metadata document could not be read: ${error.message}`),
|
|
203
|
+
)
|
|
204
|
+
const parsed = documentSchema.safeParse(parseJson(body))
|
|
205
|
+
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
fail('The client metadata document is not a valid public client description')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (parsed.data.client_id !== clientId) {
|
|
211
|
+
fail('The client metadata document names another client_id')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!parsed.data.redirect_uris.every(isAllowedRedirect)) {
|
|
215
|
+
fail('Every redirect URI must be https, or http to localhost')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const metadata = {
|
|
219
|
+
clientId,
|
|
220
|
+
clientName: parsed.data.client_name,
|
|
221
|
+
redirectUris: parsed.data.redirect_uris,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Only a valid document is kept, and only as long as its server says it may be.
|
|
225
|
+
if (maxAgeMs > 0) {
|
|
226
|
+
this.cache.set(clientId, { metadata, until: Date.now() + maxAgeMs })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return metadata
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -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,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ROTATION_GRACE_MS, Session } from '@/domain/auth/entities/session.entity'
|
|
3
|
+
|
|
4
|
+
const NOW = new Date('2026-10-02T12:00:00Z')
|
|
5
|
+
const later = (ms: number) => new Date(NOW.getTime() + ms)
|
|
6
|
+
|
|
7
|
+
describe('Session', () => {
|
|
8
|
+
it('accepts the current token, and after a rotation only the new one', () => {
|
|
9
|
+
const session = new Session('user-1', 'first', NOW)
|
|
10
|
+
|
|
11
|
+
expect(session.check('first', NOW)).toBe('valid')
|
|
12
|
+
|
|
13
|
+
session.rotate('second', later(1000))
|
|
14
|
+
|
|
15
|
+
expect(session.check('second', later(2000))).toBe('valid')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('tells a token rotated a moment ago from one kept and replayed', () => {
|
|
19
|
+
const session = new Session('user-1', 'first', NOW)
|
|
20
|
+
|
|
21
|
+
session.rotate('second', NOW)
|
|
22
|
+
|
|
23
|
+
expect(session.check('first', later(ROTATION_GRACE_MS - 1))).toBe('superseded')
|
|
24
|
+
expect(session.check('first', later(ROTATION_GRACE_MS))).toBe('reused')
|
|
25
|
+
expect(session.check('never-issued', NOW)).toBe('reused')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('refuses every token once revoked or expired', () => {
|
|
29
|
+
const revoked = new Session('user-1', 'first', NOW)
|
|
30
|
+
revoked.revoke(NOW)
|
|
31
|
+
|
|
32
|
+
expect(revoked.check('first', NOW)).toBe('expired')
|
|
33
|
+
expect(new Session('user-1', 'first', NOW).check('first', later(31 * 86_400_000))).toBe(
|
|
34
|
+
'expired',
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('reads back the refresh token it writes, and nothing malformed', () => {
|
|
39
|
+
const session = new Session('user-1', 'hash', NOW)
|
|
40
|
+
session.id = '0199a7e2-0000-7000-8000-000000000000'
|
|
41
|
+
|
|
42
|
+
expect(Session.parseRefreshToken(session.refreshToken('s3cret'))).toEqual({
|
|
43
|
+
sessionId: session.id,
|
|
44
|
+
secret: 's3cret',
|
|
45
|
+
})
|
|
46
|
+
for (const token of ['', 'no-dot', 'not-a-uuid.secret', `${session.id}.`]) {
|
|
47
|
+
expect(Session.parseRefreshToken(token), token).toBeNull()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { authFakes } from '@test/support/fakes/auth-fakes'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { ROTATION_GRACE_MS } from '@/domain/auth/entities/session.entity'
|
|
4
|
+
import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
|
|
5
|
+
import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
|
|
6
|
+
|
|
7
|
+
const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('RefreshSessionUseCase', () => {
|
|
14
|
+
it('rotates the refresh token on every use', async () => {
|
|
15
|
+
const { register, refresh } = authFakes()
|
|
16
|
+
const first = await register(ANA)
|
|
17
|
+
|
|
18
|
+
const second = await refresh.execute(first.refreshToken)
|
|
19
|
+
const third = await refresh.execute(second.refreshToken)
|
|
20
|
+
|
|
21
|
+
expect(new Set([first.refreshToken, second.refreshToken, third.refreshToken]).size).toBe(3)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('asks a client that lost a race to retry, and revokes nothing', async () => {
|
|
25
|
+
const { register, refresh, sessions } = authFakes()
|
|
26
|
+
const first = await register(ANA)
|
|
27
|
+
|
|
28
|
+
const second = await refresh.execute(first.refreshToken)
|
|
29
|
+
|
|
30
|
+
await expect(refresh.execute(first.refreshToken)).rejects.toBeInstanceOf(SupersededTokenError)
|
|
31
|
+
expect([...sessions.rows.values()][0]?.revokedAt).toBeNull()
|
|
32
|
+
await expect(refresh.execute(second.refreshToken)).resolves.toBeDefined()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('ends the session when a token comes back after the grace period', async () => {
|
|
36
|
+
vi.useFakeTimers()
|
|
37
|
+
const { register, refresh, sessions } = authFakes()
|
|
38
|
+
const first = await register(ANA)
|
|
39
|
+
const second = await refresh.execute(first.refreshToken)
|
|
40
|
+
|
|
41
|
+
vi.advanceTimersByTime(ROTATION_GRACE_MS)
|
|
42
|
+
|
|
43
|
+
await expect(refresh.execute(first.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
|
|
44
|
+
expect([...sessions.rows.values()][0]?.revokedAt).not.toBeNull()
|
|
45
|
+
// The thief's replay also locked out the rightful client: that is the point.
|
|
46
|
+
await expect(refresh.execute(second.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('refuses a token that names no session or is not one at all', async () => {
|
|
50
|
+
const { refresh } = authFakes()
|
|
51
|
+
|
|
52
|
+
for (const token of ['garbage', '0199a7e2-0000-7000-8000-000000000000.secret']) {
|
|
53
|
+
await expect(refresh.execute(token), token).rejects.toBeInstanceOf(InvalidSessionError)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('refuses every token of a session signed out of', async () => {
|
|
58
|
+
const { register, signOut, refresh, sessions } = authFakes()
|
|
59
|
+
const tokens = await register(ANA)
|
|
60
|
+
const session = [...sessions.rows.values()][0]
|
|
61
|
+
|
|
62
|
+
await signOut.execute(session?.userId ?? '', session?.id ?? '')
|
|
63
|
+
|
|
64
|
+
await expect(refresh.execute(tokens.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { authFakes } from '@test/support/fakes/auth-fakes'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
|
|
4
|
+
|
|
5
|
+
const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
|
|
6
|
+
|
|
7
|
+
describe('SignInUseCase', () => {
|
|
8
|
+
it('starts a session for the right password, whatever the case of the email', async () => {
|
|
9
|
+
const { register, signIn, sessions } = authFakes()
|
|
10
|
+
await register(ANA)
|
|
11
|
+
|
|
12
|
+
await signIn.execute({ email: 'ANA@example.com', password: ANA.password })
|
|
13
|
+
|
|
14
|
+
expect(sessions.rows.size).toBe(2)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('answers a wrong password and an unknown email alike, after checking a password both times', async () => {
|
|
18
|
+
const { register, signIn, hasher } = authFakes()
|
|
19
|
+
await register(ANA)
|
|
20
|
+
|
|
21
|
+
await expect(signIn.execute({ email: ANA.email, password: 'wrong' })).rejects.toBeInstanceOf(
|
|
22
|
+
InvalidCredentialsError,
|
|
23
|
+
)
|
|
24
|
+
await expect(
|
|
25
|
+
signIn.execute({ email: 'nobody@example.com', password: ANA.password }),
|
|
26
|
+
).rejects.toBeInstanceOf(InvalidCredentialsError)
|
|
27
|
+
expect(hasher.verifications).toBe(2)
|
|
28
|
+
})
|
|
29
|
+
})
|