@stjoseph/prumo 0.2.0 → 0.3.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 +50 -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 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- 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/README.md +2 -2
- package/dist/clean.js +2 -2
- package/dist/compose.js +1 -9
- package/dist/generate.js +3 -4
- package/dist/mcp.js +15 -8
- package/package.json +1 -1
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
- package/templates/api/package.json +6 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/refresh.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/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -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/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/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/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -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 +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -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 +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -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 +14 -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 +96 -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 +75 -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 +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -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 +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -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/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -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/mcp/mcp.server.ts +10 -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/test/domain/auth/entities/session.entity.spec.ts +50 -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 +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -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/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- 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/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -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-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- 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 +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -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/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- 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 +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- 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/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- 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,201 @@
|
|
|
1
|
+
import { testEnv, testOrm } from '@test/support/setup'
|
|
2
|
+
import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
|
|
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
|
+
const BEARER = { 'x-auth-transport': 'bearer' }
|
|
9
|
+
|
|
10
|
+
let app: FastifyInstance
|
|
11
|
+
|
|
12
|
+
const account = () => ({
|
|
13
|
+
name: 'Ana',
|
|
14
|
+
email: `${crypto.randomUUID()}@example.com`,
|
|
15
|
+
password: 'correct-horse-battery',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function cookiesOf(response: LightMyRequestResponse): Record<string, string> {
|
|
19
|
+
return Object.fromEntries(response.cookies.map((cookie) => [cookie.name, cookie.value]))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setCookie(response: LightMyRequestResponse, name: string) {
|
|
23
|
+
return response.cookies.find((cookie) => cookie.name === name)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
app = await buildApp(createContainer({ env: testEnv(), orm: testOrm() }))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await app.close()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('auth controller, on the web', () => {
|
|
35
|
+
it('signs up into httpOnly cookies, the refresh one sent only to its route', async () => {
|
|
36
|
+
const response = await app.inject({
|
|
37
|
+
method: 'POST',
|
|
38
|
+
url: '/api/auth/sign-up',
|
|
39
|
+
headers: { origin: ORIGIN },
|
|
40
|
+
payload: account(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(response.statusCode).toBe(201)
|
|
44
|
+
expect(response.body).toBe('')
|
|
45
|
+
expect(setCookie(response, 'access_token')).toMatchObject({
|
|
46
|
+
httpOnly: true,
|
|
47
|
+
sameSite: 'Lax',
|
|
48
|
+
path: '/',
|
|
49
|
+
})
|
|
50
|
+
expect(setCookie(response, 'refresh_token')).toMatchObject({
|
|
51
|
+
httpOnly: true,
|
|
52
|
+
path: '/api/auth/refresh',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('reads the session from the cookie, refreshes it, and signs out', async () => {
|
|
57
|
+
const signUp = await app.inject({
|
|
58
|
+
method: 'POST',
|
|
59
|
+
url: '/api/auth/sign-up',
|
|
60
|
+
headers: { origin: ORIGIN },
|
|
61
|
+
payload: account(),
|
|
62
|
+
})
|
|
63
|
+
const { access_token, refresh_token } = cookiesOf(signUp)
|
|
64
|
+
|
|
65
|
+
const session = await app.inject({
|
|
66
|
+
url: '/api/auth/session',
|
|
67
|
+
headers: { cookie: `access_token=${access_token}` },
|
|
68
|
+
})
|
|
69
|
+
expect(session.json()).toMatchObject({ user: { id: expect.any(String) } })
|
|
70
|
+
|
|
71
|
+
const refreshed = await app.inject({
|
|
72
|
+
method: 'POST',
|
|
73
|
+
url: '/api/auth/refresh',
|
|
74
|
+
headers: { cookie: `refresh_token=${refresh_token}`, origin: ORIGIN },
|
|
75
|
+
})
|
|
76
|
+
expect(refreshed.statusCode).toBe(204)
|
|
77
|
+
expect(cookiesOf(refreshed).refresh_token).not.toBe(refresh_token)
|
|
78
|
+
|
|
79
|
+
const signOut = await app.inject({
|
|
80
|
+
method: 'POST',
|
|
81
|
+
url: '/api/auth/sign-out',
|
|
82
|
+
headers: { cookie: `access_token=${cookiesOf(refreshed).access_token}`, origin: ORIGIN },
|
|
83
|
+
})
|
|
84
|
+
expect(signOut.statusCode).toBe(204)
|
|
85
|
+
|
|
86
|
+
const afterSignOut = await app.inject({
|
|
87
|
+
method: 'POST',
|
|
88
|
+
url: '/api/auth/refresh',
|
|
89
|
+
headers: { cookie: `refresh_token=${cookiesOf(refreshed).refresh_token}`, origin: ORIGIN },
|
|
90
|
+
})
|
|
91
|
+
expect(afterSignOut.statusCode).toBe(401)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('refuses a request that leans on a cookie from another origin, or from none', async () => {
|
|
95
|
+
const signUp = await app.inject({
|
|
96
|
+
method: 'POST',
|
|
97
|
+
url: '/api/auth/sign-up',
|
|
98
|
+
headers: { origin: ORIGIN },
|
|
99
|
+
payload: account(),
|
|
100
|
+
})
|
|
101
|
+
const cookie = `access_token=${cookiesOf(signUp).access_token}`
|
|
102
|
+
|
|
103
|
+
for (const headers of [{ cookie, origin: 'https://evil.example' }, { cookie }]) {
|
|
104
|
+
const response = await app.inject({ method: 'POST', url: '/api/auth/sign-out', headers })
|
|
105
|
+
|
|
106
|
+
expect(response.statusCode).toBe(403)
|
|
107
|
+
expect(response.headers['content-type']).toContain('application/problem+json')
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('auth controller, for a native client', () => {
|
|
113
|
+
it('hands the tokens over in the body, and accepts them as a bearer', async () => {
|
|
114
|
+
const signUp = await app.inject({
|
|
115
|
+
method: 'POST',
|
|
116
|
+
url: '/api/auth/sign-up',
|
|
117
|
+
headers: BEARER,
|
|
118
|
+
payload: account(),
|
|
119
|
+
})
|
|
120
|
+
const tokens = signUp.json()
|
|
121
|
+
|
|
122
|
+
expect(signUp.statusCode).toBe(201)
|
|
123
|
+
expect(signUp.cookies).toEqual([])
|
|
124
|
+
expect(tokens).toEqual({
|
|
125
|
+
accessToken: expect.any(String),
|
|
126
|
+
refreshToken: expect.any(String),
|
|
127
|
+
expiresIn: 900,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const session = await app.inject({
|
|
131
|
+
url: '/api/auth/session',
|
|
132
|
+
headers: { authorization: `Bearer ${tokens.accessToken}` },
|
|
133
|
+
})
|
|
134
|
+
expect(session.statusCode).toBe(200)
|
|
135
|
+
|
|
136
|
+
const refreshed = await app.inject({
|
|
137
|
+
method: 'POST',
|
|
138
|
+
url: '/api/auth/refresh',
|
|
139
|
+
headers: BEARER,
|
|
140
|
+
payload: { refreshToken: tokens.refreshToken },
|
|
141
|
+
})
|
|
142
|
+
expect(refreshed.statusCode).toBe(200)
|
|
143
|
+
expect(refreshed.json().refreshToken).not.toBe(tokens.refreshToken)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('auth controller, refusing', () => {
|
|
148
|
+
it('answers a wrong password with 401 and a taken email with 409, as problem+json', async () => {
|
|
149
|
+
const ana = account()
|
|
150
|
+
await app.inject({ method: 'POST', url: '/api/auth/sign-up', headers: BEARER, payload: ana })
|
|
151
|
+
|
|
152
|
+
const wrong = await app.inject({
|
|
153
|
+
method: 'POST',
|
|
154
|
+
url: '/api/auth/sign-in',
|
|
155
|
+
headers: BEARER,
|
|
156
|
+
payload: { email: ana.email, password: 'wrong-password' },
|
|
157
|
+
})
|
|
158
|
+
const taken = await app.inject({
|
|
159
|
+
method: 'POST',
|
|
160
|
+
url: '/api/auth/sign-up',
|
|
161
|
+
headers: BEARER,
|
|
162
|
+
payload: ana,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
expect(wrong.statusCode).toBe(401)
|
|
166
|
+
expect(wrong.json()).toMatchObject({ detail: 'Invalid email or password' })
|
|
167
|
+
expect(taken.statusCode).toBe(409)
|
|
168
|
+
expect(taken.headers['content-type']).toContain('application/problem+json')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('names the fields a sign-up gets wrong', async () => {
|
|
172
|
+
const response = await app.inject({
|
|
173
|
+
method: 'POST',
|
|
174
|
+
url: '/api/auth/sign-up',
|
|
175
|
+
headers: BEARER,
|
|
176
|
+
payload: { name: '', email: 'not-an-email', password: 'short' },
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(response.statusCode).toBe(400)
|
|
180
|
+
expect(Object.keys(response.json().errors).sort()).toEqual(['email', 'name', 'password'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('slows down repeated sign-in attempts from one address', async () => {
|
|
184
|
+
const attempt = () =>
|
|
185
|
+
app.inject({
|
|
186
|
+
method: 'POST',
|
|
187
|
+
url: '/api/auth/sign-in',
|
|
188
|
+
headers: BEARER,
|
|
189
|
+
payload: { email: 'nobody@example.com', password: 'whatever' },
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
for (let i = 0; i < 10; i += 1) {
|
|
193
|
+
expect((await attempt()).statusCode).toBe(401)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const limited = await attempt()
|
|
197
|
+
|
|
198
|
+
expect(limited.statusCode).toBe(429)
|
|
199
|
+
expect(limited.headers['content-type']).toContain('application/problem+json')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -4,11 +4,14 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli
|
|
|
4
4
|
import { testEnv, testOrm } from '@test/support/setup'
|
|
5
5
|
import type { FastifyInstance } from 'fastify'
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
7
|
-
import {
|
|
7
|
+
import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
|
|
8
|
+
import { CLIENT_METADATA, type ClientMetadata } from '@/domain/oauth/ports/client-metadata.port'
|
|
8
9
|
import { createContainer } from '@/infra/di'
|
|
9
10
|
import { buildApp } from '@/infra/http/app'
|
|
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
|
|
|
@@ -24,11 +27,25 @@ function freePort(): Promise<number> {
|
|
|
24
27
|
})
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
//
|
|
30
|
+
// Stands in for the HTTPS fetch of the client's metadata document, which the adapter's own test covers.
|
|
31
|
+
const clients = {
|
|
32
|
+
async read(clientId: string): Promise<ClientMetadata> {
|
|
33
|
+
if (clientId !== CLIENT_ID) {
|
|
34
|
+
throw new InvalidClientError('Unknown client')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { clientId, clientName: 'Example Client', redirectUris: [REDIRECT] }
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The SDK's client reaches the MCP route over real HTTP, so the application listens.
|
|
28
42
|
beforeEach(async () => {
|
|
29
43
|
const port = await freePort()
|
|
30
|
-
env = { ...testEnv(),
|
|
31
|
-
|
|
44
|
+
env = { ...testEnv(), API_URL: `http://127.0.0.1:${port}` }
|
|
45
|
+
const container = createContainer({ env, orm: testOrm() })
|
|
46
|
+
|
|
47
|
+
container.register(CLIENT_METADATA, { useValue: clients })
|
|
48
|
+
app = await buildApp(container)
|
|
32
49
|
await app.listen({ port, host: '127.0.0.1' })
|
|
33
50
|
})
|
|
34
51
|
|
|
@@ -39,7 +56,7 @@ afterEach(async () => {
|
|
|
39
56
|
async function signUp(): Promise<string> {
|
|
40
57
|
const response = await app.inject({
|
|
41
58
|
method: 'POST',
|
|
42
|
-
url: '/api/auth/sign-up
|
|
59
|
+
url: '/api/auth/sign-up',
|
|
43
60
|
headers: { origin: ORIGIN },
|
|
44
61
|
payload: {
|
|
45
62
|
name: 'Ana',
|
|
@@ -47,63 +64,78 @@ async function signUp(): Promise<string> {
|
|
|
47
64
|
password: 'correct-horse-battery',
|
|
48
65
|
},
|
|
49
66
|
})
|
|
50
|
-
const cookies = [response.headers['set-cookie']].flat().filter((c) => c !== undefined)
|
|
51
67
|
|
|
52
|
-
return cookies.map((cookie) => cookie.
|
|
68
|
+
return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
|
|
53
69
|
}
|
|
54
70
|
|
|
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
|
|
71
|
+
function pkce() {
|
|
64
72
|
const verifier = randomBytes(32).toString('base64url')
|
|
65
|
-
|
|
73
|
+
|
|
74
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function authorizeUrl(
|
|
78
|
+
overrides: Record<string, string | undefined> = {},
|
|
79
|
+
challenge = pkce().challenge,
|
|
80
|
+
) {
|
|
81
|
+
const params = {
|
|
66
82
|
response_type: 'code',
|
|
67
|
-
client_id:
|
|
83
|
+
client_id: CLIENT_ID,
|
|
68
84
|
redirect_uri: REDIRECT,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
85
|
+
state: 'xyz',
|
|
86
|
+
code_challenge: challenge,
|
|
72
87
|
code_challenge_method: 'S256',
|
|
73
88
|
resource: mcpResource(env),
|
|
89
|
+
...overrides,
|
|
90
|
+
}
|
|
91
|
+
const query = new URLSearchParams(
|
|
92
|
+
Object.entries(params).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return `/api/oauth/authorize?${query}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function token(form: Record<string, string>) {
|
|
99
|
+
return app.inject({
|
|
100
|
+
method: 'POST',
|
|
101
|
+
url: '/api/oauth/token',
|
|
102
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
103
|
+
payload: new URLSearchParams(form).toString(),
|
|
74
104
|
})
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The path an MCP client takes: authorize with PKCE, the user consents on the web, the code becomes tokens.
|
|
108
|
+
async function authorize(cookie: string, accept = true) {
|
|
109
|
+
const { verifier, challenge } = pkce()
|
|
110
|
+
const started = await app.inject({ url: authorizeUrl({}, challenge) })
|
|
111
|
+
const consent = new URL(started.headers.location as string)
|
|
112
|
+
const id = consent.searchParams.get('request') ?? ''
|
|
113
|
+
const view = await app.inject({ url: `/api/oauth/authorizations/${id}`, headers: { cookie } })
|
|
114
|
+
const decision = await app.inject({
|
|
81
115
|
method: 'POST',
|
|
82
|
-
url:
|
|
116
|
+
url: `/api/oauth/authorizations/${id}/decision`,
|
|
83
117
|
headers: { cookie, origin: ORIGIN },
|
|
84
|
-
payload: { accept
|
|
118
|
+
payload: { accept },
|
|
85
119
|
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
|
|
121
|
+
return { started, consent, view, redirect: new URL(decision.json().redirectTo), verifier }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function tokens(cookie: string) {
|
|
125
|
+
const { redirect, verifier } = await authorize(cookie)
|
|
126
|
+
const response = await token({
|
|
127
|
+
grant_type: 'authorization_code',
|
|
128
|
+
code: redirect.searchParams.get('code') ?? '',
|
|
129
|
+
redirect_uri: REDIRECT,
|
|
130
|
+
client_id: CLIENT_ID,
|
|
131
|
+
code_verifier: verifier,
|
|
132
|
+
resource: mcpResource(env),
|
|
99
133
|
})
|
|
100
134
|
|
|
101
|
-
return
|
|
135
|
+
return { response, code: redirect.searchParams.get('code') ?? '', verifier }
|
|
102
136
|
}
|
|
103
137
|
|
|
104
|
-
|
|
105
|
-
async function connect(token: string): Promise<Client> {
|
|
106
|
-
// The server rejects 2025-era traffic, as Better Auth's MCP profile asks.
|
|
138
|
+
async function connect(accessToken: string): Promise<Client> {
|
|
107
139
|
const client = new Client(
|
|
108
140
|
{ name: 'test', version: '1.0.0' },
|
|
109
141
|
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
|
|
@@ -111,25 +143,95 @@ async function connect(token: string): Promise<Client> {
|
|
|
111
143
|
|
|
112
144
|
await client.connect(
|
|
113
145
|
new StreamableHTTPClientTransport(new URL(mcpResource(env)), {
|
|
114
|
-
requestInit: { headers: { authorization: `Bearer ${
|
|
146
|
+
requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
|
|
115
147
|
}),
|
|
116
148
|
)
|
|
117
149
|
return client
|
|
118
150
|
}
|
|
119
151
|
|
|
120
|
-
describe('
|
|
121
|
-
it('tells an MCP client where to authorize', async () => {
|
|
122
|
-
const metadata = await app.inject({ url: '/.well-known/oauth-protected-resource' })
|
|
152
|
+
describe('discovery', () => {
|
|
153
|
+
it('tells an MCP client where to authorize, from the 401 to the server metadata', async () => {
|
|
123
154
|
const refused = await app.inject({ method: 'POST', url: '/api/mcp', payload: {} })
|
|
155
|
+
const challenge = refused.headers['www-authenticate'] as string
|
|
156
|
+
const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? ''
|
|
157
|
+
const resource = await app.inject({ url: new URL(metadataUrl).pathname })
|
|
158
|
+
const server = await app.inject({ url: '/.well-known/oauth-authorization-server' })
|
|
124
159
|
|
|
125
|
-
expect(metadata.json()).toMatchObject({ resource: mcpResource(env) })
|
|
126
160
|
expect(refused.statusCode).toBe(401)
|
|
127
|
-
expect(
|
|
161
|
+
expect(resource.json()).toMatchObject({
|
|
162
|
+
resource: mcpResource(env),
|
|
163
|
+
authorization_servers: [env.API_URL],
|
|
164
|
+
})
|
|
165
|
+
expect(server.json()).toMatchObject({
|
|
166
|
+
issuer: env.API_URL,
|
|
167
|
+
code_challenge_methods_supported: ['S256'],
|
|
168
|
+
client_id_metadata_document_supported: true,
|
|
169
|
+
authorization_response_iss_parameter_supported: true,
|
|
170
|
+
})
|
|
128
171
|
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('authorization', () => {
|
|
175
|
+
it('sends the browser to consent, shows who asks and where the code goes, and returns it with iss', async () => {
|
|
176
|
+
const { started, consent, view, redirect } = await authorize(await signUp())
|
|
129
177
|
|
|
178
|
+
expect(started.statusCode).toBe(302)
|
|
179
|
+
expect(`${consent.origin}${consent.pathname}`).toBe(`${ORIGIN}/consent`)
|
|
180
|
+
expect(view.json()).toEqual({
|
|
181
|
+
clientId: CLIENT_ID,
|
|
182
|
+
clientName: 'Example Client',
|
|
183
|
+
clientHost: 'client.example.com',
|
|
184
|
+
redirectHost: 'client.example.com',
|
|
185
|
+
redirectsToThisDevice: false,
|
|
186
|
+
})
|
|
187
|
+
expect(`${redirect.origin}${redirect.pathname}`).toBe(REDIRECT)
|
|
188
|
+
expect(redirect.searchParams.get('state')).toBe('xyz')
|
|
189
|
+
expect(redirect.searchParams.get('iss')).toBe(env.API_URL)
|
|
190
|
+
expect(redirect.searchParams.get('code')).not.toBeNull()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('answers a denial with access_denied', async () => {
|
|
194
|
+
const { redirect } = await authorize(await signUp(), false)
|
|
195
|
+
|
|
196
|
+
expect(redirect.searchParams.get('error')).toBe('access_denied')
|
|
197
|
+
expect(redirect.searchParams.get('code')).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('never redirects for an unknown client or an unregistered redirect URI', async () => {
|
|
201
|
+
for (const overrides of [
|
|
202
|
+
{ client_id: 'https://evil.example.com/client.json' },
|
|
203
|
+
{ redirect_uri: 'https://evil.example.com/callback' },
|
|
204
|
+
]) {
|
|
205
|
+
const response = await app.inject({ url: authorizeUrl(overrides) })
|
|
206
|
+
|
|
207
|
+
expect(response.statusCode).toBe(422)
|
|
208
|
+
expect(response.headers.location).toBeUndefined()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('sends a known client its error: no PKCE, plain PKCE, another resource', async () => {
|
|
213
|
+
for (const [overrides, error] of [
|
|
214
|
+
[{ code_challenge_method: undefined }, 'invalid_request'],
|
|
215
|
+
[{ code_challenge_method: 'plain' }, 'invalid_request'],
|
|
216
|
+
[{ resource: 'https://elsewhere.example.com/mcp' }, 'invalid_target'],
|
|
217
|
+
] as const) {
|
|
218
|
+
const response = await app.inject({ url: authorizeUrl(overrides) })
|
|
219
|
+
const location = new URL(response.headers.location as string)
|
|
220
|
+
|
|
221
|
+
expect(location.searchParams.get('error')).toBe(error)
|
|
222
|
+
expect(location.searchParams.get('state')).toBe('xyz')
|
|
223
|
+
expect(location.searchParams.get('iss')).toBe(env.API_URL)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('token', () => {
|
|
130
229
|
it('runs a tool for the user who authorized the client, with public fields only', async () => {
|
|
131
|
-
const
|
|
230
|
+
const { response } = await tokens(await signUp())
|
|
231
|
+
const client = await connect(response.json().access_token)
|
|
132
232
|
const result = await client.callTool({ name: 'get_profile', arguments: {} })
|
|
233
|
+
|
|
234
|
+
expect(response.headers['cache-control']).toBe('no-store')
|
|
133
235
|
expect(Object.keys(result.structuredContent ?? {}).sort()).toEqual(
|
|
134
236
|
['createdAt', 'displayName', 'id', 'locale', 'timezone', 'updatedAt', 'userId'].sort(),
|
|
135
237
|
)
|
|
@@ -139,7 +241,8 @@ describe('mcp controller', () => {
|
|
|
139
241
|
})
|
|
140
242
|
|
|
141
243
|
it('changes the profile through a tool', async () => {
|
|
142
|
-
const
|
|
244
|
+
const { response } = await tokens(await signUp())
|
|
245
|
+
const client = await connect(response.json().access_token)
|
|
143
246
|
const result = await client.callTool({
|
|
144
247
|
name: 'update_profile',
|
|
145
248
|
arguments: { timezone: 'America/Sao_Paulo' },
|
|
@@ -149,4 +252,79 @@ describe('mcp controller', () => {
|
|
|
149
252
|
|
|
150
253
|
await client.close()
|
|
151
254
|
})
|
|
255
|
+
|
|
256
|
+
it('refuses a wrong verifier, and a code spent twice ends what the first use obtained', async () => {
|
|
257
|
+
const cookie = await signUp()
|
|
258
|
+
const { redirect } = await authorize(cookie)
|
|
259
|
+
const wrong = await token({
|
|
260
|
+
grant_type: 'authorization_code',
|
|
261
|
+
code: redirect.searchParams.get('code') ?? '',
|
|
262
|
+
redirect_uri: REDIRECT,
|
|
263
|
+
client_id: CLIENT_ID,
|
|
264
|
+
code_verifier: pkce().verifier,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(wrong.statusCode).toBe(400)
|
|
268
|
+
expect(wrong.json().error).toBe('invalid_grant')
|
|
269
|
+
|
|
270
|
+
const { response, code, verifier } = await tokens(cookie)
|
|
271
|
+
const replay = await token({
|
|
272
|
+
grant_type: 'authorization_code',
|
|
273
|
+
code,
|
|
274
|
+
redirect_uri: REDIRECT,
|
|
275
|
+
client_id: CLIENT_ID,
|
|
276
|
+
code_verifier: verifier,
|
|
277
|
+
})
|
|
278
|
+
const refresh = await token({
|
|
279
|
+
grant_type: 'refresh_token',
|
|
280
|
+
refresh_token: response.json().refresh_token,
|
|
281
|
+
client_id: CLIENT_ID,
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
expect(replay.json().error).toBe('invalid_grant')
|
|
285
|
+
expect(refresh.json().error).toBe('invalid_grant')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('rotates the refresh token, and only for the client it was issued to', async () => {
|
|
289
|
+
const { response } = await tokens(await signUp())
|
|
290
|
+
const refreshToken = response.json().refresh_token
|
|
291
|
+
const otherClient = await token({
|
|
292
|
+
grant_type: 'refresh_token',
|
|
293
|
+
refresh_token: refreshToken,
|
|
294
|
+
client_id: 'https://other.example.com/client.json',
|
|
295
|
+
})
|
|
296
|
+
const rotated = await token({
|
|
297
|
+
grant_type: 'refresh_token',
|
|
298
|
+
refresh_token: refreshToken,
|
|
299
|
+
client_id: CLIENT_ID,
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(otherClient.json().error).toBe('invalid_grant')
|
|
303
|
+
expect(rotated.statusCode).toBe(200)
|
|
304
|
+
expect(rotated.json().refresh_token).not.toBe(refreshToken)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('keeps the MCP token and the application’s tokens apart', async () => {
|
|
308
|
+
const cookie = await signUp()
|
|
309
|
+
const { response } = await tokens(cookie)
|
|
310
|
+
const access = cookie
|
|
311
|
+
.split('; ')
|
|
312
|
+
.find((pair) => pair.startsWith('access_token='))
|
|
313
|
+
?.slice(13)
|
|
314
|
+
|
|
315
|
+
const mcpTokenOnApi = await app.inject({
|
|
316
|
+
url: '/api/v1/users/me',
|
|
317
|
+
headers: { authorization: `Bearer ${response.json().access_token}` },
|
|
318
|
+
})
|
|
319
|
+
const apiTokenOnMcp = await app.inject({
|
|
320
|
+
method: 'POST',
|
|
321
|
+
url: '/api/mcp',
|
|
322
|
+
headers: { authorization: `Bearer ${access}` },
|
|
323
|
+
payload: {},
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
expect(mcpTokenOnApi.statusCode).toBe(401)
|
|
327
|
+
expect(apiTokenOnMcp.statusCode).toBe(401)
|
|
328
|
+
expect(apiTokenOnMcp.headers['www-authenticate']).toContain('error="invalid_token"')
|
|
329
|
+
})
|
|
152
330
|
})
|
|
@@ -11,7 +11,7 @@ let app: FastifyInstance
|
|
|
11
11
|
async function signUp(): Promise<string[]> {
|
|
12
12
|
const response = await app.inject({
|
|
13
13
|
method: 'POST',
|
|
14
|
-
url: '/api/auth/sign-up
|
|
14
|
+
url: '/api/auth/sign-up',
|
|
15
15
|
headers: { origin: ORIGIN },
|
|
16
16
|
payload: {
|
|
17
17
|
name: 'Ana',
|
|
@@ -20,7 +20,7 @@ async function signUp(): Promise<string[]> {
|
|
|
20
20
|
},
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
expect(response.statusCode).toBe(
|
|
23
|
+
expect(response.statusCode).toBe(201)
|
|
24
24
|
|
|
25
25
|
const cookies = response.headers['set-cookie']
|
|
26
26
|
return Array.isArray(cookies) ? cookies : [cookies ?? '']
|
|
@@ -94,7 +94,7 @@ describe('users controller', () => {
|
|
|
94
94
|
headers: { cookie, origin: ORIGIN },
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
-
expect(response.statusCode).toBe(
|
|
97
|
+
expect(response.statusCode).toBe(204)
|
|
98
98
|
})
|
|
99
99
|
|
|
100
100
|
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
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
|
|
3
|
+
import { HttpsClientMetadata, isPrivateAddress } from '@/infra/oauth/https-client-metadata.adapter'
|
|
4
|
+
|
|
5
|
+
describe('HttpsClientMetadata', () => {
|
|
6
|
+
it('refuses a client_id that is not a canonical https URL with a path', async () => {
|
|
7
|
+
const source = new HttpsClientMetadata()
|
|
8
|
+
|
|
9
|
+
for (const clientId of [
|
|
10
|
+
'not a url',
|
|
11
|
+
'http://client.example.com/client.json',
|
|
12
|
+
'https://client.example.com',
|
|
13
|
+
'https://client.example.com/',
|
|
14
|
+
'https://client.example.com/client.json?x=1',
|
|
15
|
+
'https://client.example.com/client.json#top',
|
|
16
|
+
'https://user:pass@client.example.com/client.json',
|
|
17
|
+
'https://client.example.com/a/../client.json',
|
|
18
|
+
'https://CLIENT.example.com/client.json',
|
|
19
|
+
]) {
|
|
20
|
+
await expect(source.read(clientId), clientId).rejects.toBeInstanceOf(InvalidClientError)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('refuses to fetch from this machine or its network, whatever the name says', async () => {
|
|
25
|
+
const source = new HttpsClientMetadata()
|
|
26
|
+
|
|
27
|
+
for (const clientId of [
|
|
28
|
+
'https://localhost/client.json',
|
|
29
|
+
'https://127.0.0.1/client.json',
|
|
30
|
+
'https://10.0.0.1/client.json',
|
|
31
|
+
'https://[::1]/client.json',
|
|
32
|
+
]) {
|
|
33
|
+
await expect(source.read(clientId), clientId).rejects.toThrow(
|
|
34
|
+
/private address|could not be read/,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('knows the private, loopback, link-local and mapped ranges from public ones', () => {
|
|
40
|
+
for (const address of [
|
|
41
|
+
'127.0.0.1',
|
|
42
|
+
'10.1.2.3',
|
|
43
|
+
'172.16.0.1',
|
|
44
|
+
'192.168.1.1',
|
|
45
|
+
'169.254.169.254',
|
|
46
|
+
'::1',
|
|
47
|
+
'fe80::1',
|
|
48
|
+
'fd00::1',
|
|
49
|
+
'::ffff:127.0.0.1',
|
|
50
|
+
]) {
|
|
51
|
+
expect(isPrivateAddress(address), address).toBe(true)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const address of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) {
|
|
55
|
+
expect(isPrivateAddress(address), address).toBe(false)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|