Skip to content

Commit 5c62b76

Browse files
committed
feat: Add webhooks
Allow calling application server for authorizing some operations, and send notifications after they succeed. Also add a global flag to disable signups compltetely on demo instances.
1 parent fb6f56f commit 5c62b76

File tree

8 files changed

+271
-27
lines changed

8 files changed

+271
-27
lines changed

packages/server/src/env.ts

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const envSchema = z.object({
3030
),
3131

3232
// Optional variables
33+
ENABLE_SIGNUP: booleanSchema.optional().default('true'),
34+
WEBHOOK_URL: z.string().url().optional(),
3335
DEBUG: booleanSchema.default('false'),
3436
CORS_FORCE_ENABLE: booleanSchema.default('false'),
3537
DATABASE_MAX_SIZE_BYTES: z

packages/server/src/plugins/pkg.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { FastifyPluginAsync } from 'fastify'
2+
import fp from 'fastify-plugin'
3+
import fs from 'node:fs/promises'
4+
import path from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { z } from 'zod'
7+
import type { App } from '../types'
8+
9+
const packageJsonSchema = z.object({
10+
name: z.string(),
11+
version: z.string(),
12+
license: z.string(),
13+
description: z.string(),
14+
})
15+
16+
declare module 'fastify' {
17+
interface FastifyInstance {
18+
pkg: z.infer<typeof packageJsonSchema>
19+
}
20+
}
21+
22+
const pkgPlugin: FastifyPluginAsync = async (app: App) => {
23+
const packageJsonPath = path.resolve(
24+
path.dirname(fileURLToPath(import.meta.url)),
25+
'../../package.json'
26+
)
27+
const packageJson = await fs.readFile(packageJsonPath, { encoding: 'utf8' })
28+
app.decorate('pkg', packageJsonSchema.parse(JSON.parse(packageJson)))
29+
}
30+
31+
export default fp(pkgPlugin, {
32+
fastify: '4.x',
33+
name: 'pkg',
34+
})

packages/server/src/plugins/swagger.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@ import swagger from '@fastify/swagger'
22
import swaggerUI from '@fastify/swagger-ui'
33
import type { FastifyPluginAsync } from 'fastify'
44
import fp from 'fastify-plugin'
5-
import { createRequire } from 'node:module'
65
import { env } from '../env.js'
76
import type { App } from '../types'
87

98
const swaggerPlugin: FastifyPluginAsync = async (app: App) => {
10-
const require = createRequire(import.meta.url)
11-
const pkg = require('../../package.json')
129
await app.register(swagger, {
1310
openapi: {
1411
tags: [
@@ -18,18 +15,17 @@ const swaggerPlugin: FastifyPluginAsync = async (app: App) => {
1815
{ name: 'permissions', description: 'Managing authorization' },
1916
],
2017
info: {
21-
title: pkg.name,
22-
version: `${pkg.version} (${env.DEPLOYMENT_TAG})`,
23-
description: pkg.description,
18+
title: app.pkg.name,
19+
version: `${app.pkg.version} (${env.DEPLOYMENT_TAG})`,
20+
description: app.pkg.description,
2421
license: {
25-
name: pkg.license,
22+
name: app.pkg.license,
2623
url: 'https://github.com/SocialGouv/e2esdk/blob/main/LICENSE',
2724
},
2825
},
2926
externalDocs: {
30-
// todo: Use source URL in sceau
3127
description: 'GitHub repository',
32-
url: 'https://github.com/SocialGouv/e2esdk',
28+
url: app.codeSignature.sourceURL,
3329
},
3430
},
3531
})
@@ -42,4 +38,8 @@ const swaggerPlugin: FastifyPluginAsync = async (app: App) => {
4238
export default fp(swaggerPlugin, {
4339
fastify: '4.x',
4440
name: 'swagger',
41+
dependencies: ['pkg', 'codeSignature'],
42+
decorators: {
43+
fastify: ['pkg', 'codeSignature'],
44+
},
4545
})
+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {
2+
Identity,
3+
PostKeychainItemRequestBody,
4+
PostSharedKeyBody,
5+
} from '@socialgouv/e2esdk-api'
6+
import { signAuth } from '@socialgouv/e2esdk-crypto'
7+
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
8+
import fp from 'fastify-plugin'
9+
import { env } from 'process'
10+
import type { App } from '../types'
11+
12+
type Decoration = {
13+
// Authorization
14+
authorizeSignup(req: FastifyRequest): Promise<boolean>
15+
authorizeKeyShare(
16+
req: FastifyRequest,
17+
sharedKey: PostSharedKeyBody
18+
): Promise<boolean>
19+
20+
// Notifications
21+
notifySignup(req: FastifyRequest, identity: Identity): void
22+
notifyKeyAdded(
23+
req: FastifyRequest,
24+
item: Omit<
25+
PostKeychainItemRequestBody,
26+
'name' | 'payload' | 'subkeyIndex' | 'signature'
27+
>
28+
): void
29+
}
30+
31+
declare module 'fastify' {
32+
interface FastifyInstance {
33+
webhook: Decoration
34+
}
35+
}
36+
37+
// --
38+
39+
type WebhookAPICallArgs = {
40+
name: keyof Decoration
41+
req: FastifyRequest
42+
path: string
43+
body?: any
44+
}
45+
46+
// --
47+
48+
const webhookPlugin: FastifyPluginAsync = async (app: App) => {
49+
const baseURL = env.WEBHOOK_URL
50+
if (!baseURL) {
51+
const decoration: Decoration = {
52+
authorizeSignup() {
53+
return Promise.resolve(true)
54+
},
55+
authorizeKeyShare() {
56+
return Promise.resolve(true)
57+
},
58+
notifySignup() {},
59+
notifyKeyAdded() {},
60+
}
61+
app.decorate('webhook', decoration)
62+
return
63+
}
64+
app.log.info({ msg: 'Setting up webhook', url: baseURL })
65+
66+
async function webhookApiCall({
67+
req,
68+
path,
69+
name,
70+
body: payload,
71+
}: WebhookAPICallArgs) {
72+
try {
73+
const method = 'POST'
74+
const url = baseURL + path
75+
const timestamp = new Date().toISOString()
76+
const userId = encodeURIComponent(req.identity.userId)
77+
const body = payload ? JSON.stringify(payload) : undefined
78+
const referrer =
79+
typeof req.headers.referrer === 'string'
80+
? req.headers.referrer
81+
: undefined
82+
const signature = signAuth(
83+
app.sodium,
84+
app.sodium.from_base64(env.SIGNATURE_PRIVATE_KEY),
85+
{
86+
clientId: req.clientId,
87+
method,
88+
userId,
89+
url,
90+
timestamp,
91+
body,
92+
recipientPublicKey: 'none',
93+
}
94+
)
95+
req.auditLog.trace({
96+
msg: `webhook:${name}:fetch:request`,
97+
method,
98+
url,
99+
referrer,
100+
timestamp,
101+
signature,
102+
body,
103+
})
104+
const response = await fetch(url, {
105+
method,
106+
mode: 'no-cors',
107+
cache: 'no-store',
108+
credentials: 'omit',
109+
redirect: 'error',
110+
referrer,
111+
body,
112+
headers: {
113+
...(body ? { 'content-type': 'application/json' } : {}),
114+
origin: env.DEPLOYMENT_URL,
115+
'user-agent': `${app.pkg.name}@${app.pkg.version} Webhook`,
116+
'x-e2esdk-user-id': userId,
117+
'x-e2esdk-request-id': req.id,
118+
'x-e2esdk-client-id': req.clientId,
119+
'x-e2esdk-timestamp': timestamp,
120+
'x-e2esdk-signature': signature,
121+
'x-e2esdk-server-pubkey': env.SIGNATURE_PUBLIC_KEY,
122+
},
123+
})
124+
req.auditLog.trace({
125+
msg: `webhook:${name}:fetch:response`,
126+
status: response.status,
127+
})
128+
return response
129+
} catch (error) {
130+
req.auditLog.trace({
131+
msg: `webhook:${name}:error`,
132+
error,
133+
})
134+
return null
135+
}
136+
}
137+
138+
const decoration: Decoration = {
139+
async authorizeSignup(req) {
140+
const userId = encodeURIComponent(req.identity.userId)
141+
const response = await webhookApiCall({
142+
req,
143+
name: 'authorizeSignup',
144+
path: '/authorize/signup',
145+
body: {
146+
userId,
147+
},
148+
})
149+
return response?.status === 200
150+
},
151+
async authorizeKeyShare(req, sharedKey) {
152+
const response = await webhookApiCall({
153+
req,
154+
name: 'authorizeKeyShare',
155+
path: '/authorize/key-share',
156+
body: sharedKey,
157+
})
158+
return response?.status === 200
159+
},
160+
notifySignup(req, identity) {
161+
webhookApiCall({
162+
req,
163+
name: 'notifySignup',
164+
path: '/notify/signup',
165+
body: identity,
166+
})
167+
},
168+
notifyKeyAdded(req, item) {
169+
webhookApiCall({
170+
req,
171+
name: 'notifyKeyAdded',
172+
path: '/notify/key-added',
173+
body: item,
174+
})
175+
},
176+
}
177+
app.decorate('webhook', decoration)
178+
}
179+
180+
export default fp(webhookPlugin, {
181+
fastify: '4.x',
182+
name: 'webhook',
183+
dependencies: ['pkg'],
184+
decorators: {
185+
fastify: ['pkg'],
186+
},
187+
})

packages/server/src/routes/auth.ts

+12
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@socialgouv/e2esdk-api'
77
import { zodToJsonSchema } from 'zod-to-json-schema'
88
import { createIdentity } from '../database/models/identity.js'
9+
import { env } from '../env.js'
910
import { App } from '../types'
1011

1112
export default async function authRoutes(app: App) {
@@ -38,12 +39,23 @@ export default async function authRoutes(app: App) {
3839
},
3940
},
4041
async function signup(req, res) {
42+
if (!env.ENABLE_SIGNUP) {
43+
const reason = 'Signup is not allowed on this server'
44+
req.auditLog.warn({ msg: 'signup:forbidden', body: req.body, reason })
45+
throw app.httpErrors.forbidden(reason)
46+
}
47+
if (!(await app.webhook.authorizeSignup(req))) {
48+
const reason = 'Signup not allowed by application server'
49+
req.auditLog.warn({ msg: 'signup:forbidden', body: req.body, reason })
50+
throw app.httpErrors.forbidden(reason)
51+
}
4152
try {
4253
await createIdentity(app.db, req.body)
4354
} catch {
4455
req.auditLog.warn({ msg: 'signup:conflict', body: req.body })
4556
throw app.httpErrors.conflict('This account was already registered')
4657
}
58+
app.webhook.notifySignup(req, req.body)
4759
req.auditLog.info({ msg: 'signup:success', body: req.body })
4860
return res.status(201).send(req.body)
4961
}

packages/server/src/routes/info.ts

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import fs from 'node:fs/promises'
2-
import path from 'node:path'
3-
import { fileURLToPath } from 'node:url'
41
import { z } from 'zod'
52
import { zodToJsonSchema } from 'zod-to-json-schema'
63
import { env } from '../env.js'
@@ -19,25 +16,11 @@ const infoResponseBody = z.object({
1916
})
2017
type InfoResponseBody = z.infer<typeof infoResponseBody>
2118

22-
async function readVersion() {
23-
const packageJsonPath = path.resolve(
24-
path.dirname(fileURLToPath(import.meta.url)),
25-
'../../package.json'
26-
)
27-
try {
28-
const packageJson = await fs.readFile(packageJsonPath, { encoding: 'utf8' })
29-
return JSON.parse(packageJson).version
30-
} catch {
31-
return Promise.resolve('local')
32-
}
33-
}
34-
3519
// --
3620

3721
export default async function infoRoutes(app: App) {
38-
const version = await readVersion()
3922
const serverInfo: InfoResponseBody = {
40-
version,
23+
version: app.pkg.version,
4124
builtAt: app.codeSignature.timestamp,
4225
buildURL: app.codeSignature.buildURL,
4326
sourceURL: app.codeSignature.sourceURL,

packages/server/src/routes/keychain.ts

+16
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ export default async function keychainRoutes(app: App) {
128128
body: req.body,
129129
isAuthor: true,
130130
})
131+
app.webhook.notifyKeyAdded(req, {
132+
createdAt: req.body.createdAt,
133+
expiresAt: req.body.expiresAt,
134+
nameFingerprint: req.body.nameFingerprint,
135+
payloadFingerprint: req.body.payloadFingerprint,
136+
ownerId: req.body.ownerId,
137+
sharedBy: req.body.sharedBy,
138+
})
131139
return res.status(201).send()
132140
}
133141

@@ -174,6 +182,14 @@ export default async function keychainRoutes(app: App) {
174182
isAuthor: false,
175183
sharedKey,
176184
})
185+
app.webhook.notifyKeyAdded(req, {
186+
createdAt: req.body.createdAt,
187+
expiresAt: req.body.expiresAt,
188+
nameFingerprint: req.body.nameFingerprint,
189+
payloadFingerprint: req.body.payloadFingerprint,
190+
ownerId: req.body.ownerId,
191+
sharedBy: req.body.sharedBy,
192+
})
177193
return res.status(201).send()
178194
}
179195
)

packages/server/src/routes/sharedKeys.ts

+10
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ export default async function sharedKeysRoutes(app: App) {
104104
})
105105
throw app.httpErrors.forbidden(reason)
106106
}
107+
// Finally, ask the application server through a webhook
108+
if (!(await app.webhook.authorizeKeyShare(req, req.body))) {
109+
const reason = 'You are not allowed to share this key'
110+
req.auditLog.warn({
111+
msg: 'postSharedKey:forbidden',
112+
reason,
113+
body: req.body,
114+
})
115+
throw app.httpErrors.forbidden(reason)
116+
}
107117
await storeSharedKey(app.db, req.body)
108118
req.auditLog.info({ msg: 'postSharedKey:success', body: req.body })
109119
return res.status(201).send()

0 commit comments

Comments
 (0)