@reuters-graphics/gfx-better-auth 0.1.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 +81 -0
- package/dist/client/index.d.ts +142 -0
- package/dist/client/index.js +32 -0
- package/dist/domains.d.ts +24 -0
- package/dist/domains.js +30 -0
- package/dist/emails/Template.d.ts +36 -0
- package/dist/emails/Template.js +82 -0
- package/dist/emails/_components/Brand.d.ts +13 -0
- package/dist/emails/_components/Brand.js +28 -0
- package/dist/emails/_components/CentreCard.d.ts +10 -0
- package/dist/emails/_components/CentreCard.js +22 -0
- package/dist/emails/_components/CentreWell.d.ts +7 -0
- package/dist/emails/_components/CentreWell.js +9 -0
- package/dist/emails/_components/Footer.d.ts +7 -0
- package/dist/emails/_components/Footer.js +26 -0
- package/dist/emails/_components/Head.d.ts +10 -0
- package/dist/emails/_components/Head.js +19 -0
- package/dist/emails/_components/OpenButton.d.ts +8 -0
- package/dist/emails/_components/OpenButton.js +21 -0
- package/dist/emails/_components/index.d.ts +14 -0
- package/dist/emails/_components/index.js +14 -0
- package/dist/emails/_components/tokens.d.ts +28 -0
- package/dist/emails/_components/tokens.js +28 -0
- package/dist/emails/index.d.ts +96 -0
- package/dist/emails/index.js +160 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/schema/index.d.ts +718 -0
- package/dist/schema/index.js +108 -0
- package/dist/server/auth.d.ts +67 -0
- package/dist/server/auth.js +152 -0
- package/dist/server/config.d.ts +81 -0
- package/dist/server/config.js +105 -0
- package/dist/server/dev.d.ts +15 -0
- package/dist/server/dev.js +48 -0
- package/dist/server/escape.d.ts +9 -0
- package/dist/server/escape.js +28 -0
- package/dist/server/guard.d.ts +13 -0
- package/dist/server/guard.js +17 -0
- package/dist/server/handle.d.ts +62 -0
- package/dist/server/handle.js +252 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.js +11 -0
- package/dist/server/pages.d.ts +53 -0
- package/dist/server/pages.js +133 -0
- package/dist/server/testing.d.ts +31 -0
- package/dist/server/testing.js +53 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/package.json +101 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
|
2
|
+
/**
|
|
3
|
+
* The four tables better-auth needs, as **column objects you spread** plus
|
|
4
|
+
* pre-assembled tables for apps that don't extend them.
|
|
5
|
+
*
|
|
6
|
+
* The package ships table *definitions*; each app owns its own migration
|
|
7
|
+
* history (ADR-0005). Re-export from your schema barrel and point
|
|
8
|
+
* `drizzle.config.ts` at that — drizzle-kit follows imports into
|
|
9
|
+
* `node_modules` happily:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // src/lib/server/db/schema/index.ts
|
|
13
|
+
* export * from '@reuters-graphics/gfx-better-auth/schema';
|
|
14
|
+
* export * from './your-tables';
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* To add columns, spread instead of re-exporting, and tell better-auth about
|
|
18
|
+
* them via `additionalUserFields` (ADR-0006 — roles are the app's, not ours):
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const user = pgTable('user', { ...userColumns, role: text('role') });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* ⚠️ **Every timestamp is `timestamptz`, and `decision-desk`'s are not.**
|
|
26
|
+
*
|
|
27
|
+
* A bare `timestamp` column stores no offset, so the value is not an instant:
|
|
28
|
+
* the driver writes UTC and reads it back as local, and on a machine in BST
|
|
29
|
+
* every row comes back an hour early. It round-trips consistently inside one
|
|
30
|
+
* process — which is why decision-desk works — but `session.expires_at` is a
|
|
31
|
+
* security-relevant value and "consistent, given identical timezones" is not a
|
|
32
|
+
* property worth depending on (ADR-0012).
|
|
33
|
+
*
|
|
34
|
+
* Migrating from a decision-desk-shaped schema therefore needs an
|
|
35
|
+
* `ALTER TABLE … ALTER COLUMN … TYPE timestamptz`.
|
|
36
|
+
*/
|
|
37
|
+
export const userColumns = {
|
|
38
|
+
id: text('id').primaryKey(),
|
|
39
|
+
name: text('name').notNull(),
|
|
40
|
+
email: text('email').notNull().unique(),
|
|
41
|
+
emailVerified: boolean('email_verified').notNull(),
|
|
42
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
|
43
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
|
44
|
+
/**
|
|
45
|
+
* ⚠️ **Required by better-auth, and always `null` for us.** It is part of
|
|
46
|
+
* better-auth's core user model for social providers, which this package
|
|
47
|
+
* does not have — we are magic-link only, and nothing ever writes it.
|
|
48
|
+
*
|
|
49
|
+
* It is not optional. Omitting it makes better-auth ≥ 1.7 refuse the schema
|
|
50
|
+
* outright (`SchemaMismatchError`, `Missing columns: user.image`) on the
|
|
51
|
+
* first request — not at construction, so it surfaces as a failed login
|
|
52
|
+
* rather than a failed boot. `schema.test.ts` asserts the whole contract
|
|
53
|
+
* against `getAuthTables()` so the next such column is caught by a test
|
|
54
|
+
* instead of by a consumer.
|
|
55
|
+
*/
|
|
56
|
+
image: text('image'),
|
|
57
|
+
/**
|
|
58
|
+
* The kill switch (ADR-0004). Checked on every request, so a departed
|
|
59
|
+
* colleague is out immediately rather than when their session lapses.
|
|
60
|
+
* Set automatically when Postmark reports the address inactive.
|
|
61
|
+
*/
|
|
62
|
+
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
|
63
|
+
};
|
|
64
|
+
export const user = pgTable('user', userColumns);
|
|
65
|
+
export const sessionColumns = {
|
|
66
|
+
id: text('id').primaryKey(),
|
|
67
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
68
|
+
token: text('token').notNull().unique(),
|
|
69
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
|
70
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
|
71
|
+
ipAddress: text('ip_address'),
|
|
72
|
+
userAgent: text('user_agent'),
|
|
73
|
+
userId: text('user_id')
|
|
74
|
+
.notNull()
|
|
75
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
76
|
+
};
|
|
77
|
+
export const session = pgTable('session', sessionColumns);
|
|
78
|
+
export const accountColumns = {
|
|
79
|
+
id: text('id').primaryKey(),
|
|
80
|
+
accountId: text('account_id').notNull(),
|
|
81
|
+
providerId: text('provider_id').notNull(),
|
|
82
|
+
userId: text('user_id')
|
|
83
|
+
.notNull()
|
|
84
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
85
|
+
accessToken: text('access_token'),
|
|
86
|
+
refreshToken: text('refresh_token'),
|
|
87
|
+
idToken: text('id_token'),
|
|
88
|
+
accessTokenExpiresAt: timestamp('access_token_expires_at', {
|
|
89
|
+
withTimezone: true,
|
|
90
|
+
}),
|
|
91
|
+
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', {
|
|
92
|
+
withTimezone: true,
|
|
93
|
+
}),
|
|
94
|
+
scope: text('scope'),
|
|
95
|
+
password: text('password'),
|
|
96
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
|
97
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
|
98
|
+
};
|
|
99
|
+
export const account = pgTable('account', accountColumns);
|
|
100
|
+
export const verificationColumns = {
|
|
101
|
+
id: text('id').primaryKey(),
|
|
102
|
+
identifier: text('identifier').notNull(),
|
|
103
|
+
value: text('value').notNull(),
|
|
104
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
105
|
+
createdAt: timestamp('created_at', { withTimezone: true }),
|
|
106
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }),
|
|
107
|
+
};
|
|
108
|
+
export const verification = pgTable('verification', verificationColumns);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { BetterAuthOptions } from 'better-auth/types';
|
|
2
|
+
import { type GfxAuthOptions } from './config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build the configured better-auth instance.
|
|
5
|
+
*
|
|
6
|
+
* Everything opinionated lives here so a consumer does not have to know it:
|
|
7
|
+
* the domain gate, the SafeLinks indirection, the kill switch, session TTL and
|
|
8
|
+
* the development inbox.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const auth = createGfxAuth({
|
|
13
|
+
* db,
|
|
14
|
+
* appName: 'lightbox',
|
|
15
|
+
* baseURL: env.PUBLIC_APP_URL,
|
|
16
|
+
* secret: env.BETTER_AUTH_SECRET,
|
|
17
|
+
* postmark: { token: env.POSTMARK_API_KEY, from: '…', messageStream: '…' },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare const createGfxAuth: (options: GfxAuthOptions) => GfxAuth;
|
|
22
|
+
/**
|
|
23
|
+
* The auth instance, as a **hand-written type rather than an inferred one**.
|
|
24
|
+
*
|
|
25
|
+
* 🚨 This annotation is load-bearing, and removing it breaks the published
|
|
26
|
+
* package in a way `publint` cannot see. better-auth's inferred return type
|
|
27
|
+
* transitively names `@better-auth/core/db/internal`, which under pnpm lives
|
|
28
|
+
* at a hashed, non-portable path — so `svelte-package` **silently fails to
|
|
29
|
+
* emit `dist/server/auth.d.ts`**, warns, and exits 0. A consumer then gets
|
|
30
|
+
* `TS7016: Could not find a declaration file for module './auth.js'` on
|
|
31
|
+
* install: exactly the "installs and then cannot be imported" failure this
|
|
32
|
+
* repo is most afraid of.
|
|
33
|
+
*
|
|
34
|
+
* `pnpm build` now fails if any `dist` module lacks its declaration, so this
|
|
35
|
+
* cannot regress silently — see `scripts/check-declarations.mjs`.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ The cost is that this is narrower than better-auth's own instance type:
|
|
38
|
+
* it is the surface this package uses and promises, not all of `auth.api`. If
|
|
39
|
+
* you need the rest, derive it in your own app where inference is free:
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const auth = createGfxAuth({ … });
|
|
43
|
+
* // your own precise handle on it, if you need one:
|
|
44
|
+
* type MyAuth = typeof auth;
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export interface GfxAuth {
|
|
48
|
+
/** better-auth's request handler, mounted by {@link gfxAuth}. */
|
|
49
|
+
handler: (request: Request) => Promise<Response>;
|
|
50
|
+
/** The resolved better-auth options, read by `isAuthPath` and `svelteKitHandler`. */
|
|
51
|
+
options: BetterAuthOptions;
|
|
52
|
+
api: {
|
|
53
|
+
getSession: (input: {
|
|
54
|
+
headers: Headers;
|
|
55
|
+
}) => Promise<{
|
|
56
|
+
user?: unknown;
|
|
57
|
+
session?: unknown;
|
|
58
|
+
} | null>;
|
|
59
|
+
signInMagicLink: (input: {
|
|
60
|
+
body: {
|
|
61
|
+
email: string;
|
|
62
|
+
callbackURL?: string;
|
|
63
|
+
};
|
|
64
|
+
headers: Headers;
|
|
65
|
+
}) => Promise<unknown>;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
import { betterAuth } from 'better-auth';
|
|
3
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
4
|
+
import { magicLink } from 'better-auth/plugins';
|
|
5
|
+
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
|
6
|
+
import { isAllowedEmail, normaliseEmail } from '../domains.js';
|
|
7
|
+
import { gfxAuthConfig, } from './config.js';
|
|
8
|
+
import { stashDevMagicLink } from './dev.js';
|
|
9
|
+
import { sendSignInEmail } from '../emails/index.js';
|
|
10
|
+
/**
|
|
11
|
+
* Indirected through one function so the email module's exact signature is a
|
|
12
|
+
* detail of `src/emails/`, and so `sendEmail` can be injected in tests.
|
|
13
|
+
*/
|
|
14
|
+
const defaultSender = (message) => sendSignInEmail(message);
|
|
15
|
+
/** The endpoint the domain gate guards. Mount-relative — no `/api/auth`. */
|
|
16
|
+
const SIGN_IN_PATH = '/sign-in/magic-link';
|
|
17
|
+
/**
|
|
18
|
+
* Build the configured better-auth instance.
|
|
19
|
+
*
|
|
20
|
+
* Everything opinionated lives here so a consumer does not have to know it:
|
|
21
|
+
* the domain gate, the SafeLinks indirection, the kill switch, session TTL and
|
|
22
|
+
* the development inbox.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* export const auth = createGfxAuth({
|
|
27
|
+
* db,
|
|
28
|
+
* appName: 'lightbox',
|
|
29
|
+
* baseURL: env.PUBLIC_APP_URL,
|
|
30
|
+
* secret: env.BETTER_AUTH_SECRET,
|
|
31
|
+
* postmark: { token: env.POSTMARK_API_KEY, from: '…', messageStream: '…' },
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export const createGfxAuth = (options) => {
|
|
36
|
+
const { db, additionalUserFields, sendEmail, ...rest } = options;
|
|
37
|
+
const config = gfxAuthConfig.parse(rest);
|
|
38
|
+
const send = sendEmail ?? defaultSender;
|
|
39
|
+
// ✅ **The same guard as everywhere else, and it tree-shakes.** This used to
|
|
40
|
+
// be a `process.env.NODE_ENV` check, on the reasoning that `$app/environment`
|
|
41
|
+
// could not be imported here because better-auth's CLI loads a consumer's
|
|
42
|
+
// auth instance from plain Node. That reasoning was sound and the workaround
|
|
43
|
+
// was pointless: the `/server` barrel re-exported modules that imported
|
|
44
|
+
// `$app/*` anyway, so the CLI was broken regardless (ADR-0016).
|
|
45
|
+
//
|
|
46
|
+
// `esm-env` resolves to a literal in any Vite build and to a runtime check in
|
|
47
|
+
// Node, so it is both tree-shakeable and loadable everywhere — which is what
|
|
48
|
+
// conventions § 3 was always trying to get.
|
|
49
|
+
const skipInbox = DEV && config.dev.skipInbox && !process.env.EMAIL_MAGIC_LINKS;
|
|
50
|
+
if (!skipInbox && !config.postmark.token) {
|
|
51
|
+
throw new Error('gfx-better-auth: no Postmark token, and the development inbox is not in use, ' +
|
|
52
|
+
'so no sign-in email could ever be sent. Set `postmark.token` ' +
|
|
53
|
+
'(POSTMARK_API_KEY), or run in development for the dev flow. ' +
|
|
54
|
+
'Failing at boot rather than at somebody’s first login attempt.');
|
|
55
|
+
}
|
|
56
|
+
const auth = betterAuth({
|
|
57
|
+
appName: config.appName,
|
|
58
|
+
baseURL: config.baseURL,
|
|
59
|
+
secret: config.secret,
|
|
60
|
+
database: drizzleAdapter(db, { provider: 'pg' }),
|
|
61
|
+
// better-auth rate-limits /sign-in/magic-link at 5 per 60s per IP, on by
|
|
62
|
+
// default in production. Exposed so a test can turn it on, and a consumer
|
|
63
|
+
// can turn it off — not otherwise reconfigured.
|
|
64
|
+
...(config.rateLimit ? { rateLimit: config.rateLimit } : {}),
|
|
65
|
+
session: {
|
|
66
|
+
expiresIn: config.session.expiresIn,
|
|
67
|
+
updateAge: config.session.updateAge,
|
|
68
|
+
// 🚨 Off deliberately. A cached session is a session the `disabledAt`
|
|
69
|
+
// check cannot see — the kill switch has to be able to take effect on
|
|
70
|
+
// the next request, not at the end of a cache window (ADR-0004).
|
|
71
|
+
cookieCache: { enabled: false },
|
|
72
|
+
},
|
|
73
|
+
user: {
|
|
74
|
+
additionalFields: {
|
|
75
|
+
...additionalUserFields,
|
|
76
|
+
/**
|
|
77
|
+
* Declared so better-auth selects it, which is what lets the handle
|
|
78
|
+
* see it on `locals.user` and treat a disabled user as signed out.
|
|
79
|
+
* `input: false` keeps it off any client-writable surface — it would
|
|
80
|
+
* otherwise be a self-service un-disable.
|
|
81
|
+
*/
|
|
82
|
+
disabledAt: { type: 'date', required: false, input: false },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
plugins: [
|
|
86
|
+
magicLink({
|
|
87
|
+
expiresIn: config.magicLink.expiresIn,
|
|
88
|
+
// A leaked database backup should not contain usable login tokens.
|
|
89
|
+
// Costs nothing: the plaintext token is still handed to us below.
|
|
90
|
+
storeToken: 'hashed',
|
|
91
|
+
sendMagicLink: async ({ email, url, token }) => {
|
|
92
|
+
// 🚨 THE SAFELINKS INDIRECTION. better-auth's `url` points at the
|
|
93
|
+
// verify endpoint, which consumes the token on GET. Outlook
|
|
94
|
+
// pre-fetches links in email, so that URL must never reach an inbox
|
|
95
|
+
// — we point at a page instead, and the token is exchanged on
|
|
96
|
+
// submit (ADR-0002).
|
|
97
|
+
//
|
|
98
|
+
// better-auth 1.7 deprecated and neutered `allowedAttempts`: a token is consumed
|
|
99
|
+
// atomically on first verification, so this is the only available
|
|
100
|
+
// mitigation, not merely the tidiest.
|
|
101
|
+
const confirm = new URL('/magic-link/confirm', config.baseURL);
|
|
102
|
+
confirm.searchParams.set('token', token);
|
|
103
|
+
const callbackURL = new URL(url).searchParams.get('callbackURL');
|
|
104
|
+
if (callbackURL)
|
|
105
|
+
confirm.searchParams.set('callbackURL', callbackURL);
|
|
106
|
+
if (skipInbox) {
|
|
107
|
+
stashDevMagicLink(email, confirm.toString());
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await send({
|
|
111
|
+
to: email,
|
|
112
|
+
url: confirm.toString(),
|
|
113
|
+
appName: config.appName,
|
|
114
|
+
expiresIn: config.magicLink.expiresIn,
|
|
115
|
+
postmark: config.postmark,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
],
|
|
120
|
+
hooks: {
|
|
121
|
+
/**
|
|
122
|
+
* 🚨 **The entire access-control model, and it is here on purpose.**
|
|
123
|
+
*
|
|
124
|
+
* A check in the sign-in form is bypassed by posting to the endpoint
|
|
125
|
+
* directly, so the form is not a trust boundary. This is, and it is not
|
|
126
|
+
* configurable (ADR-0004).
|
|
127
|
+
*/
|
|
128
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
129
|
+
if (ctx.path !== SIGN_IN_PATH)
|
|
130
|
+
return;
|
|
131
|
+
const body = (ctx.body ?? {});
|
|
132
|
+
const raw = typeof body.email === 'string' ? body.email : '';
|
|
133
|
+
const email = normaliseEmail(raw);
|
|
134
|
+
if (config.dev.enforceDomainGate && !isAllowedEmail(email)) {
|
|
135
|
+
throw new APIError('FORBIDDEN', {
|
|
136
|
+
message: 'Only Thomson Reuters email addresses can sign in to this app.',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Rewrite the body to the normalised address so one person is one
|
|
140
|
+
// user. Without this, `Jane.Doe+x@…` and `jane.doe@…` become two rows
|
|
141
|
+
// with two sets of saved work — and the gate is the only place that
|
|
142
|
+
// sees every sign-in, including direct POSTs.
|
|
143
|
+
return { context: { ...ctx, body: { ...body, email } } };
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
// Narrowed deliberately, and the cast is the point rather than a shortcut:
|
|
148
|
+
// better-auth's inferred instance type cannot be named portably, so exposing
|
|
149
|
+
// it is what stops `dist/server/auth.d.ts` being generated at all. See
|
|
150
|
+
// {@link GfxAuth}.
|
|
151
|
+
return auth;
|
|
152
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Validated at construction so a missing secret fails at boot with a clear
|
|
4
|
+
* message, rather than at somebody's first login attempt.
|
|
5
|
+
*/
|
|
6
|
+
export declare const gfxAuthConfig: z.ZodObject<{
|
|
7
|
+
appName: z.ZodString;
|
|
8
|
+
baseURL: z.ZodString;
|
|
9
|
+
secret: z.ZodString;
|
|
10
|
+
postmark: z.ZodObject<{
|
|
11
|
+
token: z.ZodOptional<z.ZodString>;
|
|
12
|
+
from: z.ZodString;
|
|
13
|
+
messageStream: z.ZodString;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
session: z.ZodPrefault<z.ZodObject<{
|
|
16
|
+
expiresIn: z.ZodDefault<z.ZodNumber>;
|
|
17
|
+
updateAge: z.ZodDefault<z.ZodNumber>;
|
|
18
|
+
}, z.core.$strip>>;
|
|
19
|
+
magicLink: z.ZodPrefault<z.ZodObject<{
|
|
20
|
+
expiresIn: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
rateLimit: z.ZodOptional<z.ZodObject<{
|
|
23
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
24
|
+
}, z.core.$strip>>;
|
|
25
|
+
dev: z.ZodPrefault<z.ZodObject<{
|
|
26
|
+
skipInbox: z.ZodDefault<z.ZodBoolean>;
|
|
27
|
+
enforceDomainGate: z.ZodDefault<z.ZodBoolean>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
/**
|
|
31
|
+
* Validated config. `db` and `additionalUserFields` are passed alongside it
|
|
32
|
+
* rather than through Zod — the first is a Drizzle instance and the second is
|
|
33
|
+
* better-auth's own field descriptor shape, neither of which is worth
|
|
34
|
+
* re-describing here.
|
|
35
|
+
*/
|
|
36
|
+
export type GfxAuthConfig = z.input<typeof gfxAuthConfig>;
|
|
37
|
+
/** What the package hands a sender. The transport is the sender's business. */
|
|
38
|
+
export interface SignInEmail {
|
|
39
|
+
to: string;
|
|
40
|
+
/** The **confirm** page URL, already carrying the token. Never the verify endpoint. */
|
|
41
|
+
url: string;
|
|
42
|
+
appName: string;
|
|
43
|
+
/** Link lifetime in seconds, so the copy can say so without hardcoding it. */
|
|
44
|
+
expiresIn: number;
|
|
45
|
+
postmark: {
|
|
46
|
+
token?: string | undefined;
|
|
47
|
+
from: string;
|
|
48
|
+
messageStream: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export interface GfxAuthOptions extends GfxAuthConfig {
|
|
52
|
+
/**
|
|
53
|
+
* Your Drizzle instance. The `user` row lives in **your** database, so your
|
|
54
|
+
* data can foreign-key to it with a real cascade (ADR-0001).
|
|
55
|
+
*/
|
|
56
|
+
db: any;
|
|
57
|
+
/**
|
|
58
|
+
* Override how the sign-in email is sent. Defaults to the package's
|
|
59
|
+
* Postmark sender.
|
|
60
|
+
*
|
|
61
|
+
* Exists mainly as a test seam — **no test may send real email or contact a
|
|
62
|
+
* real Postmark account**, and injecting a fake here is cleaner than
|
|
63
|
+
* mocking a module. A consumer with its own transport can also use it.
|
|
64
|
+
*/
|
|
65
|
+
sendEmail?: (message: SignInEmail) => Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Extra columns you added to `user`, declared so better-auth selects them
|
|
68
|
+
* (ADR-0006 — roles are yours, not ours):
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* export const user = pgTable('user', { ...userColumns, role: text('role') });
|
|
72
|
+
*
|
|
73
|
+
* createGfxAuth({ …, additionalUserFields: {
|
|
74
|
+
* role: { type: 'string', required: false, input: false },
|
|
75
|
+
* } });
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ `disabledAt` is declared for you and must not be redeclared here.
|
|
79
|
+
*/
|
|
80
|
+
additionalUserFields?: Record<string, unknown>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Validated at construction so a missing secret fails at boot with a clear
|
|
4
|
+
* message, rather than at somebody's first login attempt.
|
|
5
|
+
*/
|
|
6
|
+
export const gfxAuthConfig = z.object({
|
|
7
|
+
/** Shown in the sign-in email and page, e.g. "lightbox". */
|
|
8
|
+
appName: z.string().min(1),
|
|
9
|
+
baseURL: z.string().url(),
|
|
10
|
+
secret: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(32, 'BETTER_AUTH_SECRET must be at least 32 characters'),
|
|
13
|
+
postmark: z.object({
|
|
14
|
+
token: z.string().optional(),
|
|
15
|
+
from: z.string().email(),
|
|
16
|
+
messageStream: z.string().min(1),
|
|
17
|
+
}),
|
|
18
|
+
/**
|
|
19
|
+
* Session lifetime in seconds.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **`expiresIn` is an _idle_ timeout, not an absolute cap.** better-auth
|
|
22
|
+
* refreshes a session on activity and sets `expiresAt = now + expiresIn`,
|
|
23
|
+
* so it is measured from last use, not from sign-in. An earlier version of
|
|
24
|
+
* this comment said otherwise and was wrong (ADR-0011).
|
|
25
|
+
*
|
|
26
|
+
* `updateAge` is not a safety control — it is how stale a session may get
|
|
27
|
+
* before better-auth bothers to rewrite the row.
|
|
28
|
+
*
|
|
29
|
+
* 🚨 **See `USAGE.md` for what this means for access control**, and for why
|
|
30
|
+
* `user.disabledAt` matters. Defaults follow decision-desk: 12h / 1h.
|
|
31
|
+
*/
|
|
32
|
+
session: z
|
|
33
|
+
.object({
|
|
34
|
+
expiresIn: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.positive()
|
|
38
|
+
.default(60 * 60 * 12),
|
|
39
|
+
updateAge: z
|
|
40
|
+
.number()
|
|
41
|
+
.int()
|
|
42
|
+
.positive()
|
|
43
|
+
.default(60 * 60),
|
|
44
|
+
})
|
|
45
|
+
// `prefault`, not `default`: Zod 4's `.default()` takes the OUTPUT type, so
|
|
46
|
+
// `{}` is rejected even though every field has its own default. `prefault`
|
|
47
|
+
// supplies the INPUT, letting the inner defaults fill it in.
|
|
48
|
+
.prefault({}),
|
|
49
|
+
magicLink: z
|
|
50
|
+
.object({
|
|
51
|
+
/**
|
|
52
|
+
* How long a link is valid, in seconds. Default 10 minutes.
|
|
53
|
+
*
|
|
54
|
+
* better-auth's own default is 5, which is tight for corporate mail —
|
|
55
|
+
* a message can sit in a scanner queue for a minute or two before it is
|
|
56
|
+
* even delivered. `rngs.io` has run 10 minutes in production for years.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ A token is single-use and consumed atomically on first
|
|
59
|
+
* verification, so a longer window widens the period in which an
|
|
60
|
+
* *unused* link in an inbox is live. It does not allow replay.
|
|
61
|
+
*/
|
|
62
|
+
expiresIn: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.positive()
|
|
66
|
+
.default(60 * 10),
|
|
67
|
+
})
|
|
68
|
+
.prefault({}),
|
|
69
|
+
/**
|
|
70
|
+
* Passed through to better-auth, which rate-limits `/sign-in/magic-link` and
|
|
71
|
+
* `/magic-link/verify` at **5 requests per 60s per IP** and enables that
|
|
72
|
+
* automatically in production. Without it, requesting a link is a way to
|
|
73
|
+
* mail-bomb a colleague.
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ **The counters live in memory by default**, so on serverless each
|
|
76
|
+
* instance counts separately and the real limit is 5 × however many lambdas
|
|
77
|
+
* are warm. Better than nothing, and not a hard bound. Pass
|
|
78
|
+
* `{ enabled: true }` to force it on outside production.
|
|
79
|
+
*/
|
|
80
|
+
rateLimit: z.object({ enabled: z.boolean().optional() }).optional(),
|
|
81
|
+
dev: z
|
|
82
|
+
.object({
|
|
83
|
+
/**
|
|
84
|
+
* Skip Postmark, stash the confirm URL, and render it as a link on
|
|
85
|
+
* `/magic-link/sent` — with a **real, single-use token**. The flow
|
|
86
|
+
* production runs, minus the inbox (ADR-0003, ADR-0007).
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ **Do not "simplify" this into a redirect to the verify endpoint.**
|
|
89
|
+
* That skips `/magic-link/confirm`, which is the only reason this
|
|
90
|
+
* package owns a route at all, so the SafeLinks workaround would never
|
|
91
|
+
* run locally. ADR-0003 originally said to do exactly that, copying
|
|
92
|
+
* rngs.io; ADR-0007 is the correction.
|
|
93
|
+
*
|
|
94
|
+
* Set `EMAIL_MAGIC_LINKS=1` to opt back into real email.
|
|
95
|
+
*/
|
|
96
|
+
skipInbox: z.boolean().default(true),
|
|
97
|
+
/**
|
|
98
|
+
* Keep the domain gate on in development. Defaults to `true`, unlike
|
|
99
|
+
* rngs.io — a gate that only runs in production is a gate nobody has
|
|
100
|
+
* tested (ADR-0004).
|
|
101
|
+
*/
|
|
102
|
+
enforceDomainGate: z.boolean().default(true),
|
|
103
|
+
})
|
|
104
|
+
.prefault({}),
|
|
105
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed on the **normalised** address. Getting this wrong is a confusing bug
|
|
3
|
+
* rather than a loud one: typing `Jane.Doe+x@…` would stash under one key and
|
|
4
|
+
* the sent page would look under another, showing an empty page instead of an
|
|
5
|
+
* error.
|
|
6
|
+
*/
|
|
7
|
+
export declare const stashDevMagicLink: (email: string, url: string) => void;
|
|
8
|
+
/**
|
|
9
|
+
* One-time-use, and expiring. Reading it removes it, so a link left in a
|
|
10
|
+
* long-open tab cannot be reused — the stash should be no weaker than the
|
|
11
|
+
* token it holds.
|
|
12
|
+
*/
|
|
13
|
+
export declare const consumeDevMagicLink: (email: string) => string | null;
|
|
14
|
+
/** Test seam. Never called by the package itself. */
|
|
15
|
+
export declare const clearDevMagicLinks: () => void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { normaliseEmail } from '../domains.js';
|
|
2
|
+
/**
|
|
3
|
+
* The development inbox: a link stashed in memory instead of emailed.
|
|
4
|
+
*
|
|
5
|
+
* `sendMagicLink` puts the **confirm** URL here and `/magic-link/sent` renders
|
|
6
|
+
* it as a link, so a local sign-in goes through `/magic-link/confirm` exactly
|
|
7
|
+
* as a click from a real inbox would (ADR-0007).
|
|
8
|
+
*
|
|
9
|
+
* 🚨 **Not a bypass.** The token is real, single-use and verified by the code
|
|
10
|
+
* path production runs. Only Postmark is skipped.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **Do not replace this with a redirect to the verify endpoint.** That is
|
|
13
|
+
* what ADR-0003 originally said, copying `rngs.io`, and it skips the confirm
|
|
14
|
+
* page — so the SafeLinks workaround would never run locally, which is the one
|
|
15
|
+
* thing this whole design exists for.
|
|
16
|
+
*
|
|
17
|
+
* Process-local and deliberately so: it does not survive a restart and does
|
|
18
|
+
* not work across instances, which is correct for something that must never be
|
|
19
|
+
* reachable in production.
|
|
20
|
+
*/
|
|
21
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
22
|
+
const stash = new Map();
|
|
23
|
+
/**
|
|
24
|
+
* Keyed on the **normalised** address. Getting this wrong is a confusing bug
|
|
25
|
+
* rather than a loud one: typing `Jane.Doe+x@…` would stash under one key and
|
|
26
|
+
* the sent page would look under another, showing an empty page instead of an
|
|
27
|
+
* error.
|
|
28
|
+
*/
|
|
29
|
+
export const stashDevMagicLink = (email, url) => {
|
|
30
|
+
stash.set(normaliseEmail(email), { url, expiresAt: Date.now() + TTL_MS });
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* One-time-use, and expiring. Reading it removes it, so a link left in a
|
|
34
|
+
* long-open tab cannot be reused — the stash should be no weaker than the
|
|
35
|
+
* token it holds.
|
|
36
|
+
*/
|
|
37
|
+
export const consumeDevMagicLink = (email) => {
|
|
38
|
+
const key = normaliseEmail(email);
|
|
39
|
+
const entry = stash.get(key);
|
|
40
|
+
if (!entry)
|
|
41
|
+
return null;
|
|
42
|
+
stash.delete(key);
|
|
43
|
+
if (entry.expiresAt < Date.now())
|
|
44
|
+
return null;
|
|
45
|
+
return entry.url;
|
|
46
|
+
};
|
|
47
|
+
/** Test seam. Never called by the package itself. */
|
|
48
|
+
export const clearDevMagicLinks = () => stash.clear();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const escapeHtml: (value: unknown) => string;
|
|
2
|
+
/**
|
|
3
|
+
* Escape a value for use inside a URL query string.
|
|
4
|
+
*
|
|
5
|
+
* `encodeURIComponent` already removes everything dangerous to HTML, but the
|
|
6
|
+
* result is then interpolated into an attribute, so it is escaped too rather
|
|
7
|
+
* than relying on that being true.
|
|
8
|
+
*/
|
|
9
|
+
export declare const escapeUrlParam: (value: unknown) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML escaping for the built-in pages.
|
|
3
|
+
*
|
|
4
|
+
* 🚨 **This is load-bearing.** The pages are HTML template literals served
|
|
5
|
+
* straight from the handle (ADR-0008), so there is no framework escaping
|
|
6
|
+
* anything. Every interpolated value goes through here — the email address a
|
|
7
|
+
* visitor typed, an error message, `appName`, and above all **the token**.
|
|
8
|
+
*
|
|
9
|
+
* Escapes the five characters that matter in both element text and quoted
|
|
10
|
+
* attribute values, so one function is safe in either position. Attributes in
|
|
11
|
+
* the pages are always double-quoted.
|
|
12
|
+
*/
|
|
13
|
+
const REPLACEMENTS = {
|
|
14
|
+
'&': '&',
|
|
15
|
+
'<': '<',
|
|
16
|
+
'>': '>',
|
|
17
|
+
'"': '"',
|
|
18
|
+
"'": ''',
|
|
19
|
+
};
|
|
20
|
+
export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => REPLACEMENTS[c]);
|
|
21
|
+
/**
|
|
22
|
+
* Escape a value for use inside a URL query string.
|
|
23
|
+
*
|
|
24
|
+
* `encodeURIComponent` already removes everything dangerous to HTML, but the
|
|
25
|
+
* result is then interpolated into an attribute, so it is escaped too rather
|
|
26
|
+
* than relying on that being true.
|
|
27
|
+
*/
|
|
28
|
+
export const escapeUrlParam = (value) => escapeHtml(encodeURIComponent(String(value ?? '')));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { GfxAuthUser } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Throw a 401 unless there is a signed-in, non-disabled user.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **The "non-disabled" half is not enforced here.** This only reads
|
|
7
|
+
* `event.locals.user`; it is {@link gfxAuth} that treats a user with
|
|
8
|
+
* `disabledAt` set as signed out, so the guarantee holds *because* the handle
|
|
9
|
+
* ran first (ADR-0004). Called from an app whose `hooks.server.ts` does not
|
|
10
|
+
* include `gfxAuth`, this would happily return a disabled user — which is
|
|
11
|
+
* another reason hook order is documented as strictly as it is.
|
|
12
|
+
*/
|
|
13
|
+
export declare const requireUser: (event: RequestEvent) => GfxAuthUser;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* Throw a 401 unless there is a signed-in, non-disabled user.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **The "non-disabled" half is not enforced here.** This only reads
|
|
6
|
+
* `event.locals.user`; it is {@link gfxAuth} that treats a user with
|
|
7
|
+
* `disabledAt` set as signed out, so the guarantee holds *because* the handle
|
|
8
|
+
* ran first (ADR-0004). Called from an app whose `hooks.server.ts` does not
|
|
9
|
+
* include `gfxAuth`, this would happily return a disabled user — which is
|
|
10
|
+
* another reason hook order is documented as strictly as it is.
|
|
11
|
+
*/
|
|
12
|
+
export const requireUser = (event) => {
|
|
13
|
+
const user = event.locals.user;
|
|
14
|
+
if (!user)
|
|
15
|
+
error(401, 'Unauthorized');
|
|
16
|
+
return user;
|
|
17
|
+
};
|