@stjoseph/prumo 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +6 -4
- package/dist/asker.js +13 -0
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -3
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +17 -3
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- package/dist/mcp.js +0 -107
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { fakeOidcServer } from '@test/support/fake-oidc-server'
|
|
2
|
+
import { testEnv } from '@test/support/setup'
|
|
3
|
+
// One line, so its marker takes all of it.
|
|
4
|
+
import * as jose from 'jose' // prumo:apple
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error' // prumo:google
|
|
7
|
+
import { OpenIdIdentityProviders } from '@/infra/social/openid-identity-providers.adapter'
|
|
8
|
+
|
|
9
|
+
let server: Awaited<ReturnType<typeof fakeOidcServer>>
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await server.close()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function callback(provider: string, code: string, state: string): URL {
|
|
16
|
+
return new URL(
|
|
17
|
+
`http://localhost:3000/api/auth/social/${provider}/callback?code=${code}&state=${state}`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// prumo:google
|
|
22
|
+
describe('OpenIdIdentityProviders with Google', () => {
|
|
23
|
+
async function google() {
|
|
24
|
+
server = await fakeOidcServer({ pkce: true })
|
|
25
|
+
|
|
26
|
+
return new OpenIdIdentityProviders(
|
|
27
|
+
{ ...testEnv(), GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret' },
|
|
28
|
+
{ issuers: { google: server.issuer }, insecure: true },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('sends PKCE, state and nonce, and reads the verified profile back', async () => {
|
|
33
|
+
const providers = await google()
|
|
34
|
+
const redirect = await providers.redirect('google', 'state-1')
|
|
35
|
+
const url = new URL(redirect.url)
|
|
36
|
+
const code = server.authorize(redirect.url, {
|
|
37
|
+
sub: 'g-1',
|
|
38
|
+
email: 'ana@example.com',
|
|
39
|
+
email_verified: true,
|
|
40
|
+
name: 'Ana',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
|
44
|
+
expect(url.searchParams.get('redirect_uri')).toBe(
|
|
45
|
+
'http://localhost:3000/api/auth/social/google/callback',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const profile = await providers.profile('google', {
|
|
49
|
+
callbackUrl: callback('google', code, 'state-1'),
|
|
50
|
+
state: 'state-1',
|
|
51
|
+
codeVerifier: redirect.codeVerifier,
|
|
52
|
+
nonce: redirect.nonce,
|
|
53
|
+
name: null,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(profile).toEqual({
|
|
57
|
+
subject: 'g-1',
|
|
58
|
+
email: 'ana@example.com',
|
|
59
|
+
emailVerified: true,
|
|
60
|
+
name: 'Ana',
|
|
61
|
+
})
|
|
62
|
+
expect(server.tokenRequests[0]?.get('client_secret')).toBe('google-secret')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('refuses another state, another nonce and a code it did not issue', async () => {
|
|
66
|
+
const providers = await google()
|
|
67
|
+
const redirect = await providers.redirect('google', 'state-1')
|
|
68
|
+
const base = {
|
|
69
|
+
state: 'state-1',
|
|
70
|
+
codeVerifier: redirect.codeVerifier,
|
|
71
|
+
nonce: redirect.nonce,
|
|
72
|
+
name: null,
|
|
73
|
+
}
|
|
74
|
+
const claims = { sub: 'g-1', email: 'ana@example.com', email_verified: true }
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
providers.profile('google', {
|
|
78
|
+
...base,
|
|
79
|
+
callbackUrl: callback('google', server.authorize(redirect.url, claims), 'other-state'),
|
|
80
|
+
}),
|
|
81
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
82
|
+
await expect(
|
|
83
|
+
providers.profile('google', {
|
|
84
|
+
...base,
|
|
85
|
+
nonce: 'other-nonce',
|
|
86
|
+
callbackUrl: callback('google', server.authorize(redirect.url, claims), 'state-1'),
|
|
87
|
+
}),
|
|
88
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
89
|
+
await expect(
|
|
90
|
+
providers.profile('google', {
|
|
91
|
+
...base,
|
|
92
|
+
callbackUrl: callback('google', 'forged', 'state-1'),
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toBeInstanceOf(SocialSignInFailedError)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('is not configured without its client id and secret', () => {
|
|
98
|
+
expect(new OpenIdIdentityProviders(testEnv()).isConfigured('google')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// prumo:end-google
|
|
103
|
+
// prumo:apple
|
|
104
|
+
describe('OpenIdIdentityProviders with Apple', () => {
|
|
105
|
+
it('signs its client secret with the .p8 key, asks for a form post, and reads "true" as verified', async () => {
|
|
106
|
+
server = await fakeOidcServer({ pkce: false })
|
|
107
|
+
const { privateKey, publicKey } = await jose.generateKeyPair('ES256', { extractable: true })
|
|
108
|
+
const providers = new OpenIdIdentityProviders(
|
|
109
|
+
{
|
|
110
|
+
...testEnv(),
|
|
111
|
+
APPLE_CLIENT_ID: 'com.example.web',
|
|
112
|
+
APPLE_TEAM_ID: 'TEAM123456',
|
|
113
|
+
APPLE_KEY_ID: 'KEY1234567',
|
|
114
|
+
// As it sits in .env: one line, its breaks written as \n.
|
|
115
|
+
APPLE_PRIVATE_KEY: (await jose.exportPKCS8(privateKey)).replaceAll('\n', '\\n'),
|
|
116
|
+
},
|
|
117
|
+
{ issuers: { apple: server.issuer }, insecure: true },
|
|
118
|
+
)
|
|
119
|
+
const redirect = await providers.redirect('apple', 'state-1')
|
|
120
|
+
const url = new URL(redirect.url)
|
|
121
|
+
const code = server.authorize(redirect.url, {
|
|
122
|
+
sub: 'a-1',
|
|
123
|
+
email: 'ana@privaterelay.appleid.com',
|
|
124
|
+
email_verified: 'true',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(url.searchParams.get('response_mode')).toBe('form_post')
|
|
128
|
+
expect(url.searchParams.get('code_challenge')).toBeNull()
|
|
129
|
+
expect(redirect.codeVerifier).toBeNull()
|
|
130
|
+
|
|
131
|
+
const profile = await providers.profile('apple', {
|
|
132
|
+
callbackUrl: callback('apple', code, 'state-1'),
|
|
133
|
+
state: 'state-1',
|
|
134
|
+
codeVerifier: null,
|
|
135
|
+
nonce: redirect.nonce,
|
|
136
|
+
name: 'Ana Apple',
|
|
137
|
+
})
|
|
138
|
+
const secret = server.tokenRequests[0]?.get('client_secret') ?? ''
|
|
139
|
+
const { payload } = await jose.jwtVerify(
|
|
140
|
+
secret,
|
|
141
|
+
await jose.importSPKI(await jose.exportSPKI(publicKey), 'ES256'),
|
|
142
|
+
{
|
|
143
|
+
issuer: 'TEAM123456',
|
|
144
|
+
subject: 'com.example.web',
|
|
145
|
+
audience: 'https://appleid.apple.com',
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(profile).toMatchObject({ subject: 'a-1', emailVerified: true, name: 'Ana Apple' })
|
|
150
|
+
expect(jose.decodeProtectedHeader(secret)).toMatchObject({ alg: 'ES256', kid: 'KEY1234567' })
|
|
151
|
+
expect(payload.exp).toBeGreaterThan(Date.now() / 1000)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
// prumo:end-apple
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createServer, type Server } from 'node:http'
|
|
3
|
+
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
|
|
4
|
+
|
|
5
|
+
type Grant = { nonce: string; challenge: string | null; claims: Record<string, unknown> }
|
|
6
|
+
|
|
7
|
+
// A minimal OpenID Connect provider on localhost: discovery, keys, and a token endpoint that signs an ID token for
|
|
8
|
+
// the code it handed out. Enough to run the real adapter end to end, which no test can do against Google or Apple.
|
|
9
|
+
export async function fakeOidcServer({ pkce }: { pkce: boolean }) {
|
|
10
|
+
const { privateKey, publicKey } = await generateKeyPair('RS256')
|
|
11
|
+
const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }
|
|
12
|
+
const grants = new Map<string, Grant>()
|
|
13
|
+
const tokenRequests: URLSearchParams[] = []
|
|
14
|
+
let issuer = ''
|
|
15
|
+
|
|
16
|
+
const server: Server = createServer(async (request, response) => {
|
|
17
|
+
const url = new URL(request.url ?? '/', issuer)
|
|
18
|
+
const json = (status: number, body: unknown) => {
|
|
19
|
+
response.writeHead(status, { 'content-type': 'application/json' })
|
|
20
|
+
response.end(JSON.stringify(body))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (url.pathname === '/.well-known/openid-configuration') {
|
|
24
|
+
return json(200, {
|
|
25
|
+
issuer,
|
|
26
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
27
|
+
token_endpoint: `${issuer}/token`,
|
|
28
|
+
jwks_uri: `${issuer}/jwks`,
|
|
29
|
+
response_types_supported: ['code'],
|
|
30
|
+
subject_types_supported: ['public'],
|
|
31
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
32
|
+
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
|
33
|
+
...(pkce && { code_challenge_methods_supported: ['S256'] }),
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (url.pathname === '/jwks') {
|
|
38
|
+
return json(200, { keys: [jwk] })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (url.pathname === '/token') {
|
|
42
|
+
let body = ''
|
|
43
|
+
for await (const chunk of request) body += chunk
|
|
44
|
+
const form = new URLSearchParams(body)
|
|
45
|
+
tokenRequests.push(form)
|
|
46
|
+
|
|
47
|
+
const grant = grants.get(form.get('code') ?? '')
|
|
48
|
+
const verifier = form.get('code_verifier')
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
grant === undefined ||
|
|
52
|
+
(grant.challenge !== null &&
|
|
53
|
+
createHash('sha256')
|
|
54
|
+
.update(verifier ?? '')
|
|
55
|
+
.digest('base64url') !== grant.challenge)
|
|
56
|
+
) {
|
|
57
|
+
return json(400, { error: 'invalid_grant' })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
grants.delete(form.get('code') ?? '')
|
|
61
|
+
|
|
62
|
+
const idToken = await new SignJWT({ nonce: grant.nonce, ...grant.claims })
|
|
63
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'k1' })
|
|
64
|
+
.setIssuer(issuer)
|
|
65
|
+
.setAudience(form.get('client_id') ?? '')
|
|
66
|
+
.setIssuedAt()
|
|
67
|
+
.setExpirationTime('5m')
|
|
68
|
+
.sign(privateKey)
|
|
69
|
+
|
|
70
|
+
return json(200, { access_token: 'access', token_type: 'Bearer', id_token: idToken })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
json(404, {})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
77
|
+
issuer = `http://127.0.0.1:${(server.address() as { port: number }).port}`
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
issuer: new URL(issuer),
|
|
81
|
+
tokenRequests,
|
|
82
|
+
// What the provider would do after the user signs in: remember the request and hand back a code.
|
|
83
|
+
authorize(authorizationUrl: string, claims: Record<string, unknown>): string {
|
|
84
|
+
const params = new URL(authorizationUrl).searchParams
|
|
85
|
+
const code = crypto.randomUUID()
|
|
86
|
+
|
|
87
|
+
grants.set(code, {
|
|
88
|
+
nonce: params.get('nonce') ?? '',
|
|
89
|
+
challenge: params.get('code_challenge'),
|
|
90
|
+
claims,
|
|
91
|
+
})
|
|
92
|
+
return code
|
|
93
|
+
},
|
|
94
|
+
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { FakeAccessTokens } from '@test/support/fakes/fake-access-tokens'
|
|
2
|
+
import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
|
|
3
|
+
import { FakeOneTimeCodes } from '@test/support/fakes/fake-one-time-codes' // prumo:email
|
|
2
4
|
import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
|
|
3
5
|
import { FakePasswordHasher } from '@test/support/fakes/fake-password-hasher'
|
|
4
6
|
import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
|
|
7
|
+
import { InMemoryEmailCodeRepository } from '@test/support/fakes/in-memory-email-code.repository' // prumo:email
|
|
5
8
|
import { InMemoryProfileRepository } from '@test/support/fakes/in-memory-profile.repository'
|
|
6
9
|
import { InMemorySessionRepository } from '@test/support/fakes/in-memory-session.repository'
|
|
7
10
|
import { InMemoryUserRepository } from '@test/support/fakes/in-memory-user.repository'
|
|
11
|
+
import type { SignUpDto } from '@/domain/auth/dto/sign-up.dto'
|
|
12
|
+
import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case' // prumo:email
|
|
8
13
|
import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
|
|
14
|
+
import { RequestPasswordResetUseCase } from '@/domain/auth/use-cases/request-password-reset.use-case' // prumo:email
|
|
15
|
+
import { ResetPasswordUseCase } from '@/domain/auth/use-cases/reset-password.use-case' // prumo:email
|
|
9
16
|
import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
|
|
10
17
|
import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
|
|
11
18
|
import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
|
|
12
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type SessionTokens,
|
|
21
|
+
StartSessionUseCase,
|
|
22
|
+
} from '@/domain/auth/use-cases/start-session.use-case'
|
|
23
|
+
import { VerifyEmailUseCase } from '@/domain/auth/use-cases/verify-email.use-case' // prumo:email
|
|
13
24
|
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
14
25
|
|
|
15
26
|
// Every auth use case wired to in-memory ports, sharing one set of them as the container would.
|
|
@@ -22,21 +33,62 @@ export function authFakes() {
|
|
|
22
33
|
const opaque = new FakeOpaqueTokens()
|
|
23
34
|
const transactions = new ImmediateTransactionManager()
|
|
24
35
|
const startSession = new StartSessionUseCase(sessions, tokens, opaque)
|
|
36
|
+
// prumo:email
|
|
37
|
+
const mailer = new FakeMailer()
|
|
38
|
+
const codes = new InMemoryEmailCodeRepository()
|
|
39
|
+
const oneTimeCodes = new FakeOneTimeCodes()
|
|
40
|
+
const issueEmailCode = new IssueEmailCodeUseCase(codes, oneTimeCodes, mailer, transactions)
|
|
41
|
+
const verifyEmail = new VerifyEmailUseCase(users, codes, oneTimeCodes, transactions, startSession)
|
|
42
|
+
// prumo:end-email
|
|
43
|
+
const signUp = new SignUpUseCase(
|
|
44
|
+
users,
|
|
45
|
+
hasher,
|
|
46
|
+
transactions,
|
|
47
|
+
new CreateProfileUseCase(profiles),
|
|
48
|
+
startSession,
|
|
49
|
+
issueEmailCode, // prumo:email
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Signs up and, where the email must be confirmed first, confirms it: a session either way.
|
|
53
|
+
async function register(request: SignUpDto): Promise<SessionTokens> {
|
|
54
|
+
const started = await signUp.execute(request)
|
|
55
|
+
|
|
56
|
+
// prumo:email
|
|
57
|
+
if (started === null) {
|
|
58
|
+
return verifyEmail.execute({ email: request.email, code: mailer.lastCode(request.email) })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// prumo:end-email
|
|
62
|
+
if (started === null) {
|
|
63
|
+
throw new Error('Sign-up opened no session')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return started
|
|
67
|
+
}
|
|
25
68
|
|
|
26
69
|
return {
|
|
27
70
|
users,
|
|
28
71
|
sessions,
|
|
29
72
|
profiles,
|
|
30
73
|
hasher,
|
|
31
|
-
signUp
|
|
74
|
+
signUp,
|
|
75
|
+
register,
|
|
76
|
+
signIn: new SignInUseCase(users, hasher, startSession),
|
|
77
|
+
refresh: new RefreshSessionUseCase(sessions, tokens, opaque, transactions),
|
|
78
|
+
signOut: new SignOutUseCase(sessions),
|
|
79
|
+
// prumo:email
|
|
80
|
+
mailer,
|
|
81
|
+
verifyEmail,
|
|
82
|
+
requestPasswordReset: new RequestPasswordResetUseCase(users, issueEmailCode),
|
|
83
|
+
resetPassword: new ResetPasswordUseCase(
|
|
32
84
|
users,
|
|
85
|
+
codes,
|
|
86
|
+
sessions,
|
|
87
|
+
oneTimeCodes,
|
|
33
88
|
hasher,
|
|
34
89
|
transactions,
|
|
35
|
-
new CreateProfileUseCase(profiles),
|
|
36
90
|
startSession,
|
|
37
91
|
),
|
|
38
|
-
|
|
39
|
-
refresh: new RefreshSessionUseCase(sessions, tokens, opaque, transactions),
|
|
40
|
-
signOut: new SignOutUseCase(sessions),
|
|
92
|
+
// prumo:end-email
|
|
41
93
|
}
|
|
42
94
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Mail, Mailer } from '@/domain/auth/ports/mailer.port'
|
|
2
|
+
|
|
3
|
+
// Keeps what would have been sent, so a test reads the code the way a person reads their inbox.
|
|
4
|
+
export class FakeMailer implements Mailer {
|
|
5
|
+
readonly sent: Mail[] = []
|
|
6
|
+
|
|
7
|
+
async send(mail: Mail): Promise<void> {
|
|
8
|
+
this.sent.push(mail)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
lastCode(to: string): string {
|
|
12
|
+
const mail = this.sent.findLast((each) => each.to === to)
|
|
13
|
+
const code = mail === undefined ? undefined : /\b\d{6}\b/.exec(mail.text)?.[0]
|
|
14
|
+
|
|
15
|
+
if (code === undefined) {
|
|
16
|
+
throw new Error(`No code was sent to ${to}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
|
|
2
|
+
|
|
3
|
+
export class FakeOneTimeCodes implements OneTimeCodes {
|
|
4
|
+
private next = 100000
|
|
5
|
+
|
|
6
|
+
create(): string {
|
|
7
|
+
this.next += 1
|
|
8
|
+
return String(this.next)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
digest(code: string): string {
|
|
12
|
+
return `digest:${code}`
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EmailCode, EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
|
|
2
|
+
import type { EmailCodeRepository } from '@/domain/auth/repositories/email-code.repository'
|
|
3
|
+
|
|
4
|
+
export class InMemoryEmailCodeRepository implements EmailCodeRepository {
|
|
5
|
+
readonly rows = new Map<string, EmailCode>()
|
|
6
|
+
|
|
7
|
+
async lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null> {
|
|
8
|
+
return this.rows.get(`${userId}:${purpose}`) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async save(code: EmailCode): Promise<void> {
|
|
12
|
+
code.id ??= crypto.randomUUID()
|
|
13
|
+
this.rows.set(`${code.userId}:${code.purpose}`, code)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -16,4 +16,12 @@ export class InMemorySessionRepository implements SessionRepository {
|
|
|
16
16
|
session.id ??= crypto.randomUUID()
|
|
17
17
|
this.rows.set(session.id, session)
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
async revokeAllFor(userId: string, now: Date): Promise<void> {
|
|
21
|
+
for (const session of this.rows.values()) {
|
|
22
|
+
if (session.userId === userId) {
|
|
23
|
+
session.revoke(now)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
19
27
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { authFakes } from '@test/support/fakes/auth-fakes'
|
|
2
|
+
import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
|
|
3
|
+
import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
|
|
4
|
+
import type { Identity } from '@/domain/auth/entities/identity.entity'
|
|
5
|
+
import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
6
|
+
import type {
|
|
7
|
+
IdentityProviders,
|
|
8
|
+
ProviderCallback,
|
|
9
|
+
ProviderName,
|
|
10
|
+
ProviderProfile,
|
|
11
|
+
ProviderRedirect,
|
|
12
|
+
} from '@/domain/auth/ports/identity-providers.port'
|
|
13
|
+
import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
|
|
14
|
+
import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
|
|
15
|
+
import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
|
|
16
|
+
import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
|
|
17
|
+
import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
|
|
18
|
+
import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
|
|
19
|
+
import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
|
|
20
|
+
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
21
|
+
import { FakeAccessTokens } from './fake-access-tokens'
|
|
22
|
+
|
|
23
|
+
export class InMemoryIdentityRepository implements IdentityRepository {
|
|
24
|
+
readonly rows: Identity[] = []
|
|
25
|
+
|
|
26
|
+
async findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
|
|
27
|
+
return this.rows.find((row) => row.provider === provider && row.subject === subject) ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async save(identity: Identity): Promise<void> {
|
|
31
|
+
identity.id ??= crypto.randomUUID()
|
|
32
|
+
this.rows.push(identity)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class InMemorySocialSignInRepository implements SocialSignInRepository {
|
|
37
|
+
readonly rows = new Map<string, SocialSignIn>()
|
|
38
|
+
|
|
39
|
+
async lockById(id: string): Promise<SocialSignIn | null> {
|
|
40
|
+
return this.rows.get(id) ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async save(signIn: SocialSignIn): Promise<void> {
|
|
44
|
+
signIn.id ??= crypto.randomUUID()
|
|
45
|
+
this.rows.set(signIn.id, signIn)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A provider that answers what the test tells it to, and remembers what it was asked.
|
|
50
|
+
export class FakeIdentityProviders implements IdentityProviders {
|
|
51
|
+
next: ProviderProfile = {
|
|
52
|
+
subject: 'google-sub-1',
|
|
53
|
+
email: 'ana@example.com',
|
|
54
|
+
emailVerified: true,
|
|
55
|
+
name: 'Ana',
|
|
56
|
+
}
|
|
57
|
+
lastCallback: ProviderCallback | undefined
|
|
58
|
+
|
|
59
|
+
constructor(private readonly configured: ProviderName[] = ['google', 'apple']) {}
|
|
60
|
+
|
|
61
|
+
isConfigured(provider: ProviderName): boolean {
|
|
62
|
+
return this.configured.includes(provider)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
|
|
66
|
+
return {
|
|
67
|
+
url: `https://${provider}.test/authorize?state=${state}`,
|
|
68
|
+
codeVerifier: 'verifier',
|
|
69
|
+
nonce: 'nonce',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async profile(_provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
|
|
74
|
+
this.lastCallback = callback
|
|
75
|
+
return this.next
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function socialFakes() {
|
|
80
|
+
const auth = authFakes()
|
|
81
|
+
const identities = new InMemoryIdentityRepository()
|
|
82
|
+
const signIns = new InMemorySocialSignInRepository()
|
|
83
|
+
const providers = new FakeIdentityProviders()
|
|
84
|
+
const opaque = new FakeOpaqueTokens()
|
|
85
|
+
const transactions = new ImmediateTransactionManager()
|
|
86
|
+
const startSession = new StartSessionUseCase(auth.sessions, new FakeAccessTokens(), opaque)
|
|
87
|
+
const linkIdentity = new LinkIdentityUseCase(
|
|
88
|
+
auth.users,
|
|
89
|
+
identities,
|
|
90
|
+
auth.sessions,
|
|
91
|
+
transactions,
|
|
92
|
+
new CreateProfileUseCase(auth.profiles),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...auth,
|
|
97
|
+
identities,
|
|
98
|
+
signIns,
|
|
99
|
+
providers,
|
|
100
|
+
linkIdentity,
|
|
101
|
+
start: new StartSocialSignInUseCase(providers, signIns, opaque),
|
|
102
|
+
complete: new CompleteSocialSignInUseCase(
|
|
103
|
+
providers,
|
|
104
|
+
signIns,
|
|
105
|
+
opaque,
|
|
106
|
+
transactions,
|
|
107
|
+
linkIdentity,
|
|
108
|
+
startSession,
|
|
109
|
+
),
|
|
110
|
+
exchange: new ExchangeSocialCodeUseCase(signIns, opaque, transactions, startSession),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
|
|
2
|
+
import { testEnv, testOrm } from '@test/support/setup'
|
|
3
|
+
import type { DependencyContainer } from 'tsyringe'
|
|
4
|
+
import { MAILER } from '@/domain/auth/ports/mailer.port' // prumo:email
|
|
5
|
+
import type { Env } from '@/infra/config/env'
|
|
6
|
+
import { createContainer } from '@/infra/di'
|
|
7
|
+
import { buildApp } from '@/infra/http/app'
|
|
8
|
+
|
|
9
|
+
export const ORIGIN = 'http://localhost:5173'
|
|
10
|
+
export const PASSWORD = 'correct-horse-battery'
|
|
11
|
+
|
|
12
|
+
// The application as a test meets it: the real container over the test database, with a mailer whose inbox the
|
|
13
|
+
// test can read.
|
|
14
|
+
export async function testApp({
|
|
15
|
+
env = testEnv(),
|
|
16
|
+
configure,
|
|
17
|
+
}: {
|
|
18
|
+
env?: Env
|
|
19
|
+
configure?: (container: DependencyContainer) => void
|
|
20
|
+
} = {}) {
|
|
21
|
+
const container = createContainer({ env, orm: testOrm() })
|
|
22
|
+
// prumo:email
|
|
23
|
+
const mailer = new FakeMailer()
|
|
24
|
+
|
|
25
|
+
container.register(MAILER, { useValue: mailer })
|
|
26
|
+
// prumo:end-email
|
|
27
|
+
configure?.(container)
|
|
28
|
+
|
|
29
|
+
const app = await buildApp(container)
|
|
30
|
+
|
|
31
|
+
// Signs up through the real routes and, where the email must be confirmed first, confirms it. Returns the
|
|
32
|
+
// response that delivered the session: cookies for the web, tokens in the body for a bearer client.
|
|
33
|
+
async function signUp({ bearer = false, email = `${crypto.randomUUID()}@example.com` } = {}) {
|
|
34
|
+
const headers = bearer ? { 'x-auth-transport': 'bearer' } : { origin: ORIGIN }
|
|
35
|
+
let response = await app.inject({
|
|
36
|
+
method: 'POST',
|
|
37
|
+
url: '/api/auth/sign-up',
|
|
38
|
+
headers,
|
|
39
|
+
payload: { name: 'Ana', email, password: PASSWORD },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// prumo:email
|
|
43
|
+
if (response.body.includes('verificationRequired')) {
|
|
44
|
+
response = await app.inject({
|
|
45
|
+
method: 'POST',
|
|
46
|
+
url: '/api/auth/email/verify',
|
|
47
|
+
headers,
|
|
48
|
+
payload: { email, code: mailer.lastCode(email) },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// prumo:end-email
|
|
53
|
+
return { response, email }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
app,
|
|
58
|
+
signUp,
|
|
59
|
+
mailer, // prumo:email
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function cookieHeader(response: { cookies: { name: string; value: string }[] }): string {
|
|
64
|
+
return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
|
|
65
|
+
}
|
|
@@ -5,6 +5,7 @@ type ProblemDocument = {
|
|
|
5
5
|
status?: number
|
|
6
6
|
detail?: string
|
|
7
7
|
requestId?: string
|
|
8
|
+
code?: string
|
|
8
9
|
errors?: FieldErrors
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -15,6 +16,8 @@ export class ApiError extends Error {
|
|
|
15
16
|
readonly detail: string | undefined,
|
|
16
17
|
readonly requestId: string | undefined,
|
|
17
18
|
readonly errors: FieldErrors,
|
|
19
|
+
// A stable name for a refusal the client acts on, such as `email_not_verified`.
|
|
20
|
+
readonly code?: string,
|
|
18
21
|
) {
|
|
19
22
|
super(detail ?? title)
|
|
20
23
|
}
|
|
@@ -35,6 +38,7 @@ export class ApiError extends Error {
|
|
|
35
38
|
problem.detail,
|
|
36
39
|
problem.requestId ?? requestId,
|
|
37
40
|
problem.errors ?? {},
|
|
41
|
+
problem.code,
|
|
38
42
|
)
|
|
39
43
|
}
|
|
40
44
|
}
|