@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
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto'
|
|
2
2
|
import { createServer } from 'node:net'
|
|
3
3
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
4
|
-
import { testEnv
|
|
4
|
+
import { testEnv } from '@test/support/setup'
|
|
5
|
+
import { cookieHeader, testApp } from '@test/support/test-app'
|
|
5
6
|
import type { FastifyInstance } from 'fastify'
|
|
6
7
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
|
|
9
|
+
import { CLIENT_METADATA, type ClientMetadata } from '@/domain/oauth/ports/client-metadata.port'
|
|
10
|
+
import { TOOL_SCOPES } from '@/infra/mcp/mcp.scopes'
|
|
11
|
+
import { mcpResource } from '@/infra/mcp/mcp.server'
|
|
10
12
|
|
|
11
13
|
const ORIGIN = 'http://localhost:5173'
|
|
14
|
+
const CLIENT_ID = 'https://client.example.com/oauth/client.json'
|
|
12
15
|
// A web client needs an https redirect off loopback; the test never follows it.
|
|
13
16
|
const REDIRECT = 'https://client.example.com/callback'
|
|
14
17
|
|
|
15
18
|
let app: FastifyInstance
|
|
16
19
|
let env: ReturnType<typeof testEnv>
|
|
20
|
+
let register: Awaited<ReturnType<typeof testApp>>['signUp']
|
|
17
21
|
|
|
18
22
|
function freePort(): Promise<number> {
|
|
19
23
|
return new Promise((resolve) => {
|
|
@@ -24,11 +28,25 @@ function freePort(): Promise<number> {
|
|
|
24
28
|
})
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
//
|
|
31
|
+
// Stands in for the HTTPS fetch of the client's metadata document, which the adapter's own test covers.
|
|
32
|
+
const clients = {
|
|
33
|
+
async read(clientId: string): Promise<ClientMetadata> {
|
|
34
|
+
if (clientId !== CLIENT_ID) {
|
|
35
|
+
throw new InvalidClientError('Unknown client')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { clientId, clientName: 'Example Client', redirectUris: [REDIRECT] }
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The SDK's client reaches the MCP route over real HTTP, so the application listens.
|
|
28
43
|
beforeEach(async () => {
|
|
29
44
|
const port = await freePort()
|
|
30
|
-
env = { ...testEnv(),
|
|
31
|
-
|
|
45
|
+
env = { ...testEnv(), API_URL: `http://127.0.0.1:${port}` }
|
|
46
|
+
;({ app, signUp: register } = await testApp({
|
|
47
|
+
env,
|
|
48
|
+
configure: (container) => container.register(CLIENT_METADATA, { useValue: clients }),
|
|
49
|
+
}))
|
|
32
50
|
await app.listen({ port, host: '127.0.0.1' })
|
|
33
51
|
})
|
|
34
52
|
|
|
@@ -37,73 +55,77 @@ afterEach(async () => {
|
|
|
37
55
|
})
|
|
38
56
|
|
|
39
57
|
async function signUp(): Promise<string> {
|
|
40
|
-
|
|
41
|
-
method: 'POST',
|
|
42
|
-
url: '/api/auth/sign-up/email',
|
|
43
|
-
headers: { origin: ORIGIN },
|
|
44
|
-
payload: {
|
|
45
|
-
name: 'Ana',
|
|
46
|
-
email: `${crypto.randomUUID()}@example.com`,
|
|
47
|
-
password: 'correct-horse-battery',
|
|
48
|
-
},
|
|
49
|
-
})
|
|
50
|
-
const cookies = [response.headers['set-cookie']].flat().filter((c) => c !== undefined)
|
|
51
|
-
|
|
52
|
-
return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
|
|
58
|
+
return cookieHeader((await register()).response)
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
async function accessToken(cookie: string): Promise<string> {
|
|
57
|
-
const client = await app.inject({
|
|
58
|
-
method: 'POST',
|
|
59
|
-
url: '/api/auth/oauth2/create-client',
|
|
60
|
-
headers: { cookie, origin: ORIGIN },
|
|
61
|
-
payload: { redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none' },
|
|
62
|
-
})
|
|
63
|
-
const clientId = client.json().client_id as string
|
|
61
|
+
function pkce() {
|
|
64
62
|
const verifier = randomBytes(32).toString('base64url')
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function authorizeUrl(
|
|
68
|
+
overrides: Record<string, string | undefined> = {},
|
|
69
|
+
challenge = pkce().challenge,
|
|
70
|
+
) {
|
|
71
|
+
const params = {
|
|
66
72
|
response_type: 'code',
|
|
67
|
-
client_id:
|
|
73
|
+
client_id: CLIENT_ID,
|
|
68
74
|
redirect_uri: REDIRECT,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
75
|
+
state: 'xyz',
|
|
76
|
+
code_challenge: challenge,
|
|
72
77
|
code_challenge_method: 'S256',
|
|
73
78
|
resource: mcpResource(env),
|
|
79
|
+
...overrides,
|
|
80
|
+
}
|
|
81
|
+
const query = new URLSearchParams(
|
|
82
|
+
Object.entries(params).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return `/api/oauth/authorize?${query}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function token(form: Record<string, string>) {
|
|
89
|
+
return app.inject({
|
|
90
|
+
method: 'POST',
|
|
91
|
+
url: '/api/oauth/token',
|
|
92
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
93
|
+
payload: new URLSearchParams(form).toString(),
|
|
74
94
|
})
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The path an MCP client takes: authorize with PKCE, the user consents on the web, the code becomes tokens.
|
|
98
|
+
async function authorize(cookie: string, accept = true, scope?: string) {
|
|
99
|
+
const { verifier, challenge } = pkce()
|
|
100
|
+
const started = await app.inject({ url: authorizeUrl({ scope }, challenge) })
|
|
101
|
+
const consent = new URL(started.headers.location as string)
|
|
102
|
+
const id = consent.searchParams.get('request') ?? ''
|
|
103
|
+
const view = await app.inject({ url: `/api/oauth/authorizations/${id}`, headers: { cookie } })
|
|
104
|
+
const decision = await app.inject({
|
|
81
105
|
method: 'POST',
|
|
82
|
-
url:
|
|
106
|
+
url: `/api/oauth/authorizations/${id}/decision`,
|
|
83
107
|
headers: { cookie, origin: ORIGIN },
|
|
84
|
-
payload: { accept
|
|
108
|
+
payload: { accept },
|
|
85
109
|
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
|
|
111
|
+
return { started, consent, view, redirect: new URL(decision.json().redirectTo), verifier }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function tokens(cookie: string, scope?: string) {
|
|
115
|
+
const { redirect, verifier } = await authorize(cookie, true, scope)
|
|
116
|
+
const response = await token({
|
|
117
|
+
grant_type: 'authorization_code',
|
|
118
|
+
code: redirect.searchParams.get('code') ?? '',
|
|
119
|
+
redirect_uri: REDIRECT,
|
|
120
|
+
client_id: CLIENT_ID,
|
|
121
|
+
code_verifier: verifier,
|
|
122
|
+
resource: mcpResource(env),
|
|
99
123
|
})
|
|
100
124
|
|
|
101
|
-
return
|
|
125
|
+
return { response, code: redirect.searchParams.get('code') ?? '', verifier }
|
|
102
126
|
}
|
|
103
127
|
|
|
104
|
-
|
|
105
|
-
async function connect(token: string): Promise<Client> {
|
|
106
|
-
// The server rejects 2025-era traffic, as Better Auth's MCP profile asks.
|
|
128
|
+
async function connect(accessToken: string): Promise<Client> {
|
|
107
129
|
const client = new Client(
|
|
108
130
|
{ name: 'test', version: '1.0.0' },
|
|
109
131
|
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
|
|
@@ -111,25 +133,169 @@ async function connect(token: string): Promise<Client> {
|
|
|
111
133
|
|
|
112
134
|
await client.connect(
|
|
113
135
|
new StreamableHTTPClientTransport(new URL(mcpResource(env)), {
|
|
114
|
-
requestInit: { headers: { authorization: `Bearer ${
|
|
136
|
+
requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
|
|
115
137
|
}),
|
|
116
138
|
)
|
|
117
139
|
return client
|
|
118
140
|
}
|
|
119
141
|
|
|
120
|
-
describe('
|
|
121
|
-
it('tells an MCP client where to authorize', async () => {
|
|
122
|
-
const metadata = await app.inject({ url: '/.well-known/oauth-protected-resource' })
|
|
142
|
+
describe('discovery', () => {
|
|
143
|
+
it('tells an MCP client where to authorize, from the 401 to the server metadata', async () => {
|
|
123
144
|
const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
|
|
145
|
+
const challenge = refused.headers['www-authenticate'] as string
|
|
146
|
+
const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? ''
|
|
147
|
+
const resource = await app.inject({ url: new URL(metadataUrl).pathname })
|
|
148
|
+
const server = await app.inject({ url: '/.well-known/oauth-authorization-server' })
|
|
124
149
|
|
|
125
|
-
expect(metadata.json()).toMatchObject({ resource: mcpResource(env) })
|
|
126
150
|
expect(refused.statusCode).toBe(401)
|
|
127
|
-
expect(
|
|
151
|
+
expect(resource.json()).toMatchObject({
|
|
152
|
+
resource: mcpResource(env),
|
|
153
|
+
authorization_servers: [env.API_URL],
|
|
154
|
+
})
|
|
155
|
+
expect(server.json()).toMatchObject({
|
|
156
|
+
issuer: env.API_URL,
|
|
157
|
+
code_challenge_methods_supported: ['S256'],
|
|
158
|
+
client_id_metadata_document_supported: true,
|
|
159
|
+
authorization_response_iss_parameter_supported: true,
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('authorization', () => {
|
|
165
|
+
it('sends the browser to consent, shows who asks and where the code goes, and returns it with iss', async () => {
|
|
166
|
+
const { started, consent, view, redirect } = await authorize(await signUp())
|
|
167
|
+
|
|
168
|
+
expect(started.statusCode).toBe(302)
|
|
169
|
+
expect(`${consent.origin}${consent.pathname}`).toBe(`${ORIGIN}/consent`)
|
|
170
|
+
expect(view.json()).toEqual({
|
|
171
|
+
clientId: CLIENT_ID,
|
|
172
|
+
clientName: 'Example Client',
|
|
173
|
+
clientHost: 'client.example.com',
|
|
174
|
+
redirectHost: 'client.example.com',
|
|
175
|
+
redirectsToThisDevice: false,
|
|
176
|
+
// Asked for nothing, so offered everything, each scope worded for a person.
|
|
177
|
+
scopes: [
|
|
178
|
+
{ scope: 'profile:read', description: 'Read your profile' },
|
|
179
|
+
{ scope: 'profile:write', description: 'Change your display name, language and timezone' },
|
|
180
|
+
],
|
|
181
|
+
})
|
|
182
|
+
expect(`${redirect.origin}${redirect.pathname}`).toBe(REDIRECT)
|
|
183
|
+
expect(redirect.searchParams.get('state')).toBe('xyz')
|
|
184
|
+
expect(redirect.searchParams.get('iss')).toBe(env.API_URL)
|
|
185
|
+
expect(redirect.searchParams.get('code')).not.toBeNull()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('answers a denial with access_denied', async () => {
|
|
189
|
+
const { redirect } = await authorize(await signUp(), false)
|
|
190
|
+
|
|
191
|
+
expect(redirect.searchParams.get('error')).toBe('access_denied')
|
|
192
|
+
expect(redirect.searchParams.get('code')).toBeNull()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('never redirects for an unknown client or an unregistered redirect URI', async () => {
|
|
196
|
+
for (const overrides of [
|
|
197
|
+
{ client_id: 'https://evil.example.com/client.json' },
|
|
198
|
+
{ redirect_uri: 'https://evil.example.com/callback' },
|
|
199
|
+
]) {
|
|
200
|
+
const response = await app.inject({ url: authorizeUrl(overrides) })
|
|
201
|
+
|
|
202
|
+
expect(response.statusCode).toBe(422)
|
|
203
|
+
expect(response.headers.location).toBeUndefined()
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('sends a known client its error: no PKCE, plain PKCE, another resource', async () => {
|
|
208
|
+
for (const [overrides, error] of [
|
|
209
|
+
[{ code_challenge_method: undefined }, 'invalid_request'],
|
|
210
|
+
[{ code_challenge_method: 'plain' }, 'invalid_request'],
|
|
211
|
+
[{ resource: 'https://elsewhere.example.com/mcp' }, 'invalid_target'],
|
|
212
|
+
] as const) {
|
|
213
|
+
const response = await app.inject({ url: authorizeUrl(overrides) })
|
|
214
|
+
const location = new URL(response.headers.location as string)
|
|
215
|
+
|
|
216
|
+
expect(location.searchParams.get('error')).toBe(error)
|
|
217
|
+
expect(location.searchParams.get('state')).toBe('xyz')
|
|
218
|
+
expect(location.searchParams.get('iss')).toBe(env.API_URL)
|
|
219
|
+
}
|
|
128
220
|
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('scopes', () => {
|
|
224
|
+
it('are advertised, and named in the 401 a client starts from', async () => {
|
|
225
|
+
const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
|
|
226
|
+
const resource = await app.inject({ url: '/.well-known/oauth-protected-resource/api/mcp' })
|
|
227
|
+
const server = await app.inject({ url: '/.well-known/oauth-authorization-server' })
|
|
228
|
+
|
|
229
|
+
expect(refused.headers['www-authenticate']).toContain('scope="profile:read profile:write"')
|
|
230
|
+
expect(resource.json().scopes_supported).toEqual(['profile:read', 'profile:write'])
|
|
231
|
+
expect(server.json().scopes_supported).toEqual(['profile:read', 'profile:write'])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('let a read-only token read, and refuse its write with the scope it needs', async () => {
|
|
235
|
+
const { response } = await tokens(await signUp(), 'profile:read')
|
|
236
|
+
const { access_token, scope } = response.json()
|
|
237
|
+
const client = await connect(access_token)
|
|
238
|
+
const read = await client.callTool({ name: 'get_profile', arguments: {} })
|
|
239
|
+
const write = await app.inject({
|
|
240
|
+
method: 'POST',
|
|
241
|
+
url: '/api/mcp',
|
|
242
|
+
headers: {
|
|
243
|
+
authorization: `Bearer ${access_token}`,
|
|
244
|
+
accept: 'application/json, text/event-stream',
|
|
245
|
+
},
|
|
246
|
+
payload: {
|
|
247
|
+
jsonrpc: '2.0',
|
|
248
|
+
id: 1,
|
|
249
|
+
method: 'tools/call',
|
|
250
|
+
params: { name: 'update_profile', arguments: { timezone: 'UTC' } },
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
expect(scope).toBe('profile:read')
|
|
255
|
+
expect(read.structuredContent).toMatchObject({ displayName: 'Ana' })
|
|
256
|
+
expect(write.statusCode).toBe(403)
|
|
257
|
+
expect(write.headers['www-authenticate']).toContain('error="insufficient_scope"')
|
|
258
|
+
expect(write.headers['www-authenticate']).toContain('scope="profile:write"')
|
|
259
|
+
|
|
260
|
+
await client.close()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// A tool missing from TOOL_SCOPES would answer any token, so every tool the server lists must be there.
|
|
264
|
+
it('cover every tool the server offers', async () => {
|
|
265
|
+
const client = await connect((await tokens(await signUp())).response.json().access_token)
|
|
266
|
+
const { tools } = await client.listTools()
|
|
129
267
|
|
|
268
|
+
expect(tools.length).toBeGreaterThan(0)
|
|
269
|
+
for (const tool of tools) {
|
|
270
|
+
expect(TOOL_SCOPES[tool.name], tool.name).toBeDefined()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await client.close()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('keep the scope through a refresh, and refuse one the server does not offer', async () => {
|
|
277
|
+
const { response } = await tokens(await signUp(), 'profile:read')
|
|
278
|
+
const refreshed = await token({
|
|
279
|
+
grant_type: 'refresh_token',
|
|
280
|
+
refresh_token: response.json().refresh_token,
|
|
281
|
+
client_id: CLIENT_ID,
|
|
282
|
+
})
|
|
283
|
+
const unknown = await app.inject({ url: authorizeUrl({ scope: 'billing:write' }) })
|
|
284
|
+
|
|
285
|
+
expect(refreshed.json().scope).toBe('profile:read')
|
|
286
|
+
expect(new URL(unknown.headers.location as string).searchParams.get('error')).toBe(
|
|
287
|
+
'invalid_scope',
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('token', () => {
|
|
130
293
|
it('runs a tool for the user who authorized the client, with public fields only', async () => {
|
|
131
|
-
const
|
|
294
|
+
const { response } = await tokens(await signUp())
|
|
295
|
+
const client = await connect(response.json().access_token)
|
|
132
296
|
const result = await client.callTool({ name: 'get_profile', arguments: {} })
|
|
297
|
+
|
|
298
|
+
expect(response.headers['cache-control']).toBe('no-store')
|
|
133
299
|
expect(Object.keys(result.structuredContent ?? {}).sort()).toEqual(
|
|
134
300
|
['createdAt', 'displayName', 'id', 'locale', 'timezone', 'updatedAt', 'userId'].sort(),
|
|
135
301
|
)
|
|
@@ -139,7 +305,8 @@ describe('mcp controller', () => {
|
|
|
139
305
|
})
|
|
140
306
|
|
|
141
307
|
it('changes the profile through a tool', async () => {
|
|
142
|
-
const
|
|
308
|
+
const { response } = await tokens(await signUp())
|
|
309
|
+
const client = await connect(response.json().access_token)
|
|
143
310
|
const result = await client.callTool({
|
|
144
311
|
name: 'update_profile',
|
|
145
312
|
arguments: { timezone: 'America/Sao_Paulo' },
|
|
@@ -149,4 +316,79 @@ describe('mcp controller', () => {
|
|
|
149
316
|
|
|
150
317
|
await client.close()
|
|
151
318
|
})
|
|
319
|
+
|
|
320
|
+
it('refuses a wrong verifier, and a code spent twice ends what the first use obtained', async () => {
|
|
321
|
+
const cookie = await signUp()
|
|
322
|
+
const { redirect } = await authorize(cookie)
|
|
323
|
+
const wrong = await token({
|
|
324
|
+
grant_type: 'authorization_code',
|
|
325
|
+
code: redirect.searchParams.get('code') ?? '',
|
|
326
|
+
redirect_uri: REDIRECT,
|
|
327
|
+
client_id: CLIENT_ID,
|
|
328
|
+
code_verifier: pkce().verifier,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(wrong.statusCode).toBe(400)
|
|
332
|
+
expect(wrong.json().error).toBe('invalid_grant')
|
|
333
|
+
|
|
334
|
+
const { response, code, verifier } = await tokens(cookie)
|
|
335
|
+
const replay = await token({
|
|
336
|
+
grant_type: 'authorization_code',
|
|
337
|
+
code,
|
|
338
|
+
redirect_uri: REDIRECT,
|
|
339
|
+
client_id: CLIENT_ID,
|
|
340
|
+
code_verifier: verifier,
|
|
341
|
+
})
|
|
342
|
+
const refresh = await token({
|
|
343
|
+
grant_type: 'refresh_token',
|
|
344
|
+
refresh_token: response.json().refresh_token,
|
|
345
|
+
client_id: CLIENT_ID,
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
expect(replay.json().error).toBe('invalid_grant')
|
|
349
|
+
expect(refresh.json().error).toBe('invalid_grant')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('rotates the refresh token, and only for the client it was issued to', async () => {
|
|
353
|
+
const { response } = await tokens(await signUp())
|
|
354
|
+
const refreshToken = response.json().refresh_token
|
|
355
|
+
const otherClient = await token({
|
|
356
|
+
grant_type: 'refresh_token',
|
|
357
|
+
refresh_token: refreshToken,
|
|
358
|
+
client_id: 'https://other.example.com/client.json',
|
|
359
|
+
})
|
|
360
|
+
const rotated = await token({
|
|
361
|
+
grant_type: 'refresh_token',
|
|
362
|
+
refresh_token: refreshToken,
|
|
363
|
+
client_id: CLIENT_ID,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
expect(otherClient.json().error).toBe('invalid_grant')
|
|
367
|
+
expect(rotated.statusCode).toBe(200)
|
|
368
|
+
expect(rotated.json().refresh_token).not.toBe(refreshToken)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('keeps the MCP token and the application’s tokens apart', async () => {
|
|
372
|
+
const cookie = await signUp()
|
|
373
|
+
const { response } = await tokens(cookie)
|
|
374
|
+
const access = cookie
|
|
375
|
+
.split('; ')
|
|
376
|
+
.find((pair) => pair.startsWith('access_token='))
|
|
377
|
+
?.slice(13)
|
|
378
|
+
|
|
379
|
+
const mcpTokenOnApi = await app.inject({
|
|
380
|
+
url: '/api/v1/users/me',
|
|
381
|
+
headers: { authorization: `Bearer ${response.json().access_token}` },
|
|
382
|
+
})
|
|
383
|
+
const apiTokenOnMcp = await app.inject({
|
|
384
|
+
method: 'POST',
|
|
385
|
+
url: '/api/mcp',
|
|
386
|
+
headers: { authorization: `Bearer ${access}` },
|
|
387
|
+
payload: {},
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
expect(mcpTokenOnApi.statusCode).toBe(401)
|
|
391
|
+
expect(apiTokenOnMcp.statusCode).toBe(401)
|
|
392
|
+
expect(apiTokenOnMcp.headers['www-authenticate']).toContain('error="invalid_token"')
|
|
393
|
+
})
|
|
152
394
|
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { FakeIdentityProviders } from '@test/support/fakes/social-fakes'
|
|
2
|
+
import { testEnv } from '@test/support/setup'
|
|
3
|
+
import { testApp } from '@test/support/test-app'
|
|
4
|
+
import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
|
|
7
|
+
|
|
8
|
+
const WEB = 'http://localhost:5173'
|
|
9
|
+
|
|
10
|
+
// Whichever provider the project kept: the flow is the same for each.
|
|
11
|
+
const PROVIDER = [
|
|
12
|
+
'google', // prumo:google
|
|
13
|
+
'apple', // prumo:apple
|
|
14
|
+
][0]
|
|
15
|
+
|
|
16
|
+
let app: FastifyInstance
|
|
17
|
+
|
|
18
|
+
function browserCookie(response: LightMyRequestResponse): string {
|
|
19
|
+
const cookie = response.cookies.find((each) => each.name === 'social_sign_in')
|
|
20
|
+
return `social_sign_in=${cookie?.value ?? ''}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stateOf(response: LightMyRequestResponse): string {
|
|
24
|
+
return new URL(response.headers.location as string).searchParams.get('state') ?? ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
;({ app } = await testApp({
|
|
29
|
+
env: { ...testEnv(), MOBILE_APP_SCHEME: 'acme' },
|
|
30
|
+
configure: (container) =>
|
|
31
|
+
container.register(IDENTITY_PROVIDERS, { useValue: new FakeIdentityProviders() }),
|
|
32
|
+
}))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await app.close()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('social sign-in on the web', () => {
|
|
40
|
+
it('sends the browser to the provider and back into the app, signed in', async () => {
|
|
41
|
+
const started = await app.inject({ url: `/api/auth/social/${PROVIDER}?returnTo=/orders` })
|
|
42
|
+
const back = await app.inject({
|
|
43
|
+
url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
|
|
44
|
+
headers: { cookie: browserCookie(started) },
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
expect(started.statusCode).toBe(302)
|
|
48
|
+
expect(started.headers.location).toMatch(/^https:\/\/\w+\.test\/authorize/)
|
|
49
|
+
expect(back.statusCode).toBe(302)
|
|
50
|
+
expect(back.headers.location).toBe(`${WEB}/orders`)
|
|
51
|
+
expect(back.cookies.map((cookie) => cookie.name)).toEqual(
|
|
52
|
+
expect.arrayContaining(['access_token', 'refresh_token']),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('refuses a return without the browser that started it, and a foreign returnTo', async () => {
|
|
57
|
+
const started = await app.inject({
|
|
58
|
+
url: `/api/auth/social/${PROVIDER}?returnTo=//evil.example`,
|
|
59
|
+
})
|
|
60
|
+
const forwarded = await app.inject({
|
|
61
|
+
url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
|
|
62
|
+
})
|
|
63
|
+
const back = await app.inject({
|
|
64
|
+
url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
|
|
65
|
+
headers: { cookie: browserCookie(started) },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(forwarded.headers.location).toBe(`${WEB}/sign-in?error=social_sign_in_failed`)
|
|
69
|
+
expect(back.headers.location).toBe(`${WEB}/`)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('turns a form post, as Apple sends, into a redirect the browser’s cookie comes with', async () => {
|
|
73
|
+
const posted = await app.inject({
|
|
74
|
+
method: 'POST',
|
|
75
|
+
url: `/api/auth/social/${PROVIDER}/callback`,
|
|
76
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
77
|
+
payload: 'code=c&state=s&user=%7B%7D',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(posted.statusCode).toBe(303)
|
|
81
|
+
expect(posted.headers.location).toBe(
|
|
82
|
+
`http://localhost:3000/api/auth/social/${PROVIDER}/callback?code=c&state=s&user=%7B%7D`,
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('social sign-in on mobile', () => {
|
|
88
|
+
it('sends the app a code, which it trades once for its tokens', async () => {
|
|
89
|
+
const started = await app.inject({ url: `/api/auth/social/${PROVIDER}?client=mobile` })
|
|
90
|
+
const back = await app.inject({
|
|
91
|
+
url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
|
|
92
|
+
headers: { cookie: browserCookie(started) },
|
|
93
|
+
})
|
|
94
|
+
const location = new URL(back.headers.location as string)
|
|
95
|
+
const code = location.searchParams.get('code') ?? ''
|
|
96
|
+
const exchange = () =>
|
|
97
|
+
app.inject({
|
|
98
|
+
method: 'POST',
|
|
99
|
+
url: '/api/auth/social/exchange',
|
|
100
|
+
headers: { 'x-auth-transport': 'bearer' },
|
|
101
|
+
payload: { code },
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(`${location.protocol}//${location.host}`).toBe('acme://social')
|
|
105
|
+
expect((await exchange()).json().accessToken).toEqual(expect.any(String))
|
|
106
|
+
expect((await exchange()).statusCode).toBe(401)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -1,37 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cookieHeader, ORIGIN, testApp } from '@test/support/test-app'
|
|
2
2
|
import type { FastifyInstance } from 'fastify'
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
-
import { createContainer } from '@/infra/di'
|
|
5
|
-
import { buildApp } from '@/infra/http/app'
|
|
6
|
-
|
|
7
|
-
const ORIGIN = 'http://localhost:5173'
|
|
8
4
|
|
|
9
5
|
let app: FastifyInstance
|
|
6
|
+
let signUp: Awaited<ReturnType<typeof testApp>>['signUp']
|
|
10
7
|
|
|
11
|
-
async function
|
|
12
|
-
|
|
13
|
-
method: 'POST',
|
|
14
|
-
url: '/api/auth/sign-up/email',
|
|
15
|
-
headers: { origin: ORIGIN },
|
|
16
|
-
payload: {
|
|
17
|
-
name: 'Ana',
|
|
18
|
-
email: `${crypto.randomUUID()}@example.com`,
|
|
19
|
-
password: 'correct-horse-battery',
|
|
20
|
-
},
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
expect(response.statusCode).toBe(200)
|
|
24
|
-
|
|
25
|
-
const cookies = response.headers['set-cookie']
|
|
26
|
-
return Array.isArray(cookies) ? cookies : [cookies ?? '']
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function cookieHeader(cookies: string[]): string {
|
|
30
|
-
return cookies.map((cookie) => cookie.split(';')[0]).join('; ')
|
|
8
|
+
async function signedIn(): Promise<string> {
|
|
9
|
+
return cookieHeader((await signUp()).response)
|
|
31
10
|
}
|
|
32
11
|
|
|
33
12
|
beforeEach(async () => {
|
|
34
|
-
app = await
|
|
13
|
+
;({ app, signUp } = await testApp())
|
|
35
14
|
})
|
|
36
15
|
|
|
37
16
|
afterEach(async () => {
|
|
@@ -47,8 +26,10 @@ describe('users controller', () => {
|
|
|
47
26
|
})
|
|
48
27
|
|
|
49
28
|
it('returns the profile created at sign-up, and only its public fields', async () => {
|
|
50
|
-
const
|
|
51
|
-
|
|
29
|
+
const response = await app.inject({
|
|
30
|
+
url: '/api/v1/users/me',
|
|
31
|
+
headers: { cookie: await signedIn() },
|
|
32
|
+
})
|
|
52
33
|
|
|
53
34
|
expect(response.statusCode).toBe(200)
|
|
54
35
|
expect(Object.keys(response.json()).sort()).toEqual(
|
|
@@ -58,11 +39,10 @@ describe('users controller', () => {
|
|
|
58
39
|
})
|
|
59
40
|
|
|
60
41
|
it('updates the profile', async () => {
|
|
61
|
-
const cookie = cookieHeader(await signUp())
|
|
62
42
|
const response = await app.inject({
|
|
63
43
|
method: 'PATCH',
|
|
64
44
|
url: '/api/v1/users/me',
|
|
65
|
-
headers: { cookie, origin: ORIGIN },
|
|
45
|
+
headers: { cookie: await signedIn(), origin: ORIGIN },
|
|
66
46
|
payload: { timezone: 'America/Sao_Paulo' },
|
|
67
47
|
})
|
|
68
48
|
|
|
@@ -71,11 +51,10 @@ describe('users controller', () => {
|
|
|
71
51
|
})
|
|
72
52
|
|
|
73
53
|
it('names every rejected field, including one the schema does not know', async () => {
|
|
74
|
-
const cookie = cookieHeader(await signUp())
|
|
75
54
|
const response = await app.inject({
|
|
76
55
|
method: 'PATCH',
|
|
77
56
|
url: '/api/v1/users/me',
|
|
78
|
-
headers: { cookie, origin: ORIGIN },
|
|
57
|
+
headers: { cookie: await signedIn(), origin: ORIGIN },
|
|
79
58
|
payload: { displayName: '', role: 'admin' },
|
|
80
59
|
})
|
|
81
60
|
|
|
@@ -87,14 +66,13 @@ describe('users controller', () => {
|
|
|
87
66
|
})
|
|
88
67
|
|
|
89
68
|
it('signs out', async () => {
|
|
90
|
-
const cookie = cookieHeader(await signUp())
|
|
91
69
|
const response = await app.inject({
|
|
92
70
|
method: 'POST',
|
|
93
71
|
url: '/api/auth/sign-out',
|
|
94
|
-
headers: { cookie, origin: ORIGIN },
|
|
72
|
+
headers: { cookie: await signedIn(), origin: ORIGIN },
|
|
95
73
|
})
|
|
96
74
|
|
|
97
|
-
expect(response.statusCode).toBe(
|
|
75
|
+
expect(response.statusCode).toBe(204)
|
|
98
76
|
})
|
|
99
77
|
|
|
100
78
|
it('answers an unknown route with 404, not 401', async () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { CryptoAuthorizationCodes } from '@/infra/oauth/crypto-authorization-codes.adapter'
|
|
3
|
+
|
|
4
|
+
describe('CryptoAuthorizationCodes', () => {
|
|
5
|
+
// The example in RFC 7636, Appendix B.
|
|
6
|
+
it('matches the RFC’s own S256 example, and nothing else', () => {
|
|
7
|
+
const codes = new CryptoAuthorizationCodes()
|
|
8
|
+
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
|
|
9
|
+
|
|
10
|
+
expect(codes.matchesChallenge(verifier, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM')).toBe(
|
|
11
|
+
true,
|
|
12
|
+
)
|
|
13
|
+
expect(codes.matchesChallenge(verifier, verifier)).toBe(false)
|
|
14
|
+
expect(
|
|
15
|
+
codes.matchesChallenge(`${verifier}x`, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'),
|
|
16
|
+
).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
})
|