odoro 1.0.9 → 2.0.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/README.md +94 -0
- package/client.d.ts +90 -16
- package/dist/build-LZAUDJST.js +5 -0
- package/dist/{chunk-34RGOZFA.js → chunk-4D56Z7G3.js} +49 -50
- package/dist/{chunk-JMEHF3KN.js → chunk-7ZB5MAM6.js} +4 -4
- package/dist/{chunk-LHZGX5ML.js → chunk-OMHEQVIW.js} +295 -160
- package/dist/{chunk-3SZIN6VG.js → chunk-P3DJITWE.js} +10 -10
- package/dist/{chunk-ZVL7EXJO.js → chunk-PRWVZ2SM.js} +22 -14
- package/dist/chunk-Q2KCMTB5.js +741 -0
- package/dist/chunk-QOVBLN7A.js +241 -0
- package/dist/chunk-WWTDEV4Y.js +604 -0
- package/dist/cli.d.ts +12 -13
- package/dist/cli.js +84 -63
- package/dist/{commands-4JRBD55Z.js → commands-EUGVLQWL.js} +44 -45
- package/dist/{commands-AHMXBWQQ.js → commands-GYA7NTCX.js} +124 -124
- package/dist/{create-CYVSVDAD.js → create-QX5LEWMS.js} +158 -154
- package/dist/index.d.ts +603 -151
- package/dist/index.js +6 -6
- package/dist/{package-EIDLKAA6.js → package-T4Z7OBOH.js} +5 -5
- package/dist/preview-P4WY5GEY.js +4 -0
- package/dist/registry/index.d.ts +75 -76
- package/dist/registry/index.js +1 -1
- package/dist/{server-4UGN3SFS.js → server-2WP562EV.js} +3 -3
- package/package.json +5 -5
- package/templates/react-ts/README.md +71 -35
- package/templates/react-ts/_env.example +9 -0
- package/templates/react-ts/_variants/with-engine/src/background.tsx +118 -0
- package/templates/react-ts/_variants/without-libs/src/App.tsx +380 -0
- package/templates/react-ts/_variants/without-libs/src/background.tsx +24 -0
- package/templates/react-ts/_variants/without-libs/src/entry-server.tsx +39 -0
- package/templates/react-ts/_variants/without-libs/src/main.tsx +26 -0
- package/templates/{react-ts-server/_variantes/sans-libs/client → react-ts/_variants/without-libs}/src/styles.css +137 -137
- package/templates/react-ts/{_variantes/sans-routeur → _variants/without-router}/src/App.tsx +141 -141
- package/templates/react-ts/_variants/without-router/src/entry-server.tsx +39 -0
- package/templates/react-ts/index.html +4 -4
- package/templates/react-ts/odoro.config.ts +5 -0
- package/templates/react-ts/src/App.tsx +219 -209
- package/templates/react-ts/src/background.tsx +66 -0
- package/templates/react-ts/src/entry-server.tsx +85 -0
- package/templates/react-ts/src/main.tsx +13 -4
- package/templates/react-ts/src/router.tsx +65 -45
- package/templates/react-ts/src/styles.css +5 -5
- package/templates/react-ts-server/Dockerfile +9 -9
- package/templates/react-ts-server/README.md +97 -45
- package/templates/react-ts-server/_env.example +31 -33
- package/templates/react-ts-server/_variants/with-engine/client/src/background.tsx +118 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/App.tsx +380 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/background.tsx +24 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/entry-server.tsx +39 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/main.tsx +26 -0
- package/templates/{react-ts/_variantes/sans-libs → react-ts-server/_variants/without-libs/client}/src/styles.css +137 -137
- package/templates/react-ts-server/{_variantes/sans-routeur → _variants/without-router}/client/src/App.tsx +141 -141
- package/templates/react-ts-server/_variants/without-router/client/src/entry-server.tsx +39 -0
- package/templates/react-ts-server/client/index.html +4 -4
- package/templates/react-ts-server/client/src/App.tsx +228 -210
- package/templates/react-ts-server/client/src/account.tsx +267 -0
- package/templates/react-ts-server/client/src/auth.tsx +139 -0
- package/templates/react-ts-server/client/src/background.tsx +66 -0
- package/templates/react-ts-server/client/src/entry-server.tsx +92 -0
- package/templates/react-ts-server/client/src/main.tsx +17 -5
- package/templates/react-ts-server/client/src/router.tsx +77 -45
- package/templates/react-ts-server/client/src/styles.css +5 -5
- package/templates/react-ts-server/odoro.config.ts +7 -4
- package/templates/react-ts-server/package.json +2 -0
- package/templates/react-ts-server/scripts/dev.mjs +14 -14
- package/templates/react-ts-server/server/src/main.ts +119 -45
- package/templates/react-ts-server/server/src/modules/auth/index.ts +238 -0
- package/templates/react-ts-server/server/src/modules/auth/password.ts +122 -0
- package/templates/react-ts-server/server/src/modules/auth/store.ts +193 -0
- package/templates/react-ts-server/server/src/modules/health/index.ts +45 -46
- package/dist/build-JFQHODAT.js +0 -5
- package/dist/chunk-22KJTV2R.js +0 -380
- package/dist/chunk-DL3NPC4H.js +0 -113
- package/dist/chunk-TQUJ3MFS.js +0 -268
- package/dist/preview-7DKEAQOQ.js +0 -4
- package/templates/react-ts/_variantes/avec-moteur/src/fond.tsx +0 -118
- package/templates/react-ts/_variantes/sans-libs/src/App.tsx +0 -382
- package/templates/react-ts/_variantes/sans-libs/src/fond.tsx +0 -23
- package/templates/react-ts/_variantes/sans-libs/src/main.tsx +0 -17
- package/templates/react-ts/src/fond.tsx +0 -66
- package/templates/react-ts-server/_variantes/avec-moteur/client/src/fond.tsx +0 -118
- package/templates/react-ts-server/_variantes/sans-libs/client/src/App.tsx +0 -382
- package/templates/react-ts-server/_variantes/sans-libs/client/src/fond.tsx +0 -23
- package/templates/react-ts-server/_variantes/sans-libs/client/src/main.tsx +0 -17
- package/templates/react-ts-server/client/src/fond.tsx +0 -66
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication module — register, sign in, sign out, read your profile.
|
|
3
|
+
*
|
|
4
|
+
* ## What this demonstration is, and what it is not
|
|
5
|
+
*
|
|
6
|
+
* It is a working account system: a password hashed with `scrypt`, a session
|
|
7
|
+
* in a table, an `httpOnly` cookie. It is the shape to start from, and it is
|
|
8
|
+
* deliberately small enough to be read in full before being trusted.
|
|
9
|
+
*
|
|
10
|
+
* It is not a complete product. There is no email confirmation, no password
|
|
11
|
+
* reset, no rate limit on login, no second factor. Each one is a deliberate
|
|
12
|
+
* addition, and naming them here is more honest than implying they are handled.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the session is a cookie, and not a token in the page
|
|
15
|
+
*
|
|
16
|
+
* A token kept in `localStorage` is readable by any script that manages to run
|
|
17
|
+
* on the page. An `httpOnly` cookie is not: the browser sends it and never
|
|
18
|
+
* hands it over. The cost is a `sameSite` policy to think about, which the
|
|
19
|
+
* kernel sets to `lax` — enough to block the ordinary cross-site form post,
|
|
20
|
+
* while an ordinary link still arrives signed in.
|
|
21
|
+
*
|
|
22
|
+
* ## Why signing in says so little
|
|
23
|
+
*
|
|
24
|
+
* A wrong email and a wrong password give the **same** answer. Distinguishing
|
|
25
|
+
* them turns the login form into a way to ask whether an address has an
|
|
26
|
+
* account here — which is exactly what someone assembling a list wants.
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
ConflictError,
|
|
33
|
+
UnauthorizedError,
|
|
34
|
+
defineModule,
|
|
35
|
+
route,
|
|
36
|
+
} from '@odoro-cli/server'
|
|
37
|
+
import type { Request } from 'express'
|
|
38
|
+
import { z } from 'zod'
|
|
39
|
+
|
|
40
|
+
import { hashPassword, verifyPassword } from './password.js'
|
|
41
|
+
import { SESSION_TTL, createAuthStore, type Account, type AuthStore } from './store.js'
|
|
42
|
+
|
|
43
|
+
/** Name of the session cookie. */
|
|
44
|
+
export const SESSION_COOKIE = 'odoro_session'
|
|
45
|
+
|
|
46
|
+
/** What a profile shows. */
|
|
47
|
+
const profile = z.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
email: z.string(),
|
|
50
|
+
name: z.string(),
|
|
51
|
+
createdAt: z.string(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** What registering asks for. */
|
|
55
|
+
const registration = z.object({
|
|
56
|
+
email: z.string().email().max(320),
|
|
57
|
+
name: z.string().min(1).max(80),
|
|
58
|
+
// Length is the only rule that holds: a composition rule pushes people
|
|
59
|
+
// towards "Password1!" and nothing else.
|
|
60
|
+
password: z.string().min(12).max(200),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** What signing in asks for. */
|
|
64
|
+
const credentials = z.object({
|
|
65
|
+
email: z.string().email().max(320),
|
|
66
|
+
password: z.string().min(1).max(200),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** Renders an account as the interface reads it. */
|
|
70
|
+
function render(account: Account): z.infer<typeof profile> {
|
|
71
|
+
return {
|
|
72
|
+
id: account.id,
|
|
73
|
+
email: account.email,
|
|
74
|
+
name: account.name,
|
|
75
|
+
createdAt: account.createdAt.toISOString(),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Reads the session cookie of a request, without a parser dependency. */
|
|
80
|
+
export function readSessionCookie(request: Request): string | undefined {
|
|
81
|
+
const header = request.headers.cookie
|
|
82
|
+
if (header === undefined) return undefined
|
|
83
|
+
|
|
84
|
+
for (const part of header.split(';')) {
|
|
85
|
+
const cut = part.indexOf('=')
|
|
86
|
+
if (cut === -1) continue
|
|
87
|
+
if (part.slice(0, cut).trim() !== SESSION_COOKIE) continue
|
|
88
|
+
return decodeURIComponent(part.slice(cut + 1).trim())
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds the guard the kernel calls on every request.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* createApp({ authenticate: createAuthenticator(store), … })
|
|
98
|
+
*/
|
|
99
|
+
export function createAuthenticator(store: AuthStore) {
|
|
100
|
+
return async (request: Request) => {
|
|
101
|
+
const sessionId = readSessionCookie(request)
|
|
102
|
+
if (sessionId === undefined) return undefined
|
|
103
|
+
|
|
104
|
+
const session = await store.readSession(sessionId)
|
|
105
|
+
if (session === undefined) return undefined
|
|
106
|
+
|
|
107
|
+
return { id: session.userId, sessionId, organizationId: undefined }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The module, and the store it owns.
|
|
113
|
+
*
|
|
114
|
+
* The store comes back out because the guard needs it too, and because whoever
|
|
115
|
+
* closes the server has to close its connections.
|
|
116
|
+
*
|
|
117
|
+
* ## Why the store can be handed in
|
|
118
|
+
*
|
|
119
|
+
* By default the module opens its own, on `DATABASE_URL`. A test hands in a
|
|
120
|
+
* different one and drives the four routes without a database — which is the
|
|
121
|
+
* only way to check the cookie, the guard and the answers without provisioning
|
|
122
|
+
* a server for it.
|
|
123
|
+
*
|
|
124
|
+
* @param store The store to use. Absent, one is opened on `DATABASE_URL`.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* const auth = createAuthModule(config)
|
|
128
|
+
* createApp({ modules: [auth.module], authenticate: auth.authenticate, … })
|
|
129
|
+
*/
|
|
130
|
+
export function createAuthModule(
|
|
131
|
+
config: { DATABASE_URL: string },
|
|
132
|
+
store: AuthStore = createAuthStore(config.DATABASE_URL),
|
|
133
|
+
) {
|
|
134
|
+
|
|
135
|
+
const module = defineModule({
|
|
136
|
+
name: 'auth',
|
|
137
|
+
routes: [
|
|
138
|
+
route({
|
|
139
|
+
name: 'auth.register',
|
|
140
|
+
method: 'POST',
|
|
141
|
+
path: '/api/auth/register',
|
|
142
|
+
auth: 'public',
|
|
143
|
+
summary: 'Creates an account and signs it in.',
|
|
144
|
+
input: registration,
|
|
145
|
+
output: profile,
|
|
146
|
+
handler: async ({ input, cookies }) => {
|
|
147
|
+
const email = input.email.trim().toLowerCase()
|
|
148
|
+
|
|
149
|
+
if ((await store.byEmail(email)) !== undefined) {
|
|
150
|
+
throw new ConflictError('This address already has an account.')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const account = await store.create(
|
|
154
|
+
email,
|
|
155
|
+
input.name.trim(),
|
|
156
|
+
await hashPassword(input.password),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
cookies.set(SESSION_COOKIE, await store.openSession(account.id), {
|
|
160
|
+
maxAge: SESSION_TTL,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return render(account)
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
|
|
167
|
+
route({
|
|
168
|
+
name: 'auth.login',
|
|
169
|
+
method: 'POST',
|
|
170
|
+
path: '/api/auth/login',
|
|
171
|
+
auth: 'public',
|
|
172
|
+
summary: 'Signs in.',
|
|
173
|
+
input: credentials,
|
|
174
|
+
output: profile,
|
|
175
|
+
handler: async ({ input, cookies }) => {
|
|
176
|
+
const account = await store.byEmail(input.email.trim().toLowerCase())
|
|
177
|
+
|
|
178
|
+
// The same answer in both cases, and the hash is computed even when
|
|
179
|
+
// the account is absent: answering faster for an unknown address
|
|
180
|
+
// would tell, by the delay alone, which addresses exist.
|
|
181
|
+
const stored =
|
|
182
|
+
account?.password ??
|
|
183
|
+
'scrypt$65536$8$1$00000000000000000000000000000000$00'
|
|
184
|
+
const matches = await verifyPassword(input.password, stored)
|
|
185
|
+
|
|
186
|
+
if (account === undefined || !matches) {
|
|
187
|
+
throw new UnauthorizedError('Wrong address or password.')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
cookies.set(SESSION_COOKIE, await store.openSession(account.id), {
|
|
191
|
+
maxAge: SESSION_TTL,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
return render(account)
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
|
|
198
|
+
route({
|
|
199
|
+
name: 'auth.logout',
|
|
200
|
+
method: 'POST',
|
|
201
|
+
path: '/api/auth/logout',
|
|
202
|
+
auth: 'optional',
|
|
203
|
+
summary: 'Signs out.',
|
|
204
|
+
handler: async ({ user, cookies }) => {
|
|
205
|
+
// The cookie goes in every case: a session already unknown to the
|
|
206
|
+
// table must not leave a cookie behind that the browser keeps
|
|
207
|
+
// sending for a month.
|
|
208
|
+
if (user !== undefined) await store.closeSession(user.sessionId)
|
|
209
|
+
cookies.clear(SESSION_COOKIE)
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
|
|
213
|
+
route({
|
|
214
|
+
name: 'auth.me',
|
|
215
|
+
method: 'GET',
|
|
216
|
+
path: '/api/auth/me',
|
|
217
|
+
auth: 'required',
|
|
218
|
+
summary: 'The profile of the signed-in account.',
|
|
219
|
+
output: profile,
|
|
220
|
+
handler: async ({ user, cookies }) => {
|
|
221
|
+
const account = await store.byId(user.id)
|
|
222
|
+
|
|
223
|
+
// The session names an account that is gone: the cookie is stale,
|
|
224
|
+
// and keeping it would make every request fail the same way.
|
|
225
|
+
if (account === undefined) {
|
|
226
|
+
await store.closeSession(user.sessionId)
|
|
227
|
+
cookies.clear(SESSION_COOKIE)
|
|
228
|
+
throw new UnauthorizedError('This account no longer exists.')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return render(account)
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
] as never,
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
return { module, store, authenticate: createAuthenticator(store) }
|
|
238
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashing and verifying a password.
|
|
3
|
+
*
|
|
4
|
+
* ## Why there is no dependency here
|
|
5
|
+
*
|
|
6
|
+
* `scrypt` is in the Node standard library, and it is a password hash designed
|
|
7
|
+
* for the job: slow on purpose, and costly in memory, which is what makes a
|
|
8
|
+
* rented GPU farm a bad deal for whoever steals the table.
|
|
9
|
+
*
|
|
10
|
+
* Argon2 and bcrypt are fine choices too. They are native modules: a compiler
|
|
11
|
+
* on every machine, a rebuild on every Node upgrade, and a binary in the image.
|
|
12
|
+
* For the same guarantee, that is a price worth not paying.
|
|
13
|
+
*
|
|
14
|
+
* ## What is stored
|
|
15
|
+
*
|
|
16
|
+
* `scrypt$N$r$p$salt$hash`, everything in hexadecimal. The parameters travel
|
|
17
|
+
* with the hash: the day they are raised, the passwords already stored keep
|
|
18
|
+
* verifying with their own, and each one migrates at its owner's next login.
|
|
19
|
+
* A hash that does not carry its parameters cannot be migrated without asking
|
|
20
|
+
* everyone to reset.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'
|
|
26
|
+
import { promisify } from 'node:util'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `scrypt`, promised.
|
|
30
|
+
*
|
|
31
|
+
* The signature is written out: `promisify` keeps only the first overload of
|
|
32
|
+
* `scrypt`, the one without options — and the options are exactly what carries
|
|
33
|
+
* the cost here.
|
|
34
|
+
*/
|
|
35
|
+
const derive = promisify(scrypt) as (
|
|
36
|
+
password: string,
|
|
37
|
+
salt: Buffer,
|
|
38
|
+
keyLength: number,
|
|
39
|
+
options: ScryptOptions,
|
|
40
|
+
) => Promise<Buffer>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cost of the derivation.
|
|
44
|
+
*
|
|
45
|
+
* `N = 2^16` takes about a hundred milliseconds on an ordinary server, and
|
|
46
|
+
* asks for 64 MB of memory. That is the knob to turn as machines get faster —
|
|
47
|
+
* the stored hashes will follow on their own.
|
|
48
|
+
*/
|
|
49
|
+
const COST = { N: 65_536, r: 8, p: 1, keyLength: 32 } as const
|
|
50
|
+
|
|
51
|
+
/** Length of the salt, in bytes. */
|
|
52
|
+
const SALT = 16
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Hashes a password.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const stored = await hashPassword('correct horse battery staple')
|
|
59
|
+
*/
|
|
60
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
61
|
+
const salt = randomBytes(SALT)
|
|
62
|
+
const key = await derive(password, salt, COST.keyLength, {
|
|
63
|
+
N: COST.N,
|
|
64
|
+
r: COST.r,
|
|
65
|
+
p: COST.p,
|
|
66
|
+
// `scrypt` refuses beyond a default ceiling well below what N = 2^16 asks
|
|
67
|
+
// for. Raising it is not a weakening: it is the memory the cost demands.
|
|
68
|
+
maxmem: 256 * COST.N * COST.r,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return ['scrypt', COST.N, COST.r, COST.p, salt.toString('hex'), key.toString('hex')].join(
|
|
72
|
+
'$',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Verifies a password against a stored hash.
|
|
78
|
+
*
|
|
79
|
+
* ## Why it never throws
|
|
80
|
+
*
|
|
81
|
+
* A malformed hash — a truncated row, a migration half done — is a failed
|
|
82
|
+
* verification, not a crash. Throwing here would turn a corrupt row into a
|
|
83
|
+
* 500 on a login page, which tells an attacker that this account is special.
|
|
84
|
+
*
|
|
85
|
+
* @returns `true` if the password matches.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* await verifyPassword('correct horse battery staple', stored) // true
|
|
89
|
+
*/
|
|
90
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
91
|
+
const parts = stored.split('$')
|
|
92
|
+
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
|
|
93
|
+
|
|
94
|
+
const [, rawN, rawR, rawP, rawSalt, rawKey] = parts
|
|
95
|
+
const N = Number(rawN)
|
|
96
|
+
const r = Number(rawR)
|
|
97
|
+
const p = Number(rawP)
|
|
98
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false
|
|
99
|
+
|
|
100
|
+
let expected: Buffer
|
|
101
|
+
try {
|
|
102
|
+
expected = Buffer.from(rawKey ?? '', 'hex')
|
|
103
|
+
if (expected.length === 0) return false
|
|
104
|
+
} catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const key = await derive(password, Buffer.from(rawSalt ?? '', 'hex'), expected.length, {
|
|
110
|
+
N,
|
|
111
|
+
r,
|
|
112
|
+
p,
|
|
113
|
+
maxmem: 256 * N * r,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Constant time: a comparison that stops at the first differing byte tells,
|
|
117
|
+
// by how long it took, how many bytes were right.
|
|
118
|
+
return timingSafeEqual(key, expected)
|
|
119
|
+
} catch {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the accounts and the sessions live.
|
|
3
|
+
*
|
|
4
|
+
* ## PostgreSQL, and nothing else
|
|
5
|
+
*
|
|
6
|
+
* There is no local database in this stack: `DATABASE_URL` points at a hosted
|
|
7
|
+
* one. So this module refuses clearly when it is missing, rather than falling
|
|
8
|
+
* back to memory — a demo that works without a database teaches a shape that
|
|
9
|
+
* stops working the day it is deployed, and does so silently.
|
|
10
|
+
*
|
|
11
|
+
* ## The schema creates itself, once
|
|
12
|
+
*
|
|
13
|
+
* Two tables, created on first use. That is enough for a demo and for a first
|
|
14
|
+
* deployment; it is not a migration tool. The day the schema changes shape,
|
|
15
|
+
* the project needs a real one — and that day, this file is the one to delete.
|
|
16
|
+
*
|
|
17
|
+
* ## What is stored of a session
|
|
18
|
+
*
|
|
19
|
+
* Its **fingerprint**, not the session itself. Whoever reads the table — a
|
|
20
|
+
* backup, a support query, a leak — holds hashes, and a hash does not open a
|
|
21
|
+
* session. The identifier itself only ever exists in the cookie.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
27
|
+
|
|
28
|
+
import { ServiceUnavailableError } from '@odoro-cli/server'
|
|
29
|
+
import pg from 'pg'
|
|
30
|
+
|
|
31
|
+
/** An account, as the rest of the module reads it. */
|
|
32
|
+
export interface Account {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly email: string
|
|
35
|
+
readonly name: string
|
|
36
|
+
readonly createdAt: Date
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An account and the hash that guards it. */
|
|
40
|
+
interface AccountRow extends Account {
|
|
41
|
+
readonly password: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How long a session lives, in seconds. */
|
|
45
|
+
export const SESSION_TTL = 60 * 60 * 24 * 30
|
|
46
|
+
|
|
47
|
+
/** The schema, created on first use. */
|
|
48
|
+
const SCHEMA = `
|
|
49
|
+
CREATE TABLE IF NOT EXISTS app_user (
|
|
50
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
51
|
+
email text NOT NULL UNIQUE,
|
|
52
|
+
name text NOT NULL,
|
|
53
|
+
password text NOT NULL,
|
|
54
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS app_session (
|
|
58
|
+
id text PRIMARY KEY,
|
|
59
|
+
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
60
|
+
expires_at timestamptz NOT NULL,
|
|
61
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS app_session_user ON app_session(user_id);
|
|
65
|
+
`
|
|
66
|
+
|
|
67
|
+
/** The fingerprint under which a session identifier is stored. */
|
|
68
|
+
function fingerprint(id: string): string {
|
|
69
|
+
return createHash('sha256').update(id).digest('hex')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the rest of the module calls. */
|
|
73
|
+
export interface AuthStore {
|
|
74
|
+
create(email: string, name: string, password: string): Promise<Account>
|
|
75
|
+
byEmail(email: string): Promise<AccountRow | undefined>
|
|
76
|
+
byId(id: string): Promise<Account | undefined>
|
|
77
|
+
openSession(userId: string): Promise<string>
|
|
78
|
+
readSession(sessionId: string): Promise<{ userId: string } | undefined>
|
|
79
|
+
closeSession(sessionId: string): Promise<void>
|
|
80
|
+
dispose(): Promise<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Opens the store.
|
|
85
|
+
*
|
|
86
|
+
* @param url The `DATABASE_URL`. Empty, every call refuses with a 503 that
|
|
87
|
+
* names what is missing — the interface still starts, and says so.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* const store = createAuthStore(config.DATABASE_URL)
|
|
91
|
+
* await store.create('a@b.c', 'Ada', 'correct horse battery staple')
|
|
92
|
+
*/
|
|
93
|
+
export function createAuthStore(url: string): AuthStore {
|
|
94
|
+
const configured = url.trim().length > 0
|
|
95
|
+
|
|
96
|
+
const absent = (): never => {
|
|
97
|
+
throw new ServiceUnavailableError(
|
|
98
|
+
'DATABASE_URL missing: the accounts have nowhere to live. ' +
|
|
99
|
+
'See .env.example, or run `odoro db:create`.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pool = configured
|
|
104
|
+
? new pg.Pool({
|
|
105
|
+
connectionString: url,
|
|
106
|
+
// A demo does not need twenty connections, and a hosted database
|
|
107
|
+
// counts them.
|
|
108
|
+
max: 5,
|
|
109
|
+
})
|
|
110
|
+
: undefined
|
|
111
|
+
|
|
112
|
+
let ready: Promise<void> | undefined
|
|
113
|
+
|
|
114
|
+
/** Creates the schema, once per process. */
|
|
115
|
+
const ensure = async (): Promise<pg.Pool> => {
|
|
116
|
+
if (pool === undefined) return absent()
|
|
117
|
+
ready ??= pool.query(SCHEMA).then(() => undefined)
|
|
118
|
+
await ready
|
|
119
|
+
return pool
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
async create(email, name, password) {
|
|
124
|
+
const db = await ensure()
|
|
125
|
+
const { rows } = await db.query<Account>(
|
|
126
|
+
`INSERT INTO app_user (email, name, password)
|
|
127
|
+
VALUES ($1, $2, $3)
|
|
128
|
+
RETURNING id, email, name, created_at AS "createdAt"`,
|
|
129
|
+
[email, name, password],
|
|
130
|
+
)
|
|
131
|
+
// The row is there: `INSERT ... RETURNING` either returns it or throws.
|
|
132
|
+
return rows[0] as Account
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async byEmail(email) {
|
|
136
|
+
const db = await ensure()
|
|
137
|
+
const { rows } = await db.query<AccountRow>(
|
|
138
|
+
`SELECT id, email, name, password, created_at AS "createdAt"
|
|
139
|
+
FROM app_user WHERE email = $1`,
|
|
140
|
+
[email],
|
|
141
|
+
)
|
|
142
|
+
return rows[0]
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async byId(id) {
|
|
146
|
+
const db = await ensure()
|
|
147
|
+
const { rows } = await db.query<Account>(
|
|
148
|
+
`SELECT id, email, name, created_at AS "createdAt"
|
|
149
|
+
FROM app_user WHERE id = $1`,
|
|
150
|
+
[id],
|
|
151
|
+
)
|
|
152
|
+
return rows[0]
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async openSession(userId) {
|
|
156
|
+
const db = await ensure()
|
|
157
|
+
|
|
158
|
+
// Expired sessions go now, on a query that has nothing else to do. A
|
|
159
|
+
// scheduled job would be better; this is enough not to keep them
|
|
160
|
+
// forever, and it costs one index scan per login.
|
|
161
|
+
await db.query(`DELETE FROM app_session WHERE expires_at <= now()`)
|
|
162
|
+
|
|
163
|
+
// 256 bits drawn from the system source. This value never lands in the
|
|
164
|
+
// table: only its fingerprint does.
|
|
165
|
+
const id = randomBytes(32).toString('base64url')
|
|
166
|
+
await db.query(
|
|
167
|
+
`INSERT INTO app_session (id, user_id, expires_at)
|
|
168
|
+
VALUES ($1, $2, now() + make_interval(secs => $3))`,
|
|
169
|
+
[fingerprint(id), userId, SESSION_TTL],
|
|
170
|
+
)
|
|
171
|
+
return id
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async readSession(sessionId) {
|
|
175
|
+
const db = await ensure()
|
|
176
|
+
const { rows } = await db.query<{ userId: string }>(
|
|
177
|
+
`SELECT user_id AS "userId" FROM app_session
|
|
178
|
+
WHERE id = $1 AND expires_at > now()`,
|
|
179
|
+
[fingerprint(sessionId)],
|
|
180
|
+
)
|
|
181
|
+
return rows[0]
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async closeSession(sessionId) {
|
|
185
|
+
const db = await ensure()
|
|
186
|
+
await db.query(`DELETE FROM app_session WHERE id = $1`, [fingerprint(sessionId)])
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async dispose() {
|
|
190
|
+
await pool?.end()
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|