@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,62 @@
|
|
|
1
|
+
import { type Handle } from '@sveltejs/kit';
|
|
2
|
+
import type { GfxAuth } from './auth.js';
|
|
3
|
+
export declare const SIGN_IN_ROUTE = "/magic-link";
|
|
4
|
+
export declare const SENT_ROUTE = "/magic-link/sent";
|
|
5
|
+
export declare const CONFIRM_ROUTE = "/magic-link/confirm";
|
|
6
|
+
export declare const SIGN_OUT_ROUTE = "/sign-out";
|
|
7
|
+
export interface GfxAuthHandleOptions {
|
|
8
|
+
auth: GfxAuth;
|
|
9
|
+
/**
|
|
10
|
+
* Paths that do not require a session. Supports a trailing `*`, e.g.
|
|
11
|
+
* `['/embed/*', '/about']`. `/api/auth/*` and the sign-in pages are always
|
|
12
|
+
* public and need not be listed.
|
|
13
|
+
*/
|
|
14
|
+
publicPaths?: string[];
|
|
15
|
+
/**
|
|
16
|
+
* Pass `building` from `$app/environment` if your app prerenders anything.
|
|
17
|
+
*
|
|
18
|
+
* During prerendering the handle would otherwise try a session lookup, and
|
|
19
|
+
* there is no database at build time. It is an option rather than an import
|
|
20
|
+
* because `building` is SvelteKit-specific and importing `$app/environment`
|
|
21
|
+
* here would make this whole entry unloadable outside Vite (ADR-0016) —
|
|
22
|
+
* whereas your `hooks.server.ts` is always inside SvelteKit, so it can
|
|
23
|
+
* supply it in one line.
|
|
24
|
+
*
|
|
25
|
+
* Defaults to `false`, which is correct for an app that prerenders nothing.
|
|
26
|
+
*/
|
|
27
|
+
building?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Opt out of a built-in page to supply your own route file. Usually
|
|
30
|
+
* unnecessary — defining a route at the path is enough, and the handle
|
|
31
|
+
* defers to it automatically.
|
|
32
|
+
*/
|
|
33
|
+
routes?: {
|
|
34
|
+
signIn?: boolean;
|
|
35
|
+
sent?: boolean;
|
|
36
|
+
confirm?: boolean;
|
|
37
|
+
signOut?: boolean;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 🚨 **Open-redirect guard.** `next` and `callbackURL` arrive from a query
|
|
42
|
+
* string, so they are attacker-controlled: `?next=https://evil.example` would
|
|
43
|
+
* otherwise hand a freshly signed-in user straight to an attacker's page,
|
|
44
|
+
* which is a textbook post-authentication open redirect.
|
|
45
|
+
*
|
|
46
|
+
* Only same-site absolute paths are allowed. `//evil.example` is rejected
|
|
47
|
+
* because browsers read it as protocol-relative, and backslashes because some
|
|
48
|
+
* agents normalise them to slashes.
|
|
49
|
+
*/
|
|
50
|
+
export declare const safeRedirectTarget: (value: unknown) => string | null;
|
|
51
|
+
/**
|
|
52
|
+
* One handle that mounts the whole auth surface.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* // src/hooks.server.ts
|
|
56
|
+
* export const handle = sequence(gfxAuth({ auth }), myHandle);
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ **It must come before anything that reads `event.locals.user`**, and a
|
|
60
|
+
* wrong order fails confusingly rather than loudly.
|
|
61
|
+
*/
|
|
62
|
+
export declare const gfxAuth: (options: GfxAuthHandleOptions) => Handle;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { redirect } from '@sveltejs/kit';
|
|
2
|
+
import { svelteKitHandler, isAuthPath } from 'better-auth/svelte-kit';
|
|
3
|
+
// 🚨 `esm-env`, not `$app/environment`. Both give a compile-time constant that
|
|
4
|
+
// tree-shakes dev-only code out of a production build, but `$app/*` only
|
|
5
|
+
// resolves inside a Vite build — so importing it here made the whole `/server`
|
|
6
|
+
// entry unloadable from plain Node, breaking better-auth's CLI, migration
|
|
7
|
+
// scripts, `gfx-auth doctor`, and `signInAs` in a Playwright `globalSetup`
|
|
8
|
+
// (ADR-0016). `svelte-package` warns about this on every build.
|
|
9
|
+
import { DEV } from 'esm-env';
|
|
10
|
+
import { consumeDevMagicLink } from './dev.js';
|
|
11
|
+
import { confirmPage, html, sentPage, signInPage, signOutPage, } from './pages.js';
|
|
12
|
+
export const SIGN_IN_ROUTE = '/magic-link';
|
|
13
|
+
export const SENT_ROUTE = '/magic-link/sent';
|
|
14
|
+
export const CONFIRM_ROUTE = '/magic-link/confirm';
|
|
15
|
+
export const SIGN_OUT_ROUTE = '/sign-out';
|
|
16
|
+
/**
|
|
17
|
+
* Matches a path against a `publicPaths` entry.
|
|
18
|
+
*
|
|
19
|
+
* The `+ '/'` boundary is deliberate: without it `/login` would also match
|
|
20
|
+
* `/loginfoo`, which is how a public-path list quietly becomes a hole.
|
|
21
|
+
*/
|
|
22
|
+
const matches = (pathname, pattern) => {
|
|
23
|
+
if (pattern.endsWith('/*')) {
|
|
24
|
+
const base = pattern.slice(0, -2);
|
|
25
|
+
return pathname === base || pathname.startsWith(base + '/');
|
|
26
|
+
}
|
|
27
|
+
return pathname === pattern || pathname === pattern + '/';
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* 🚨 **Open-redirect guard.** `next` and `callbackURL` arrive from a query
|
|
31
|
+
* string, so they are attacker-controlled: `?next=https://evil.example` would
|
|
32
|
+
* otherwise hand a freshly signed-in user straight to an attacker's page,
|
|
33
|
+
* which is a textbook post-authentication open redirect.
|
|
34
|
+
*
|
|
35
|
+
* Only same-site absolute paths are allowed. `//evil.example` is rejected
|
|
36
|
+
* because browsers read it as protocol-relative, and backslashes because some
|
|
37
|
+
* agents normalise them to slashes.
|
|
38
|
+
*/
|
|
39
|
+
export const safeRedirectTarget = (value) => {
|
|
40
|
+
if (typeof value !== 'string' || value === '')
|
|
41
|
+
return null;
|
|
42
|
+
if (!value.startsWith('/'))
|
|
43
|
+
return null;
|
|
44
|
+
if (value.startsWith('//') || value.startsWith('/\\'))
|
|
45
|
+
return null;
|
|
46
|
+
if (value.includes('\\'))
|
|
47
|
+
return null;
|
|
48
|
+
return value;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* One handle that mounts the whole auth surface.
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* // src/hooks.server.ts
|
|
55
|
+
* export const handle = sequence(gfxAuth({ auth }), myHandle);
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ **It must come before anything that reads `event.locals.user`**, and a
|
|
59
|
+
* wrong order fails confusingly rather than loudly.
|
|
60
|
+
*/
|
|
61
|
+
export const gfxAuth = (options) => {
|
|
62
|
+
const { auth, publicPaths = [], routes = {}, building = false } = options;
|
|
63
|
+
const enabled = {
|
|
64
|
+
[SIGN_IN_ROUTE]: routes.signIn !== false,
|
|
65
|
+
[SENT_ROUTE]: routes.sent !== false,
|
|
66
|
+
[CONFIRM_ROUTE]: routes.confirm !== false,
|
|
67
|
+
[SIGN_OUT_ROUTE]: routes.signOut !== false,
|
|
68
|
+
};
|
|
69
|
+
const appName = auth.options.appName ?? 'this app';
|
|
70
|
+
return async ({ event, resolve }) => {
|
|
71
|
+
const { pathname } = event.url;
|
|
72
|
+
// 1. better-auth's own endpoints. Handled and returned before anything
|
|
73
|
+
// else — they must not be guarded, and they do not need locals.
|
|
74
|
+
if (isAuthPath(event.request.url, auth.options)) {
|
|
75
|
+
return svelteKitHandler({ auth, event, resolve, building });
|
|
76
|
+
}
|
|
77
|
+
// Nothing below may touch the database while prerendering.
|
|
78
|
+
if (building)
|
|
79
|
+
return resolve(event);
|
|
80
|
+
// 2. Populate locals.
|
|
81
|
+
const result = await auth.api.getSession({
|
|
82
|
+
headers: event.request.headers,
|
|
83
|
+
});
|
|
84
|
+
const user = (result?.user ?? null);
|
|
85
|
+
// 🚨 The kill switch (ADR-0004). A disabled user is treated as signed
|
|
86
|
+
// out on **every request**, so revoking access does not wait for their
|
|
87
|
+
// session to expire. This is why `session.cookieCache` is off: a cached
|
|
88
|
+
// session is one this check never sees.
|
|
89
|
+
const disabled = !!user?.disabledAt;
|
|
90
|
+
// Cast because the package cannot see the consumer's merged `App.Locals`;
|
|
91
|
+
// `GfxAuthLocals` is what they are asked to extend it with.
|
|
92
|
+
const locals = event.locals;
|
|
93
|
+
locals.user = disabled ? null : user;
|
|
94
|
+
locals.session =
|
|
95
|
+
disabled ? null : (result?.session ?? null);
|
|
96
|
+
// 3. Our pages — but only where the app has not defined its own route.
|
|
97
|
+
// `event.route.id` is null exactly when no route matched, which is the
|
|
98
|
+
// whole deferral mechanism (ADR-0002).
|
|
99
|
+
//
|
|
100
|
+
// ⚠️ This is a "whose page renders" decision, never an authorization
|
|
101
|
+
// one — SvelteKit's own docs warn `route.id` is not for that, and the
|
|
102
|
+
// guard below deliberately uses `pathname` instead.
|
|
103
|
+
if (event.route.id === null && enabled[pathname]) {
|
|
104
|
+
const served = await servePage(event, { auth, appName });
|
|
105
|
+
if (served)
|
|
106
|
+
return served;
|
|
107
|
+
}
|
|
108
|
+
// 4. Guard.
|
|
109
|
+
const isPublic = pathname === SIGN_IN_ROUTE ||
|
|
110
|
+
pathname === SENT_ROUTE ||
|
|
111
|
+
pathname === CONFIRM_ROUTE ||
|
|
112
|
+
// Signing out must not require being signed in: otherwise a stale or
|
|
113
|
+
// half-broken session bounces to the sign-in page with no way out.
|
|
114
|
+
pathname === SIGN_OUT_ROUTE ||
|
|
115
|
+
publicPaths.some((p) => matches(pathname, p));
|
|
116
|
+
if (!locals.user && !isPublic) {
|
|
117
|
+
const next = pathname + event.url.search;
|
|
118
|
+
redirect(302, `${SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`);
|
|
119
|
+
}
|
|
120
|
+
return resolve(event);
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* The page half. Returns `null` if this request is not one of ours, so the
|
|
125
|
+
* caller falls through to `resolve`.
|
|
126
|
+
*/
|
|
127
|
+
const servePage = async (event, ctx) => {
|
|
128
|
+
const { auth, appName } = ctx;
|
|
129
|
+
const { pathname } = event.url;
|
|
130
|
+
const method = event.request.method;
|
|
131
|
+
if (method !== 'GET' && method !== 'POST')
|
|
132
|
+
return null;
|
|
133
|
+
// ── /magic-link ─────────────────────────────────────────────────────────
|
|
134
|
+
if (pathname === SIGN_IN_ROUTE) {
|
|
135
|
+
const next = safeRedirectTarget(event.url.searchParams.get('next'));
|
|
136
|
+
if (method === 'GET')
|
|
137
|
+
return html(signInPage({ appName, next }));
|
|
138
|
+
const form = await event.request.formData();
|
|
139
|
+
const email = String(form.get('email') ?? '');
|
|
140
|
+
const formNext = safeRedirectTarget(form.get('next')) ?? next;
|
|
141
|
+
try {
|
|
142
|
+
await auth.api.signInMagicLink({
|
|
143
|
+
body: { email, callbackURL: formNext ?? '/' },
|
|
144
|
+
// Required, not optional: the endpoint declares `requireHeaders` so it
|
|
145
|
+
// can see the origin, IP and user agent.
|
|
146
|
+
headers: event.request.headers,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// The gate refuses non-TR addresses here (ADR-0004). Show the message
|
|
151
|
+
// rather than a 403 page — the endpoint is the trust boundary, this is
|
|
152
|
+
// only how a human finds out.
|
|
153
|
+
const message = error?.body?.message ??
|
|
154
|
+
error?.message ??
|
|
155
|
+
'Something went wrong sending your link. Try again.';
|
|
156
|
+
return html(signInPage({ appName, next: formNext, error: message, email }), 400);
|
|
157
|
+
}
|
|
158
|
+
const sent = new URL(SENT_ROUTE, event.url.origin);
|
|
159
|
+
sent.searchParams.set('email', email);
|
|
160
|
+
if (formNext)
|
|
161
|
+
sent.searchParams.set('next', formNext);
|
|
162
|
+
return new Response(null, {
|
|
163
|
+
status: 303,
|
|
164
|
+
headers: { location: sent.pathname + sent.search },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// ── /magic-link/sent ────────────────────────────────────────────────────
|
|
168
|
+
if (pathname === SENT_ROUTE) {
|
|
169
|
+
if (method !== 'GET')
|
|
170
|
+
return null;
|
|
171
|
+
const email = event.url.searchParams.get('email');
|
|
172
|
+
if (!email)
|
|
173
|
+
redirect(302, SIGN_IN_ROUTE);
|
|
174
|
+
// `DEV` is a compile-time constant in any Vite build, so this whole
|
|
175
|
+
// branch — and the import it reaches — is tree-shaken out of production.
|
|
176
|
+
// The guarantee is the build, not the condition (ADR-0003), and it is
|
|
177
|
+
// verified against a real consumer build by `pnpm run verify:install`.
|
|
178
|
+
const devUrl = DEV ? consumeDevMagicLink(email) : null;
|
|
179
|
+
return html(sentPage({ appName, email, devUrl }));
|
|
180
|
+
}
|
|
181
|
+
// ── /sign-out ───────────────────────────────────────────────────────────
|
|
182
|
+
if (pathname === SIGN_OUT_ROUTE) {
|
|
183
|
+
// 🚨 GET only ever renders a form. Signing out on GET would let a
|
|
184
|
+
// link-prefetcher or a cross-site `<img src="/sign-out">` do it — the same
|
|
185
|
+
// shape of bug as the SafeLinks one this package exists for.
|
|
186
|
+
if (method === 'GET')
|
|
187
|
+
return html(signOutPage({ appName }));
|
|
188
|
+
// Delegate to better-auth rather than clearing the cookie ourselves, so
|
|
189
|
+
// the session row is deleted too and the cookie attributes match the ones
|
|
190
|
+
// that set it.
|
|
191
|
+
//
|
|
192
|
+
// ⚠️ A plain HTML form cannot call it directly: the endpoint accepts only
|
|
193
|
+
// `application/json` and answers a form post with 415. That is the whole
|
|
194
|
+
// reason this route exists — without it, a package whose sign-in needs no
|
|
195
|
+
// JavaScript would need JavaScript to sign out.
|
|
196
|
+
const headers = new Headers({ 'content-type': 'application/json' });
|
|
197
|
+
const cookie = event.request.headers.get('cookie');
|
|
198
|
+
if (cookie)
|
|
199
|
+
headers.set('cookie', cookie);
|
|
200
|
+
// Forwarded so better-auth can still do its own origin check on this.
|
|
201
|
+
const origin = event.request.headers.get('origin');
|
|
202
|
+
if (origin)
|
|
203
|
+
headers.set('origin', origin);
|
|
204
|
+
const result = await auth.handler(new Request(new URL('/api/auth/sign-out', event.url.origin), {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers,
|
|
207
|
+
body: '{}',
|
|
208
|
+
}));
|
|
209
|
+
const out = new Headers({ location: SIGN_IN_ROUTE });
|
|
210
|
+
// `getSetCookie` keeps them separate; a plain `get` would join multiple
|
|
211
|
+
// cookies with a comma and produce one malformed header.
|
|
212
|
+
for (const c of result.headers.getSetCookie())
|
|
213
|
+
out.append('set-cookie', c);
|
|
214
|
+
return new Response(null, { status: 303, headers: out });
|
|
215
|
+
}
|
|
216
|
+
// ── /magic-link/confirm ─────────────────────────────────────────────────
|
|
217
|
+
if (pathname === CONFIRM_ROUTE) {
|
|
218
|
+
if (method === 'GET') {
|
|
219
|
+
const token = event.url.searchParams.get('token');
|
|
220
|
+
if (!token)
|
|
221
|
+
redirect(302, SIGN_IN_ROUTE);
|
|
222
|
+
// 🚨 The token is rendered, never used. A SafeLinks scanner reaches
|
|
223
|
+
// exactly this far and no further — which is the entire point of the
|
|
224
|
+
// page existing (ADR-0002).
|
|
225
|
+
return html(confirmPage({
|
|
226
|
+
appName,
|
|
227
|
+
token,
|
|
228
|
+
callbackURL: safeRedirectTarget(event.url.searchParams.get('callbackURL')),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
const form = await event.request.formData();
|
|
232
|
+
const token = String(form.get('token') ?? '');
|
|
233
|
+
if (!token)
|
|
234
|
+
return html(confirmPage({
|
|
235
|
+
appName,
|
|
236
|
+
token: '',
|
|
237
|
+
error: 'That sign-in link was incomplete. Request a new one.',
|
|
238
|
+
}), 400);
|
|
239
|
+
// Hand the browser to the verify endpoint. It is a redirect rather than a
|
|
240
|
+
// server-side `auth.api` call on purpose: the exchange then happens on a
|
|
241
|
+
// real request that better-auth is handling, so `Set-Cookie` lands
|
|
242
|
+
// without needing the `sveltekitCookies` plugin.
|
|
243
|
+
const verify = new URL('/api/auth/magic-link/verify', event.url.origin);
|
|
244
|
+
verify.searchParams.set('token', token);
|
|
245
|
+
verify.searchParams.set('callbackURL', safeRedirectTarget(form.get('callbackURL')) ?? '/');
|
|
246
|
+
return new Response(null, {
|
|
247
|
+
status: 303,
|
|
248
|
+
headers: { location: verify.pathname + verify.search },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server entry — everything that touches the database, Postmark or a session.
|
|
3
|
+
*
|
|
4
|
+
* 🚨 Never import this from a component. The entry-point split is a safety
|
|
5
|
+
* boundary, not tidiness: this is what stops something reaching for a secret
|
|
6
|
+
* ending up in a client bundle. `/client` is the browser-safe half.
|
|
7
|
+
*/
|
|
8
|
+
export { createGfxAuth, type GfxAuth } from './auth.js';
|
|
9
|
+
export { gfxAuth, SIGN_IN_ROUTE, SENT_ROUTE, CONFIRM_ROUTE, SIGN_OUT_ROUTE, type GfxAuthHandleOptions, } from './handle.js';
|
|
10
|
+
export { requireUser } from './guard.js';
|
|
11
|
+
export { signInAs } from './testing.js';
|
|
12
|
+
export type { GfxAuthConfig, GfxAuthOptions, SignInEmail } from './config.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server entry — everything that touches the database, Postmark or a session.
|
|
3
|
+
*
|
|
4
|
+
* 🚨 Never import this from a component. The entry-point split is a safety
|
|
5
|
+
* boundary, not tidiness: this is what stops something reaching for a secret
|
|
6
|
+
* ending up in a client bundle. `/client` is the browser-safe half.
|
|
7
|
+
*/
|
|
8
|
+
export { createGfxAuth } from './auth.js';
|
|
9
|
+
export { gfxAuth, SIGN_IN_ROUTE, SENT_ROUTE, CONFIRM_ROUTE, SIGN_OUT_ROUTE, } from './handle.js';
|
|
10
|
+
export { requireUser } from './guard.js';
|
|
11
|
+
export { signInAs } from './testing.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface SignInPageProps {
|
|
2
|
+
appName: string;
|
|
3
|
+
/** Path to return to after signing in. */
|
|
4
|
+
next?: string | null;
|
|
5
|
+
error?: string | null;
|
|
6
|
+
email?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export declare const signInPage: ({ appName, next, error, email, }: SignInPageProps) => string;
|
|
9
|
+
export interface SentPageProps {
|
|
10
|
+
appName: string;
|
|
11
|
+
email: string;
|
|
12
|
+
/**
|
|
13
|
+
* In development only: the stashed confirm URL, rendered as a link so the
|
|
14
|
+
* inbox can be skipped without skipping the flow (ADR-0007). Always `null`
|
|
15
|
+
* in production.
|
|
16
|
+
*/
|
|
17
|
+
devUrl?: string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare const sentPage: ({ appName, email, devUrl }: SentPageProps) => string;
|
|
20
|
+
export interface ConfirmPageProps {
|
|
21
|
+
appName: string;
|
|
22
|
+
token: string;
|
|
23
|
+
callbackURL?: string | null;
|
|
24
|
+
error?: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 🚨 **The reason this package owns a route at all.**
|
|
28
|
+
*
|
|
29
|
+
* Outlook SafeLinks pre-fetches URLs in email, and a magic-link token is
|
|
30
|
+
* consumed atomically on first verification — better-auth 1.7 deprecated
|
|
31
|
+
* `allowedAttempts` and now ignores it, so a scanner's GET
|
|
32
|
+
* would burn the token and the real click would fail.
|
|
33
|
+
*
|
|
34
|
+
* So a scanner must only ever reach **a page**. The token rides through the
|
|
35
|
+
* HTML in a hidden input and is exchanged on submit, which a pre-fetcher does
|
|
36
|
+
* not do. Nothing in this page's rendering may touch or verify the token.
|
|
37
|
+
*/
|
|
38
|
+
export declare const confirmPage: ({ appName, token, callbackURL, error, }: ConfirmPageProps) => string;
|
|
39
|
+
export interface SignOutPageProps {
|
|
40
|
+
appName: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* ⚠️ **Sign-out is a POST, and this page exists so it can be.**
|
|
44
|
+
*
|
|
45
|
+
* It would be easier to sign out on `GET /sign-out`, and that would be wrong
|
|
46
|
+
* twice over: a link-prefetcher would sign people out just by scanning a page
|
|
47
|
+
* — the same class of bug as the SafeLinks one this package was built around —
|
|
48
|
+
* and any cross-site `<img src="/sign-out">` would too.
|
|
49
|
+
*
|
|
50
|
+
* So the state change needs a POST, and a POST with no JavaScript needs a form.
|
|
51
|
+
*/
|
|
52
|
+
export declare const signOutPage: ({ appName }: SignOutPageProps) => string;
|
|
53
|
+
export declare const html: (body: string, status?: number) => Response;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { escapeHtml } from './escape.js';
|
|
2
|
+
/**
|
|
3
|
+
* The three built-in pages, as HTML strings (ADR-0008).
|
|
4
|
+
*
|
|
5
|
+
* A `Handle` returns a `Response`, so it cannot render a SvelteKit page and
|
|
6
|
+
* there are no form actions — the hook handles the POSTs itself. That makes
|
|
7
|
+
* these plain strings rather than components, and makes escaping our job:
|
|
8
|
+
* **every interpolation goes through `escapeHtml`.**
|
|
9
|
+
*
|
|
10
|
+
* The forms are plain HTML POSTs with no JavaScript, so sign-in works in a
|
|
11
|
+
* text browser and with scripting disabled. For the one flow that must never
|
|
12
|
+
* break, that is a feature rather than a limitation.
|
|
13
|
+
*
|
|
14
|
+
* These are a **fallback**, not a design system. An app that cares defines its
|
|
15
|
+
* own route file and the handle defers to it.
|
|
16
|
+
*/
|
|
17
|
+
const STYLES = `
|
|
18
|
+
:root { color-scheme: light dark; --fg: #1a1a1a; --muted: #5c6370;
|
|
19
|
+
--bg: #f4f5f7; --card: #ffffff; --line: #dfe2e8; --accent: #1a1a1a; }
|
|
20
|
+
@media (prefers-color-scheme: dark) {
|
|
21
|
+
:root { --fg: #f0f1f3; --muted: #9aa2af; --bg: #16181d; --card: #1f2228;
|
|
22
|
+
--line: #32363f; --accent: #f0f1f3; }
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
26
|
+
padding: 24px; background: var(--bg); color: var(--fg);
|
|
27
|
+
font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
28
|
+
main { width: 100%; max-width: 27rem; background: var(--card);
|
|
29
|
+
border: 1px solid var(--line); border-radius: 10px; padding: 32px; }
|
|
30
|
+
h1 { margin: 0 0 4px; font-size: 1.25rem; letter-spacing: -0.01em; }
|
|
31
|
+
p { margin: 0 0 20px; color: var(--muted); font-size: 0.9375rem; }
|
|
32
|
+
label { display: block; font-size: 0.8125rem; font-weight: 600; margin-bottom: 6px; }
|
|
33
|
+
input[type=email] { width: 100%; padding: 10px 12px; font-size: 1rem;
|
|
34
|
+
color: var(--fg); background: var(--bg); border: 1px solid var(--line);
|
|
35
|
+
border-radius: 6px; }
|
|
36
|
+
input[type=email]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
37
|
+
button { width: 100%; margin-top: 16px; padding: 11px 16px; font: inherit;
|
|
38
|
+
font-weight: 600; color: var(--card); background: var(--accent);
|
|
39
|
+
border: 0; border-radius: 6px; cursor: pointer; }
|
|
40
|
+
button:hover { opacity: 0.85; }
|
|
41
|
+
.note { margin: 20px 0 0; padding-top: 16px; border-top: 1px solid var(--line);
|
|
42
|
+
font-size: 0.8125rem; }
|
|
43
|
+
.err { margin: 0 0 20px; padding: 10px 12px; border-radius: 6px;
|
|
44
|
+
background: #fdeaea; color: #8c1c1c; font-size: 0.875rem; }
|
|
45
|
+
@media (prefers-color-scheme: dark) { .err { background: #3a1d1d; color: #f3b5b5; } }
|
|
46
|
+
a { color: inherit; }
|
|
47
|
+
`;
|
|
48
|
+
const shell = (title, appName, body) => `<!doctype html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8">
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
53
|
+
<meta name="robots" content="noindex, nofollow">
|
|
54
|
+
<title>${escapeHtml(title)} · ${escapeHtml(appName)}</title>
|
|
55
|
+
<style>${STYLES}</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<main>
|
|
59
|
+
${body}
|
|
60
|
+
</main>
|
|
61
|
+
</body>
|
|
62
|
+
</html>`;
|
|
63
|
+
export const signInPage = ({ appName, next, error, email, }) => shell('Sign in', appName, `<h1>Sign in to ${escapeHtml(appName)}</h1>
|
|
64
|
+
<p>We'll email you a link. No password needed.</p>
|
|
65
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}
|
|
66
|
+
<form method="post">
|
|
67
|
+
${next ? `<input type="hidden" name="next" value="${escapeHtml(next)}">` : ''}
|
|
68
|
+
<label for="email">Thomson Reuters email</label>
|
|
69
|
+
<input id="email" name="email" type="email" inputmode="email"
|
|
70
|
+
autocomplete="email" autocapitalize="off" spellcheck="false" required
|
|
71
|
+
placeholder="you@thomsonreuters.com"${email ? ` value="${escapeHtml(email)}"` : ''}>
|
|
72
|
+
<button type="submit">Email me a link</button>
|
|
73
|
+
</form>
|
|
74
|
+
<p class="note">Only Thomson Reuters staff can sign in.</p>`);
|
|
75
|
+
export const sentPage = ({ appName, email, devUrl }) => shell('Check your email', appName, `<h1>Check your email</h1>
|
|
76
|
+
<p>We sent a sign-in link to <strong>${escapeHtml(email)}</strong>. Open it in
|
|
77
|
+
this browser to continue.</p>
|
|
78
|
+
${devUrl ?
|
|
79
|
+
`<p class="err" style="background:#e8f0fe;color:#1a3d7c">
|
|
80
|
+
<strong>Development.</strong> No email was sent.</p>
|
|
81
|
+
<a href="${escapeHtml(devUrl)}"><button type="button">Open the sign-in link</button></a>
|
|
82
|
+
<p class="note">This is a real, single-use token and it goes through the same
|
|
83
|
+
confirm page a link from your inbox would.</p>`
|
|
84
|
+
: `<p class="note">The link expires shortly. If it doesn't arrive, check your
|
|
85
|
+
spam folder, then <a href=".">request another</a>.</p>`}`);
|
|
86
|
+
/**
|
|
87
|
+
* 🚨 **The reason this package owns a route at all.**
|
|
88
|
+
*
|
|
89
|
+
* Outlook SafeLinks pre-fetches URLs in email, and a magic-link token is
|
|
90
|
+
* consumed atomically on first verification — better-auth 1.7 deprecated
|
|
91
|
+
* `allowedAttempts` and now ignores it, so a scanner's GET
|
|
92
|
+
* would burn the token and the real click would fail.
|
|
93
|
+
*
|
|
94
|
+
* So a scanner must only ever reach **a page**. The token rides through the
|
|
95
|
+
* HTML in a hidden input and is exchanged on submit, which a pre-fetcher does
|
|
96
|
+
* not do. Nothing in this page's rendering may touch or verify the token.
|
|
97
|
+
*/
|
|
98
|
+
export const confirmPage = ({ appName, token, callbackURL, error, }) => shell('Confirm sign-in', appName, `<h1>Confirm sign-in</h1>
|
|
99
|
+
<p>One more click and you're in to ${escapeHtml(appName)}.</p>
|
|
100
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}
|
|
101
|
+
<form method="post">
|
|
102
|
+
<input type="hidden" name="token" value="${escapeHtml(token)}">
|
|
103
|
+
${callbackURL ? `<input type="hidden" name="callbackURL" value="${escapeHtml(callbackURL)}">` : ''}
|
|
104
|
+
<button type="submit">Sign in</button>
|
|
105
|
+
</form>
|
|
106
|
+
<p class="note">This extra step exists because corporate email scanners open
|
|
107
|
+
links before you do, which would otherwise use up your one-time code.</p>`);
|
|
108
|
+
/**
|
|
109
|
+
* ⚠️ **Sign-out is a POST, and this page exists so it can be.**
|
|
110
|
+
*
|
|
111
|
+
* It would be easier to sign out on `GET /sign-out`, and that would be wrong
|
|
112
|
+
* twice over: a link-prefetcher would sign people out just by scanning a page
|
|
113
|
+
* — the same class of bug as the SafeLinks one this package was built around —
|
|
114
|
+
* and any cross-site `<img src="/sign-out">` would too.
|
|
115
|
+
*
|
|
116
|
+
* So the state change needs a POST, and a POST with no JavaScript needs a form.
|
|
117
|
+
*/
|
|
118
|
+
export const signOutPage = ({ appName }) => shell('Sign out', appName, `<h1>Sign out of ${escapeHtml(appName)}?</h1>
|
|
119
|
+
<p>You'll need a new sign-in link to get back in.</p>
|
|
120
|
+
<form method="post">
|
|
121
|
+
<button type="submit">Sign out</button>
|
|
122
|
+
</form>`);
|
|
123
|
+
export const html = (body, status = 200) => new Response(body, {
|
|
124
|
+
status,
|
|
125
|
+
headers: {
|
|
126
|
+
'content-type': 'text/html; charset=utf-8',
|
|
127
|
+
// These pages carry a token in the markup. They must not be stored by a
|
|
128
|
+
// shared cache, and they have no business in a search index.
|
|
129
|
+
'cache-control': 'no-store, must-revalidate',
|
|
130
|
+
'referrer-policy': 'no-referrer',
|
|
131
|
+
'x-robots-tag': 'noindex, nofollow',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GfxAuth } from './auth.js';
|
|
2
|
+
/**
|
|
3
|
+
* Sign in as `email` and return the session cookie, for Playwright
|
|
4
|
+
* `globalSetup` and integration tests. No inbox, no browser.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const { cookie } = await signInAs(auth, 'jane.doe@thomsonreuters.com');
|
|
8
|
+
* await context.addCookies([parse(cookie)]);
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* 🚨 **Never expose this over HTTP.** It is guarded by `DEV` from `esm-env` —
|
|
12
|
+
* a compile-time constant in any Vite build, so the body is tree-shaken out of
|
|
13
|
+
* production — with a runtime assertion behind that.
|
|
14
|
+
*
|
|
15
|
+
* `esm-env` rather than `$app/environment` because **this has to work in plain
|
|
16
|
+
* Node**: a Playwright `globalSetup` runs outside Vite, and with the old import
|
|
17
|
+
* it could not even be loaded there — which is the one place it is documented
|
|
18
|
+
* for (ADR-0016).
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ It drives the **real** flow rather than minting a session directly:
|
|
21
|
+
* request a link, read it from the development stash, exchange the token. So
|
|
22
|
+
* it exercises the same code path production runs and needs no better-auth
|
|
23
|
+
* internals, which also means it depends on `dev.skipInbox` being on. If you
|
|
24
|
+
* set `EMAIL_MAGIC_LINKS=1` or `dev.skipInbox: false`, this throws rather than
|
|
25
|
+
* silently signing nobody in.
|
|
26
|
+
*
|
|
27
|
+
* The domain gate still applies, so `email` must be a Thomson Reuters address.
|
|
28
|
+
*/
|
|
29
|
+
export declare const signInAs: (auth: GfxAuth, email: string) => Promise<{
|
|
30
|
+
cookie: string;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
import { consumeDevMagicLink } from './dev.js';
|
|
3
|
+
/**
|
|
4
|
+
* Sign in as `email` and return the session cookie, for Playwright
|
|
5
|
+
* `globalSetup` and integration tests. No inbox, no browser.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const { cookie } = await signInAs(auth, 'jane.doe@thomsonreuters.com');
|
|
9
|
+
* await context.addCookies([parse(cookie)]);
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* 🚨 **Never expose this over HTTP.** It is guarded by `DEV` from `esm-env` —
|
|
13
|
+
* a compile-time constant in any Vite build, so the body is tree-shaken out of
|
|
14
|
+
* production — with a runtime assertion behind that.
|
|
15
|
+
*
|
|
16
|
+
* `esm-env` rather than `$app/environment` because **this has to work in plain
|
|
17
|
+
* Node**: a Playwright `globalSetup` runs outside Vite, and with the old import
|
|
18
|
+
* it could not even be loaded there — which is the one place it is documented
|
|
19
|
+
* for (ADR-0016).
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ It drives the **real** flow rather than minting a session directly:
|
|
22
|
+
* request a link, read it from the development stash, exchange the token. So
|
|
23
|
+
* it exercises the same code path production runs and needs no better-auth
|
|
24
|
+
* internals, which also means it depends on `dev.skipInbox` being on. If you
|
|
25
|
+
* set `EMAIL_MAGIC_LINKS=1` or `dev.skipInbox: false`, this throws rather than
|
|
26
|
+
* silently signing nobody in.
|
|
27
|
+
*
|
|
28
|
+
* The domain gate still applies, so `email` must be a Thomson Reuters address.
|
|
29
|
+
*/
|
|
30
|
+
export const signInAs = async (auth, email) => {
|
|
31
|
+
if (!DEV)
|
|
32
|
+
throw new Error('signInAs() is development-only and must never be reachable in production.');
|
|
33
|
+
const origin = auth.options.baseURL ?? 'http://localhost';
|
|
34
|
+
await auth.api.signInMagicLink({
|
|
35
|
+
body: { email, callbackURL: '/' },
|
|
36
|
+
headers: new Headers({ origin, 'content-type': 'application/json' }),
|
|
37
|
+
});
|
|
38
|
+
const stashed = consumeDevMagicLink(email);
|
|
39
|
+
if (!stashed)
|
|
40
|
+
throw new Error(`signInAs(): no development link was stashed for ${email}. ` +
|
|
41
|
+
'This needs `dev.skipInbox` on and `EMAIL_MAGIC_LINKS` unset.');
|
|
42
|
+
const token = new URL(stashed).searchParams.get('token');
|
|
43
|
+
if (!token)
|
|
44
|
+
throw new Error('signInAs(): the stashed link carried no token.');
|
|
45
|
+
const verify = new URL('/api/auth/magic-link/verify', origin);
|
|
46
|
+
verify.searchParams.set('token', token);
|
|
47
|
+
verify.searchParams.set('callbackURL', '/');
|
|
48
|
+
const response = await auth.handler(new Request(verify, { headers: { origin }, redirect: 'manual' }));
|
|
49
|
+
const cookie = response.headers.get('set-cookie');
|
|
50
|
+
if (!cookie)
|
|
51
|
+
throw new Error(`signInAs(): verifying the token set no cookie (status ${response.status}).`);
|
|
52
|
+
return { cookie };
|
|
53
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge into your app's `App.Locals` so `event.locals.user` is typed:
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* // src/app.d.ts
|
|
6
|
+
* import type { GfxAuthLocals } from '@reuters-graphics/gfx-better-auth';
|
|
7
|
+
* declare global {
|
|
8
|
+
* namespace App {
|
|
9
|
+
* interface Locals extends GfxAuthLocals {}
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
* export {};
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export interface GfxAuthUser {
|
|
16
|
+
id: string;
|
|
17
|
+
email: string;
|
|
18
|
+
name: string;
|
|
19
|
+
emailVerified: boolean;
|
|
20
|
+
disabledAt: Date | null;
|
|
21
|
+
}
|
|
22
|
+
export interface GfxAuthSession {
|
|
23
|
+
id: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
expiresAt: Date;
|
|
26
|
+
}
|
|
27
|
+
export interface GfxAuthLocals {
|
|
28
|
+
user: GfxAuthUser | null;
|
|
29
|
+
session: GfxAuthSession | null;
|
|
30
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|