@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,192 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
|
|
5
|
+
import type { SocialClient } from '@/domain/auth/entities/social-sign-in.entity'
|
|
6
|
+
import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
|
|
7
|
+
import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
|
|
8
|
+
import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
|
|
9
|
+
import { DomainError } from '@/domain/shared/errors/domain-error'
|
|
10
|
+
import { ENV, type Env } from '@/infra/config/env'
|
|
11
|
+
import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
|
|
12
|
+
import { HttpError } from '@/infra/http/errors/http-error'
|
|
13
|
+
import { sessionDelivery } from '@/infra/http/session-delivery'
|
|
14
|
+
|
|
15
|
+
const BROWSER_COOKIE = 'social_sign_in'
|
|
16
|
+
const BROWSER_COOKIE_MAX_AGE_S = 10 * 60
|
|
17
|
+
|
|
18
|
+
const PROVIDERS = [
|
|
19
|
+
'google', // prumo:google
|
|
20
|
+
'apple', // prumo:apple
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
const providerParams = z.object({ provider: z.enum(PROVIDERS) })
|
|
24
|
+
|
|
25
|
+
// Only a path on the web app: an absolute URL here would turn sign-in into an open redirect.
|
|
26
|
+
function safePath(path: string | undefined): string {
|
|
27
|
+
return path?.startsWith('/') && !path.startsWith('//') ? path : '/'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Apple sends the name it shares once, as JSON beside the code.
|
|
31
|
+
function appleName(user: string | undefined): string | null {
|
|
32
|
+
try {
|
|
33
|
+
const name = (JSON.parse(user ?? '') as { name?: { firstName?: string; lastName?: string } })
|
|
34
|
+
.name
|
|
35
|
+
const full = [name?.firstName, name?.lastName].filter(Boolean).join(' ')
|
|
36
|
+
|
|
37
|
+
return full === '' ? null : full
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function socialController(fastify: FastifyInstance): Promise<void> {
|
|
44
|
+
const app = fastify.withTypeProvider<ZodTypeProvider>()
|
|
45
|
+
const env = app.container.resolve<Env>(ENV)
|
|
46
|
+
const { deliver, setCookies } = sessionDelivery(env)
|
|
47
|
+
const cookie = {
|
|
48
|
+
httpOnly: true,
|
|
49
|
+
sameSite: 'lax',
|
|
50
|
+
secure: env.API_URL.startsWith('https://'),
|
|
51
|
+
path: '/api/auth/social',
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
const back = (client: SocialClient, query: Record<string, string>) =>
|
|
55
|
+
client === 'mobile'
|
|
56
|
+
? `${env.MOBILE_APP_SCHEME}://social?${new URLSearchParams(query)}`
|
|
57
|
+
: `${env.WEB_ORIGIN}/sign-in?${new URLSearchParams(query)}`
|
|
58
|
+
|
|
59
|
+
// Apple's answer is a form.
|
|
60
|
+
app.addContentTypeParser(
|
|
61
|
+
'application/x-www-form-urlencoded',
|
|
62
|
+
{ parseAs: 'string' },
|
|
63
|
+
(_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
app.get(
|
|
67
|
+
'/:provider',
|
|
68
|
+
{
|
|
69
|
+
config: { public: true, ...ATTEMPTS },
|
|
70
|
+
schema: {
|
|
71
|
+
tags: ['auth'],
|
|
72
|
+
params: providerParams,
|
|
73
|
+
querystring: z.object({
|
|
74
|
+
client: z.enum(['web', 'mobile']).default('web'),
|
|
75
|
+
returnTo: z.string().max(512).optional(),
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
async (request, reply) => {
|
|
80
|
+
const { client, returnTo } = request.query
|
|
81
|
+
|
|
82
|
+
if (client === 'mobile' && env.MOBILE_APP_SCHEME === undefined) {
|
|
83
|
+
throw new HttpError(
|
|
84
|
+
422,
|
|
85
|
+
'MOBILE_APP_SCHEME is not set, so a mobile app cannot be sent back',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const started = await app.container.resolve(StartSocialSignInUseCase).execute({
|
|
90
|
+
provider: request.params.provider,
|
|
91
|
+
client,
|
|
92
|
+
returnTo: safePath(returnTo),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// The client rides beside the token, so an error found before the flow is read still goes to the right place.
|
|
96
|
+
reply.setCookie(BROWSER_COOKIE, `${client}~${started.browserToken}`, {
|
|
97
|
+
...cookie,
|
|
98
|
+
maxAge: BROWSER_COOKIE_MAX_AGE_S,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return reply.redirect(started.url)
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// A cross-site POST carries no SameSite=Lax cookie, and a top-level GET does: Apple's form is turned into a
|
|
106
|
+
// redirect to the same address, so the browser's token comes with it.
|
|
107
|
+
app.post(
|
|
108
|
+
'/:provider/callback',
|
|
109
|
+
{ config: { public: true }, schema: { hide: true, params: providerParams } },
|
|
110
|
+
async (request, reply) => {
|
|
111
|
+
const form = new URLSearchParams(request.body as Record<string, string>)
|
|
112
|
+
|
|
113
|
+
return reply
|
|
114
|
+
.code(303)
|
|
115
|
+
.redirect(`${env.API_URL}/api/auth/social/${request.params.provider}/callback?${form}`)
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
app.get(
|
|
120
|
+
'/:provider/callback',
|
|
121
|
+
{
|
|
122
|
+
config: { public: true },
|
|
123
|
+
schema: {
|
|
124
|
+
hide: true,
|
|
125
|
+
params: providerParams,
|
|
126
|
+
querystring: z.object({
|
|
127
|
+
state: z.string().optional(),
|
|
128
|
+
code: z.string().optional(),
|
|
129
|
+
error: z.string().optional(),
|
|
130
|
+
user: z.string().optional(),
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
async (request, reply) => {
|
|
135
|
+
const [client, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~').split('~') as [
|
|
136
|
+
SocialClient,
|
|
137
|
+
string,
|
|
138
|
+
]
|
|
139
|
+
const { state, code, error } = request.query
|
|
140
|
+
|
|
141
|
+
reply.clearCookie(BROWSER_COOKIE, cookie)
|
|
142
|
+
|
|
143
|
+
if (error !== undefined || state === undefined || code === undefined) {
|
|
144
|
+
return reply.redirect(back(client, { error: 'social_sign_in_failed' }))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const outcome = await app.container.resolve(CompleteSocialSignInUseCase).execute({
|
|
149
|
+
provider: request.params.provider,
|
|
150
|
+
state,
|
|
151
|
+
browserToken: browserToken === '' ? undefined : browserToken,
|
|
152
|
+
callbackUrl: new URL(request.url, env.API_URL),
|
|
153
|
+
name: appleName(request.query.user),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (outcome.client === 'mobile') {
|
|
157
|
+
return reply.redirect(back('mobile', { code: outcome.code }))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setCookies(reply, outcome.tokens)
|
|
161
|
+
|
|
162
|
+
return reply.redirect(`${env.WEB_ORIGIN}${outcome.returnTo}`)
|
|
163
|
+
} catch (caught) {
|
|
164
|
+
if (caught instanceof DomainError) {
|
|
165
|
+
return reply.redirect(back(client, { error: caught.code ?? 'social_sign_in_failed' }))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw caught
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// The mobile app trades the code it was sent back with for its tokens, once.
|
|
174
|
+
app.post(
|
|
175
|
+
'/exchange',
|
|
176
|
+
{
|
|
177
|
+
config: { public: true, ...ATTEMPTS },
|
|
178
|
+
schema: {
|
|
179
|
+
tags: ['auth'],
|
|
180
|
+
body: z.strictObject({ code: z.string().min(1).max(512) }),
|
|
181
|
+
response: { 200: tokensResponseSchema },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
async (request, reply) =>
|
|
185
|
+
deliver(
|
|
186
|
+
request,
|
|
187
|
+
reply,
|
|
188
|
+
await app.container.resolve(ExchangeSocialCodeUseCase).execute(request.body.code),
|
|
189
|
+
200,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -11,6 +11,7 @@ type ProblemDocument = {
|
|
|
11
11
|
detail: string
|
|
12
12
|
instance: string
|
|
13
13
|
requestId: string
|
|
14
|
+
code?: string
|
|
14
15
|
errors?: FieldErrors
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -31,6 +32,7 @@ const TITLES: Record<number, string> = {
|
|
|
31
32
|
413: 'Payload Too Large',
|
|
32
33
|
415: 'Unsupported Media Type',
|
|
33
34
|
422: 'Unprocessable Entity',
|
|
35
|
+
429: 'Too Many Requests',
|
|
34
36
|
500: 'Internal Server Error',
|
|
35
37
|
503: 'Service Unavailable',
|
|
36
38
|
}
|
|
@@ -103,9 +105,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
|
|
|
103
105
|
request.log.error(error)
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
const code = error instanceof DomainError ? error.code : undefined
|
|
109
|
+
|
|
106
110
|
return send(request, reply, {
|
|
107
111
|
status,
|
|
108
112
|
detail: detailOf(error, status, errors),
|
|
113
|
+
...(code === undefined ? {} : { code }),
|
|
109
114
|
...(errors === undefined ? {} : { errors }),
|
|
110
115
|
})
|
|
111
116
|
})
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { DependencyContainer } from 'tsyringe'
|
|
2
|
-
import type { AuthSession, AuthUser } from '@/infra/auth/auth.factory'
|
|
3
2
|
|
|
4
3
|
declare module 'fastify' {
|
|
5
4
|
interface FastifyInstance {
|
|
@@ -11,7 +10,7 @@ declare module 'fastify' {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
interface FastifyRequest {
|
|
14
|
-
user?:
|
|
15
|
-
|
|
13
|
+
user?: { id: string }
|
|
14
|
+
sessionId?: string
|
|
16
15
|
}
|
|
17
16
|
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import { fromNodeHeaders } from 'better-auth/node'
|
|
2
1
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
3
2
|
|
|
3
|
+
function headersOf(request: FastifyRequest): Headers {
|
|
4
|
+
const headers = new Headers()
|
|
5
|
+
|
|
6
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
7
|
+
for (const each of [value].flat()) {
|
|
8
|
+
if (each !== undefined) {
|
|
9
|
+
headers.append(name, each)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return headers
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
function bodyOf(body: unknown): string | undefined {
|
|
5
18
|
if (body === undefined) {
|
|
6
19
|
return undefined
|
|
@@ -9,15 +22,14 @@ function bodyOf(body: unknown): string | undefined {
|
|
|
9
22
|
return typeof body === 'string' ? body : JSON.stringify(body)
|
|
10
23
|
}
|
|
11
24
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// string, such as a form, passes unchanged.
|
|
25
|
+
// The MCP handler answers Fetch requests. Fastify has already parsed a JSON body, so the request is rebuilt
|
|
26
|
+
// rather than handed over raw; a body kept as a string, such as a form, passes unchanged.
|
|
15
27
|
export function toFetchRequest(request: FastifyRequest): Request {
|
|
16
28
|
const body = bodyOf(request.body)
|
|
17
29
|
|
|
18
30
|
return new Request(new URL(request.url, `${request.protocol}://${request.host}`), {
|
|
19
31
|
method: request.method,
|
|
20
|
-
headers:
|
|
32
|
+
headers: headersOf(request),
|
|
21
33
|
...(body === undefined ? {} : { body }),
|
|
22
34
|
})
|
|
23
35
|
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { fromNodeHeaders } from 'better-auth/node'
|
|
2
1
|
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
|
3
|
-
import {
|
|
2
|
+
import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tokens.port'
|
|
4
3
|
import { HttpError } from '@/infra/http/errors/http-error'
|
|
5
4
|
|
|
5
|
+
export const ACCESS_COOKIE = 'access_token'
|
|
6
|
+
|
|
7
|
+
// The web sends a cookie the browser keeps; a native app and an MCP client send a bearer token.
|
|
8
|
+
function tokenOf(request: FastifyRequest): string | undefined {
|
|
9
|
+
const bearer = /^Bearer (.+)$/i.exec(request.headers.authorization ?? '')?.[1]
|
|
10
|
+
|
|
11
|
+
return bearer ?? request.cookies[ACCESS_COOKIE]
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
export function registerAuthHook(app: FastifyInstance): void {
|
|
7
|
-
const
|
|
15
|
+
const tokens = app.container.resolve<AccessTokens>(ACCESS_TOKENS)
|
|
8
16
|
|
|
9
17
|
app.addHook('preHandler', async (request) => {
|
|
10
18
|
// An unknown route answers 404, not 401: there is nothing there to protect.
|
|
@@ -12,21 +20,30 @@ export function registerAuthHook(app: FastifyInstance): void {
|
|
|
12
20
|
return
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
const
|
|
23
|
+
const token = tokenOf(request)
|
|
24
|
+
const claims = token === undefined ? null : await tokens.verify(token)
|
|
16
25
|
|
|
17
|
-
request.user =
|
|
18
|
-
request.
|
|
26
|
+
request.user = claims === null ? undefined : { id: claims.userId }
|
|
27
|
+
request.sessionId = claims?.sessionId
|
|
19
28
|
|
|
20
|
-
if (request.routeOptions.config.public !== true &&
|
|
29
|
+
if (request.routeOptions.config.public !== true && claims === null) {
|
|
21
30
|
throw new HttpError(401, 'Unauthorized')
|
|
22
31
|
}
|
|
23
32
|
})
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
export function currentUser(request: FastifyRequest):
|
|
35
|
+
export function currentUser(request: FastifyRequest): { id: string } {
|
|
27
36
|
if (request.user === undefined) {
|
|
28
37
|
throw new HttpError(401, 'Unauthorized')
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
return request.user
|
|
32
41
|
}
|
|
42
|
+
|
|
43
|
+
export function currentSession(request: FastifyRequest): string {
|
|
44
|
+
if (request.sessionId === undefined) {
|
|
45
|
+
throw new HttpError(401, 'Unauthorized')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return request.sessionId
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify'
|
|
2
|
+
import { HttpError } from '@/infra/http/errors/http-error'
|
|
3
|
+
|
|
4
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
5
|
+
|
|
6
|
+
// A cookie is sent by the browser whoever wrote the page, so a request that changes something and leans on one must
|
|
7
|
+
// come from the web app. Without a cookie there is nothing for another site to borrow: a native app and an MCP
|
|
8
|
+
// client send a bearer, and pass.
|
|
9
|
+
export function registerOriginCheck(app: FastifyInstance, webOrigin: string): void {
|
|
10
|
+
app.addHook('onRequest', async (request) => {
|
|
11
|
+
const leansOnCookie =
|
|
12
|
+
request.headers.cookie !== undefined && request.headers.authorization === undefined
|
|
13
|
+
|
|
14
|
+
if (SAFE.has(request.method) || !leansOnCookie) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (request.headers.origin !== webOrigin) {
|
|
19
|
+
throw new HttpError(403, 'Cross-origin request refused')
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
|
|
2
2
|
import 'reflect-metadata'
|
|
3
3
|
import { MikroORM } from '@mikro-orm/postgresql'
|
|
4
|
-
import type { Pool } from 'pg'
|
|
5
|
-
import { AUTH, type Auth } from '@/infra/auth/auth.factory'
|
|
6
4
|
import { loadEnv } from '@/infra/config/env'
|
|
7
5
|
import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
|
|
8
6
|
import { createContainer } from '@/infra/di'
|
|
@@ -18,10 +16,9 @@ async function main(): Promise<void> {
|
|
|
18
16
|
const container = createContainer({ env, orm })
|
|
19
17
|
const app = await buildApp(container)
|
|
20
18
|
|
|
21
|
-
// Handling the signals takes away Node's default exit, so
|
|
19
|
+
// Handling the signals takes away Node's default exit, so the pool must close for the process to end.
|
|
22
20
|
app.addHook('onClose', async () => {
|
|
23
21
|
await orm.close()
|
|
24
|
-
await (container.resolve<Auth>(AUTH).options.database as Pool).end()
|
|
25
22
|
})
|
|
26
23
|
|
|
27
24
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { createMcpHandler, type McpHttpHandler, McpServer } from '@modelcontextprotocol/server'
|
|
2
2
|
import type { DependencyContainer } from 'tsyringe'
|
|
3
|
+
import type { Env } from '@/infra/config/env'
|
|
3
4
|
import { registerUserTools } from './tools/users.tool'
|
|
4
5
|
|
|
6
|
+
// The protected resource MCP clients ask a token for; its access tokens carry it as their audience.
|
|
7
|
+
export function mcpResource(env: Pick<Env, 'API_URL'>): string {
|
|
8
|
+
return `${env.API_URL}/api/mcp`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function mcpResourceMetadataUrl(env: Pick<Env, 'API_URL'>): string {
|
|
12
|
+
return `${env.API_URL}/.well-known/oauth-protected-resource/api/mcp`
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
// One server per request: the protocol is stateless, and each instance knows only its caller.
|
|
6
16
|
export function createMcpServerHandler(container: DependencyContainer): McpHttpHandler {
|
|
7
17
|
return createMcpHandler(
|
|
@@ -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,21 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { AuthorizationCodes } from '@/domain/oauth/ports/authorization-codes.port'
|
|
3
|
+
|
|
4
|
+
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64url')
|
|
5
|
+
|
|
6
|
+
export class CryptoAuthorizationCodes implements AuthorizationCodes {
|
|
7
|
+
create(): string {
|
|
8
|
+
return randomBytes(32).toString('base64url')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest(code: string): string {
|
|
12
|
+
return sha256(code)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
matchesChallenge(verifier: string, challenge: string): boolean {
|
|
16
|
+
const expected = Buffer.from(sha256(verifier))
|
|
17
|
+
const given = Buffer.from(challenge)
|
|
18
|
+
|
|
19
|
+
return expected.length === given.length && timingSafeEqual(expected, given)
|
|
20
|
+
}
|
|
21
|
+
}
|