@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.
Files changed (50) hide show
  1. package/README.md +81 -0
  2. package/dist/client/index.d.ts +142 -0
  3. package/dist/client/index.js +32 -0
  4. package/dist/domains.d.ts +24 -0
  5. package/dist/domains.js +30 -0
  6. package/dist/emails/Template.d.ts +36 -0
  7. package/dist/emails/Template.js +82 -0
  8. package/dist/emails/_components/Brand.d.ts +13 -0
  9. package/dist/emails/_components/Brand.js +28 -0
  10. package/dist/emails/_components/CentreCard.d.ts +10 -0
  11. package/dist/emails/_components/CentreCard.js +22 -0
  12. package/dist/emails/_components/CentreWell.d.ts +7 -0
  13. package/dist/emails/_components/CentreWell.js +9 -0
  14. package/dist/emails/_components/Footer.d.ts +7 -0
  15. package/dist/emails/_components/Footer.js +26 -0
  16. package/dist/emails/_components/Head.d.ts +10 -0
  17. package/dist/emails/_components/Head.js +19 -0
  18. package/dist/emails/_components/OpenButton.d.ts +8 -0
  19. package/dist/emails/_components/OpenButton.js +21 -0
  20. package/dist/emails/_components/index.d.ts +14 -0
  21. package/dist/emails/_components/index.js +14 -0
  22. package/dist/emails/_components/tokens.d.ts +28 -0
  23. package/dist/emails/_components/tokens.js +28 -0
  24. package/dist/emails/index.d.ts +96 -0
  25. package/dist/emails/index.js +160 -0
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.js +9 -0
  28. package/dist/schema/index.d.ts +718 -0
  29. package/dist/schema/index.js +108 -0
  30. package/dist/server/auth.d.ts +67 -0
  31. package/dist/server/auth.js +152 -0
  32. package/dist/server/config.d.ts +81 -0
  33. package/dist/server/config.js +105 -0
  34. package/dist/server/dev.d.ts +15 -0
  35. package/dist/server/dev.js +48 -0
  36. package/dist/server/escape.d.ts +9 -0
  37. package/dist/server/escape.js +28 -0
  38. package/dist/server/guard.d.ts +13 -0
  39. package/dist/server/guard.js +17 -0
  40. package/dist/server/handle.d.ts +62 -0
  41. package/dist/server/handle.js +252 -0
  42. package/dist/server/index.d.ts +12 -0
  43. package/dist/server/index.js +11 -0
  44. package/dist/server/pages.d.ts +53 -0
  45. package/dist/server/pages.js +133 -0
  46. package/dist/server/testing.d.ts +31 -0
  47. package/dist/server/testing.js +53 -0
  48. package/dist/types.d.ts +30 -0
  49. package/dist/types.js +1 -0
  50. 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
+ };
@@ -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 {};