@aweftjs/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/LICENSE +21 -0
- package/README.md +548 -0
- package/dist/auth-client.d.ts +248 -0
- package/dist/auth-client.js +287 -0
- package/dist/client-modules/Reset.d.ts +12 -0
- package/dist/client-modules/Reset.js +61 -0
- package/dist/client-modules/Session.d.ts +6 -0
- package/dist/client-modules/Session.js +59 -0
- package/dist/client-modules/SignIn.d.ts +13 -0
- package/dist/client-modules/SignIn.js +58 -0
- package/dist/client-modules/Verify.d.ts +12 -0
- package/dist/client-modules/Verify.js +54 -0
- package/dist/client-modules/stage-token.d.ts +3 -0
- package/dist/client-modules/stage-token.js +9 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +33 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +11 -0
- package/dist/cookie.d.ts +15 -0
- package/dist/cookie.js +39 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +42 -0
- package/dist/links.d.ts +37 -0
- package/dist/links.js +77 -0
- package/dist/mail.d.ts +55 -0
- package/dist/mail.js +53 -0
- package/dist/modules/Check.d.ts +10 -0
- package/dist/modules/Check.js +20 -0
- package/dist/modules/Enter.d.ts +57 -0
- package/dist/modules/Enter.js +143 -0
- package/dist/modules/Gate.d.ts +6 -0
- package/dist/modules/Gate.js +43 -0
- package/dist/modules/Password.d.ts +34 -0
- package/dist/modules/Password.js +138 -0
- package/dist/modules/Roles.d.ts +32 -0
- package/dist/modules/Roles.js +135 -0
- package/dist/modules/Session.d.ts +39 -0
- package/dist/modules/Session.js +159 -0
- package/dist/modules/State.d.ts +8 -0
- package/dist/modules/State.js +22 -0
- package/dist/modules/Verify.d.ts +32 -0
- package/dist/modules/Verify.js +97 -0
- package/dist/names.d.ts +25 -0
- package/dist/names.js +44 -0
- package/dist/password.d.ts +4 -0
- package/dist/password.js +41 -0
- package/dist/props.d.ts +15 -0
- package/dist/props.js +35 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +11 -0
- package/dist/users.d.ts +9 -0
- package/dist/users.js +12 -0
- package/errors.txt +29 -0
- package/package.json +62 -0
- package/src/auth-client.ts +531 -0
- package/src/client-modules/Reset.tsx +97 -0
- package/src/client-modules/Session.ts +70 -0
- package/src/client-modules/SignIn.tsx +110 -0
- package/src/client-modules/Verify.tsx +82 -0
- package/src/client-modules/stage-token.ts +11 -0
- package/src/client.ts +38 -0
- package/src/context.ts +21 -0
- package/src/cookie.ts +35 -0
- package/src/index.ts +53 -0
- package/src/links.ts +107 -0
- package/src/mail.ts +88 -0
- package/src/modules/Check.ts +31 -0
- package/src/modules/Enter.ts +189 -0
- package/src/modules/Gate.ts +47 -0
- package/src/modules/Password.ts +155 -0
- package/src/modules/Roles.ts +163 -0
- package/src/modules/Session.ts +196 -0
- package/src/modules/State.ts +30 -0
- package/src/modules/Verify.ts +118 -0
- package/src/names.ts +49 -0
- package/src/password.ts +43 -0
- package/src/props.ts +47 -0
- package/src/token.ts +15 -0
- package/src/users.ts +18 -0
- package/surface.txt +19 -0
- package/text.json +50 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// auth/Session on the page: `createAuth` over the connection the stage handed in (design 245).
|
|
2
|
+
|
|
3
|
+
import { codecError } from '@aweftjs/codec';
|
|
4
|
+
import { type Derived, immutable, mutable } from '@aweftjs/core';
|
|
5
|
+
import type { Client, Handle } from '@aweftjs/client';
|
|
6
|
+
|
|
7
|
+
import { type Auth, createAuth } from '../auth-client.ts';
|
|
8
|
+
|
|
9
|
+
const NO_CLIENT_FIX = 'Pass the client createClient answered as the StageContext client, or none at all.';
|
|
10
|
+
const ANONYMOUS_FIX = 'Wait for user to read a string, or call enter first; an anonymous connection has no state.';
|
|
11
|
+
|
|
12
|
+
const isClient = (value: unknown): value is Client => {
|
|
13
|
+
const held = value as Partial<Client> | null;
|
|
14
|
+
return held !== null && typeof held === 'object'
|
|
15
|
+
&& typeof held.ask === 'function' && typeof held.share === 'function'
|
|
16
|
+
&& typeof held.status === 'object' && held.status !== null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const noClient = (what: string): Error =>
|
|
20
|
+
codecError('no-client', `auth/Session has no connection, so ${what}`, NO_CLIENT_FIX);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Identity with no connection at all: anonymous, at once and for good (design 245).
|
|
24
|
+
*
|
|
25
|
+
* What a static render gets, because `render` has no socket to give. `user` reads `null` from the
|
|
26
|
+
* start, which is a known answer rather than a wait: a factory that awaits identity would
|
|
27
|
+
* otherwise never return, and `render` waits on every pending promise, so the whole render hung.
|
|
28
|
+
* A gate refuses at once instead, and the sign-in act is what a static render of a gated page
|
|
29
|
+
* holds.
|
|
30
|
+
*/
|
|
31
|
+
const anonymous = (): Auth => {
|
|
32
|
+
const who: Derived<string | null | undefined> = immutable(mutable<string | null | undefined>(null));
|
|
33
|
+
const nothing: Derived<readonly string[] | undefined> = immutable(mutable<readonly string[] | undefined>([]));
|
|
34
|
+
return {
|
|
35
|
+
user: who,
|
|
36
|
+
names: nothing,
|
|
37
|
+
may: () => false,
|
|
38
|
+
enter: async () => { throw noClient('no sign-in was sent'); },
|
|
39
|
+
leave: async () => { throw noClient('no sign-out was sent'); },
|
|
40
|
+
check: async () => { throw noClient('no lookup was sent'); },
|
|
41
|
+
verify: async () => { throw noClient('no verification was sent'); },
|
|
42
|
+
change: async () => { throw noClient('no password change was sent'); },
|
|
43
|
+
forgot: async () => { throw noClient('no reset mail was sent'); },
|
|
44
|
+
reset: async () => { throw noClient('no reset was sent'); },
|
|
45
|
+
// The same refusal the real one gives an anonymous connection, so a page that reads it
|
|
46
|
+
// takes one path rather than two.
|
|
47
|
+
state: <T extends object>(): Handle<T> => {
|
|
48
|
+
const ready = Promise.reject<T>(codecError('anonymous',
|
|
49
|
+
'there is no signed-in user to share a state document for', ANONYMOUS_FIX));
|
|
50
|
+
ready.catch(() => {});
|
|
51
|
+
return { document: undefined, ready, stop: () => {} };
|
|
52
|
+
},
|
|
53
|
+
stop: () => {},
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export default ({ client, config }: { client?: unknown; config: Readonly<Record<string, unknown>> }): Auth => {
|
|
58
|
+
if (client === undefined) return anonymous();
|
|
59
|
+
if (!isClient(client)) {
|
|
60
|
+
throw codecError('no-client',
|
|
61
|
+
'auth/Session was handed a client with no status, ask or share on it',
|
|
62
|
+
NO_CLIENT_FIX);
|
|
63
|
+
}
|
|
64
|
+
const origin = config['origin'];
|
|
65
|
+
const fetch = config['fetch'];
|
|
66
|
+
return createAuth(client, {
|
|
67
|
+
...(typeof origin === 'string' ? { origin } : {}),
|
|
68
|
+
...(typeof fetch === 'function' ? { fetch: fetch as Exclude<Parameters<typeof createAuth>[1], undefined>['fetch'] } : {}),
|
|
69
|
+
});
|
|
70
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// auth/SignIn: one form for signing in and signing up, because `enter` does both (design 245).
|
|
2
|
+
//
|
|
3
|
+
// It decides nothing an application would want back: no URL of its own, no redirect, and no
|
|
4
|
+
// opinion about who may see what. Where to go afterwards is the application's.
|
|
5
|
+
|
|
6
|
+
import { mutable } from '@aweftjs/core';
|
|
7
|
+
import { Button, TextField, h, text } from '@aweftjs/ui';
|
|
8
|
+
|
|
9
|
+
import type { Auth } from '../auth-client.ts';
|
|
10
|
+
|
|
11
|
+
export const deps = ['auth/Session'];
|
|
12
|
+
|
|
13
|
+
/** What a refusal from `enter` carries: one entry per problem, each naming the field it is about. */
|
|
14
|
+
interface Reason {
|
|
15
|
+
readonly code: string;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LAYOUT = 'display:flex;flex-direction:column;gap:1rem;max-width:22rem';
|
|
20
|
+
|
|
21
|
+
const about = (reasons: readonly Reason[], code: string): string =>
|
|
22
|
+
reasons.filter((one) => one.code === code).map((one) => one.message).join(' ');
|
|
23
|
+
|
|
24
|
+
/** Everything the refusal said that was not about one of the two fields. */
|
|
25
|
+
const rest = (reasons: readonly Reason[]): string =>
|
|
26
|
+
reasons.filter((one) => one.code !== 'email' && one.code !== 'password')
|
|
27
|
+
.map((one) => one.message).join(' ');
|
|
28
|
+
|
|
29
|
+
/** What the stage hands a refused act beside the refusal (design 244). */
|
|
30
|
+
interface SignInProps {
|
|
31
|
+
/** Build the act the URL chose again. Present when this act is what `refused` names. */
|
|
32
|
+
readonly retry?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default ({ imports }: { imports: Readonly<Record<string, unknown>> }): {
|
|
36
|
+
title: unknown;
|
|
37
|
+
component: (props: SignInProps) => unknown;
|
|
38
|
+
} => {
|
|
39
|
+
const session = imports['Session'] as Auth;
|
|
40
|
+
|
|
41
|
+
const component = (props: SignInProps): unknown => {
|
|
42
|
+
const email = mutable('');
|
|
43
|
+
const password = mutable('');
|
|
44
|
+
const emailProblem = mutable('');
|
|
45
|
+
const passwordProblem = mutable('');
|
|
46
|
+
const problem = mutable('');
|
|
47
|
+
const busy = mutable(false);
|
|
48
|
+
|
|
49
|
+
const submit = async (): Promise<void> => {
|
|
50
|
+
if (busy.get()) return;
|
|
51
|
+
busy.set(true);
|
|
52
|
+
emailProblem.set('');
|
|
53
|
+
passwordProblem.set('');
|
|
54
|
+
problem.set('');
|
|
55
|
+
try {
|
|
56
|
+
const outcome = await session.enter(email.get(), password.get());
|
|
57
|
+
if (!('refused' in outcome)) {
|
|
58
|
+
// This act is standing in for the one the URL asked for, so the thing to do once
|
|
59
|
+
// the reason has stopped holding is build that act again. The battery still picks
|
|
60
|
+
// no URL: `retry` is the stage's own, and the address does not move.
|
|
61
|
+
props.retry?.();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const reasons = outcome.refused as readonly Reason[];
|
|
65
|
+
emailProblem.set(about(reasons, 'email'));
|
|
66
|
+
passwordProblem.set(about(reasons, 'password'));
|
|
67
|
+
problem.set(rest(reasons));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
problem.set(String((error as Error)?.message ?? error));
|
|
70
|
+
} finally {
|
|
71
|
+
busy.set(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<form
|
|
77
|
+
aria-label={text('Sign in')}
|
|
78
|
+
style={LAYOUT}
|
|
79
|
+
onSubmit={(event: unknown) => {
|
|
80
|
+
(event as { preventDefault(): void }).preventDefault();
|
|
81
|
+
void submit();
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<TextField
|
|
85
|
+
label={text('Email')}
|
|
86
|
+
value={email}
|
|
87
|
+
error={emailProblem}
|
|
88
|
+
placeholder={text('you@example.com')}
|
|
89
|
+
name="email"
|
|
90
|
+
autocomplete="email"
|
|
91
|
+
/>
|
|
92
|
+
<TextField
|
|
93
|
+
label={text('Password')}
|
|
94
|
+
value={password}
|
|
95
|
+
error={passwordProblem}
|
|
96
|
+
password
|
|
97
|
+
name="password"
|
|
98
|
+
autocomplete="current-password"
|
|
99
|
+
/>
|
|
100
|
+
{/* A refusal that named no field still has to be readable, and read out. */}
|
|
101
|
+
<p role="alert">{problem}</p>
|
|
102
|
+
{/* `Button` is a `type="button"`, so the click is the submit and the form's own
|
|
103
|
+
handler is what the Enter key reaches. */}
|
|
104
|
+
<Button label={text('Sign in')} loading={busy} disabled={busy} onClick={submit} />
|
|
105
|
+
</form>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return { title: text('Sign in'), component };
|
|
110
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// auth/Verify: the page a verification link opens, and the button that asks for one (design 290).
|
|
2
|
+
//
|
|
3
|
+
// With a token in the act's parameters or the URL's query it takes the link as soon as it
|
|
4
|
+
// mounts and says what happened. Without one it offers a signed-in person the mail. It picks no
|
|
5
|
+
// URL: the application names it in the acts map, and points the mail at that address.
|
|
6
|
+
|
|
7
|
+
import { all, mutable } from '@aweftjs/core';
|
|
8
|
+
import { Button, h, text } from '@aweftjs/ui';
|
|
9
|
+
import type { StageValue } from '@aweftjs/ui';
|
|
10
|
+
|
|
11
|
+
import type { Auth } from '../auth-client.ts';
|
|
12
|
+
import { tokenOf } from './stage-token.ts';
|
|
13
|
+
|
|
14
|
+
export const deps = ['auth/Session'];
|
|
15
|
+
|
|
16
|
+
interface Reason {
|
|
17
|
+
readonly code: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface VerifyProps {
|
|
22
|
+
readonly stage?: StageValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LAYOUT = 'display:flex;flex-direction:column;gap:1rem;max-width:22rem';
|
|
26
|
+
|
|
27
|
+
const said = (reasons: readonly Reason[]): string => reasons.map((one) => one.message).join(' ');
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export default ({ imports }: { imports: Readonly<Record<string, unknown>> }): {
|
|
31
|
+
title: unknown;
|
|
32
|
+
component: (props: VerifyProps) => unknown;
|
|
33
|
+
} => {
|
|
34
|
+
const session = imports['Session'] as Auth;
|
|
35
|
+
|
|
36
|
+
const component = (props: VerifyProps): unknown => {
|
|
37
|
+
const token = tokenOf(props.stage);
|
|
38
|
+
const note = mutable<unknown>('');
|
|
39
|
+
const problem = mutable('');
|
|
40
|
+
const busy = mutable(false);
|
|
41
|
+
const done = mutable(false);
|
|
42
|
+
|
|
43
|
+
const outcome = async (run: () => Promise<{ ok: true } | { refused: readonly Reason[] }>, onOk: unknown): Promise<void> => {
|
|
44
|
+
if (busy.get()) return;
|
|
45
|
+
busy.set(true);
|
|
46
|
+
problem.set('');
|
|
47
|
+
try {
|
|
48
|
+
const answer = await run();
|
|
49
|
+
if ('refused' in answer) problem.set(said(answer.refused));
|
|
50
|
+
else { note.set(onOk); done.set(true); }
|
|
51
|
+
} catch (error) {
|
|
52
|
+
problem.set(String((error as Error)?.message ?? error));
|
|
53
|
+
} finally {
|
|
54
|
+
busy.set(false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (token !== undefined) {
|
|
59
|
+
void outcome(() => session.verify(token), text('Your email address is verified.'));
|
|
60
|
+
return (
|
|
61
|
+
<section aria-label={text('Verify your email')} style={LAYOUT}>
|
|
62
|
+
<p>{note}</p>
|
|
63
|
+
<p role="alert">{problem}</p>
|
|
64
|
+
</section>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const send = (): Promise<void> => outcome(() => session.verify(), text('The link is on its way. Open it from your mail.'));
|
|
69
|
+
// Reads `user`, so the button follows a sign-in or a sign-out on the same page.
|
|
70
|
+
const anonymous = session.user.map((who) => who === null);
|
|
71
|
+
return (
|
|
72
|
+
<section aria-label={text('Verify your email')} style={LAYOUT}>
|
|
73
|
+
<p>{anonymous.map((is) => is ? text('Sign in first, then ask for the link.') : text('We will send a link to your email address.'))}</p>
|
|
74
|
+
<p>{note}</p>
|
|
75
|
+
<p role="alert">{problem}</p>
|
|
76
|
+
<Button label={text('Send the link')} loading={busy} disabled={all([anonymous, busy, done]).map(([is, sending, sent]) => is || sending || sent)} onClick={send} />
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return { title: text('Verify your email'), component };
|
|
82
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The token a link put in the URL, as the two link acts read it (design 290).
|
|
2
|
+
|
|
3
|
+
import type { StageValue } from '@aweftjs/ui';
|
|
4
|
+
|
|
5
|
+
/** The token the URL carries, from the act's own parameter first and the query second. */
|
|
6
|
+
export const tokenOf = (stage: StageValue | undefined): string | undefined => {
|
|
7
|
+
const named = stage?.params.get().token;
|
|
8
|
+
if (typeof named === 'string' && named !== '') return named;
|
|
9
|
+
const queried = stage?.query.get().token;
|
|
10
|
+
return typeof queried === 'string' && queried !== '' ? queried : undefined;
|
|
11
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The browser half of the battery, as an entry: what a page calls itself, and the source that
|
|
2
|
+
// puts the four client modules on the page (designs 245, 290).
|
|
3
|
+
|
|
4
|
+
import { fromBundle } from '@aweftjs/modules';
|
|
5
|
+
import type { Source } from '@aweftjs/modules';
|
|
6
|
+
|
|
7
|
+
export { createAuth } from './auth-client.ts';
|
|
8
|
+
export type { Auth, AuthOptions, Entered, FetchInit, FetchResponse, Fetcher, Outcome } from './auth-client.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The battery's page modules, for a stage's `sources`.
|
|
12
|
+
*
|
|
13
|
+
* Four of them. `auth/Session` is `createAuth` over the connection the stage handed the loader,
|
|
14
|
+
* and its instance is the `Auth`: `user`, `names`, `may`, `enter`, `leave`, `state`, `check`,
|
|
15
|
+
* `verify`, `change`, `forgot`, `reset` and `stop`. Any module of yours that needs to know who
|
|
16
|
+
* the page is names it in `deps`. The other three are act modules: `auth/SignIn`, the sign-in
|
|
17
|
+
* and sign-up form in one, because `enter` does both; `auth/Verify`, the page a verification
|
|
18
|
+
* link opens, and the button that asks for one; `auth/Reset`, the forgot form, and the
|
|
19
|
+
* new-password form a reset link opens.
|
|
20
|
+
*
|
|
21
|
+
* The battery picks no URL. Put an act on the address you want it at, and it lands there; the
|
|
22
|
+
* mail links point at those addresses through the server modules' `url`. Put a module of the
|
|
23
|
+
* same name in an earlier source to replace any of them, or a file exporting only `config` to
|
|
24
|
+
* configure one: `auth/Session` reads `origin` and `fetch` out of its config.
|
|
25
|
+
*
|
|
26
|
+
* Example:
|
|
27
|
+
* <StageContext sources={[app, authClient]} client={client}
|
|
28
|
+
* acts={{ '': Home, notes: 'notes/Page', join: 'auth/SignIn', verify: 'auth/Verify', reset: 'auth/Reset' }}
|
|
29
|
+
* refused="join">
|
|
30
|
+
* <Stage />
|
|
31
|
+
* </StageContext>
|
|
32
|
+
*/
|
|
33
|
+
export const authClient: Source = fromBundle({
|
|
34
|
+
'./auth/Session.ts': () => import('./client-modules/Session.ts'),
|
|
35
|
+
'./auth/SignIn.tsx': () => import('./client-modules/SignIn.tsx'),
|
|
36
|
+
'./auth/Verify.tsx': () => import('./client-modules/Verify.tsx'),
|
|
37
|
+
'./auth/Reset.tsx': () => import('./client-modules/Reset.tsx'),
|
|
38
|
+
});
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What the auth gate resolves for a connection or a request (designs 074, 275).
|
|
2
|
+
|
|
3
|
+
/** Who is on a connection: the user's id and the session token, or neither, and where it came from. */
|
|
4
|
+
export interface AuthContext {
|
|
5
|
+
readonly user: string | null;
|
|
6
|
+
readonly session: string | null;
|
|
7
|
+
/** The peer address as far as the listener could tell, so a route can count by it. Absent from a context built by hand. */
|
|
8
|
+
readonly address?: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The user on a context, when the context is one of ours and has one. */
|
|
12
|
+
export const userOf = (context: unknown): string | null => {
|
|
13
|
+
const user: unknown = (context as { user?: unknown } | null)?.user;
|
|
14
|
+
return typeof user === 'string' ? user : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The address on a context, when it carries one. */
|
|
18
|
+
export const addressOf = (context: unknown): string | undefined => {
|
|
19
|
+
const address: unknown = (context as { address?: unknown } | null)?.address;
|
|
20
|
+
return typeof address === 'string' ? address : undefined;
|
|
21
|
+
};
|
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The session cookie: reading it off a request, and writing the header that sets or clears it.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every non-empty value of the cookies by that name, in the order the client sent them.
|
|
5
|
+
*
|
|
6
|
+
* Several, because a browser sends every cookie of that name whose scope matches, and one
|
|
7
|
+
* from another path or another application on the host is not this battery's to refuse. An
|
|
8
|
+
* empty value is what the battery's own clearing header leaves, and counts as absent.
|
|
9
|
+
*/
|
|
10
|
+
export const cookiesOf = (request: Request, name: string): string[] => {
|
|
11
|
+
const header = request.headers.get('cookie');
|
|
12
|
+
if (header === null) return [];
|
|
13
|
+
const values: string[] = [];
|
|
14
|
+
for (const part of header.split(';')) {
|
|
15
|
+
const at = part.indexOf('=');
|
|
16
|
+
if (at < 0 || part.slice(0, at).trim() !== name) continue;
|
|
17
|
+
const value = part.slice(at + 1).trim();
|
|
18
|
+
if (value !== '') values.push(value);
|
|
19
|
+
}
|
|
20
|
+
return values;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The `Set-Cookie` value that sets the session cookie, or clears it for `null`.
|
|
25
|
+
*
|
|
26
|
+
* HttpOnly so script never reads it, SameSite=Lax so a cross-site form cannot present it,
|
|
27
|
+
* Secure when the request itself came over TLS, and a lifetime only when one is configured.
|
|
28
|
+
*/
|
|
29
|
+
export const setCookie = (name: string, token: string | null, request: Request, maxAgeMs?: number): string => {
|
|
30
|
+
const parts = [`${name}=${token ?? ''}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
31
|
+
if (new URL(request.url).protocol === 'https:') parts.push('Secure');
|
|
32
|
+
if (token === null) parts.push('Max-Age=0');
|
|
33
|
+
else if (maxAgeMs !== undefined) parts.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`);
|
|
34
|
+
return parts.join('; ');
|
|
35
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The battery, as two sources of server modules: the six every application loads, the two
|
|
2
|
+
// that mail and so need notify, and the store paths they query (designs 074, 289, 290).
|
|
3
|
+
|
|
4
|
+
import { fromBundle } from '@aweftjs/modules';
|
|
5
|
+
import type { Source } from '@aweftjs/modules';
|
|
6
|
+
import type { Declaration } from '@aweftjs/store';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The auth modules, for `sources`.
|
|
10
|
+
*
|
|
11
|
+
* Put the application's own source first and a module of the same name there wins, which is
|
|
12
|
+
* how one of these is replaced. Each module reads the `store` the platform handed in, which
|
|
13
|
+
* on a server is the `store` given to `createServer`.
|
|
14
|
+
*
|
|
15
|
+
* Example:
|
|
16
|
+
* const server = createServer({ sources: [own, auth], store, gate: 'auth/Gate', listener });
|
|
17
|
+
* await server.start();
|
|
18
|
+
*/
|
|
19
|
+
export const auth: Source = fromBundle({
|
|
20
|
+
'./auth/Gate.ts': () => import('./modules/Gate.ts'),
|
|
21
|
+
'./auth/Session.ts': () => import('./modules/Session.ts'),
|
|
22
|
+
'./auth/Roles.ts': () => import('./modules/Roles.ts'),
|
|
23
|
+
'./auth/Enter.ts': () => import('./modules/Enter.ts'),
|
|
24
|
+
'./auth/Check.ts': () => import('./modules/Check.ts'),
|
|
25
|
+
'./auth/State.ts': () => import('./modules/State.ts'),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The two modules that mail: `auth/Verify` and `auth/Password`. Both name `notify/Send` in
|
|
30
|
+
* their `deps`, so list `notify` beside this, and give each its `url` in a same-named file.
|
|
31
|
+
*
|
|
32
|
+
* Example:
|
|
33
|
+
* createServer({ sources: [own, auth, mail, notify], store, gate: 'auth/Gate', listener });
|
|
34
|
+
*/
|
|
35
|
+
export const mail: Source = fromBundle({
|
|
36
|
+
'./auth/Verify.ts': () => import('./modules/Verify.ts'),
|
|
37
|
+
'./auth/Password.ts': () => import('./modules/Password.ts'),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The paths the application declares on its store for these modules to query: `email` on
|
|
42
|
+
* user documents, `user` and `expires` on session and link documents.
|
|
43
|
+
*
|
|
44
|
+
* Example:
|
|
45
|
+
* const store = createStore({ driver, declare: { ...paths, title: ['title'] } });
|
|
46
|
+
*/
|
|
47
|
+
export const paths: Declaration = { email: ['email'], user: ['user'], expires: ['expires'] };
|
|
48
|
+
|
|
49
|
+
export { holds } from './names.ts';
|
|
50
|
+
export type { Implies } from './names.ts';
|
|
51
|
+
export type { AuthContext } from './context.ts';
|
|
52
|
+
export type { RefuseSignUp, SignUp } from './modules/Enter.ts';
|
|
53
|
+
export type { Roles } from './modules/Roles.ts';
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A one-time link: a token naming a `<prefix>:<token>` document that says who it is for and
|
|
2
|
+
// when it stops being valid (design 290). Verification and reset links are both this. A taken
|
|
3
|
+
// link keeps its document, marked, until its end, so a second opening is told it was taken
|
|
4
|
+
// rather than that it never was (design 294).
|
|
5
|
+
|
|
6
|
+
import { atomic } from '@aweftjs/core';
|
|
7
|
+
import type { Refusal } from '@aweftjs/core';
|
|
8
|
+
import type { Store } from '@aweftjs/store';
|
|
9
|
+
|
|
10
|
+
import { isToken, mintToken } from './token.ts';
|
|
11
|
+
|
|
12
|
+
/** What a link document holds. `user` and `expires` are paths the battery declares, so the sweep is a query. `taken` is set once and queried by nothing. */
|
|
13
|
+
export interface LinkDocument extends Record<string, unknown> {
|
|
14
|
+
user: string;
|
|
15
|
+
expires: number;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
taken?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What a token names before its end: the user of a live link, or that the link was taken. */
|
|
21
|
+
export type Link = { readonly user: string } | { readonly taken: true };
|
|
22
|
+
|
|
23
|
+
/** What a route answers for a link it will not open: one nobody issued or past its end, and one already taken. */
|
|
24
|
+
export const NOT_LIVE: Refusal = { code: 'token', message: 'this link is not one that can be used' };
|
|
25
|
+
export const TAKEN: Refusal = { code: 'taken', message: 'this link has already been used' };
|
|
26
|
+
|
|
27
|
+
export interface Links {
|
|
28
|
+
/** Mint a link for a user. Returns its token. */
|
|
29
|
+
issue(user: string): Promise<string>;
|
|
30
|
+
/** What the token names, leaving it. Undefined for a token nobody issued or one past its end. */
|
|
31
|
+
peek(token: unknown): Promise<Link | undefined>;
|
|
32
|
+
/** Take the link: the user it was for, and the document is marked taken. Undefined as `peek`. */
|
|
33
|
+
take(token: unknown): Promise<Link | undefined>;
|
|
34
|
+
/** Remove every link past its end, taken or not. Returns how many. */
|
|
35
|
+
sweep(): Promise<number>;
|
|
36
|
+
/** Stop the sweep timer. */
|
|
37
|
+
stop(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The links of one prefix, each living `lifetimeMs` and swept every `sweepMs`.
|
|
42
|
+
*
|
|
43
|
+
* The first sweep runs when this is made, so a restart clears what expired while the process
|
|
44
|
+
* was down; the timer never keeps the process alive.
|
|
45
|
+
*/
|
|
46
|
+
export const links = (store: Store, prefix: string, lifetimeMs: number, sweepMs: number): Links => {
|
|
47
|
+
const doc = (token: string): string => `${prefix}:${token}`;
|
|
48
|
+
|
|
49
|
+
const linkOf = (held: Partial<LinkDocument>): Link | undefined => {
|
|
50
|
+
if (typeof held.user !== 'string' || typeof held.expires !== 'number' || held.expires <= Date.now()) return undefined;
|
|
51
|
+
return held.taken === true ? { taken: true } : { user: held.user };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sweep = async (): Promise<number> => {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
let removed = 0;
|
|
57
|
+
for (const { doc: name, fields } of await store.find({ where: [{ field: 'expires', op: 'lt', value: now }] })) {
|
|
58
|
+
// The path is declared for every document, so the answer is filtered to this prefix.
|
|
59
|
+
if (!name.startsWith(`${prefix}:`) || typeof fields.expires !== 'number' || fields.expires >= now) continue;
|
|
60
|
+
await store.remove(name);
|
|
61
|
+
removed += 1;
|
|
62
|
+
}
|
|
63
|
+
return removed;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
void sweep().catch(() => undefined);
|
|
67
|
+
const timer = setInterval(() => { void sweep().catch(() => undefined); }, sweepMs);
|
|
68
|
+
timer.unref?.();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
issue: async (user) => {
|
|
72
|
+
const token = mintToken();
|
|
73
|
+
const handle = await store.open(doc(token));
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
atomic(() => {
|
|
76
|
+
Object.assign(handle.root, { user, expires: now + lifetimeMs, createdAt: now } satisfies LinkDocument);
|
|
77
|
+
});
|
|
78
|
+
await store.settled(handle);
|
|
79
|
+
await store.close(handle);
|
|
80
|
+
return token;
|
|
81
|
+
},
|
|
82
|
+
peek: async (token) => {
|
|
83
|
+
// A document that was never written has no commits, and `open` would create one.
|
|
84
|
+
if (!isToken(token) || await store.head(doc(token)) === 0) return undefined;
|
|
85
|
+
const handle = await store.open(doc(token));
|
|
86
|
+
const link = linkOf(handle.root as Partial<LinkDocument>);
|
|
87
|
+
await store.close(handle);
|
|
88
|
+
return link;
|
|
89
|
+
},
|
|
90
|
+
take: async (token) => {
|
|
91
|
+
if (!isToken(token) || await store.head(doc(token)) === 0) return undefined;
|
|
92
|
+
const handle = await store.open(doc(token));
|
|
93
|
+
const held = handle.root as Partial<LinkDocument>;
|
|
94
|
+
// Two takes of one token in flight open the same live document, so the first to get
|
|
95
|
+
// here marks it and the second reads the mark.
|
|
96
|
+
const link = linkOf(held);
|
|
97
|
+
if (link !== undefined && 'user' in link) {
|
|
98
|
+
atomic(() => { held.taken = true; });
|
|
99
|
+
await store.settled(handle);
|
|
100
|
+
}
|
|
101
|
+
await store.close(handle);
|
|
102
|
+
return link;
|
|
103
|
+
},
|
|
104
|
+
sweep,
|
|
105
|
+
stop: () => { clearInterval(timer); },
|
|
106
|
+
};
|
|
107
|
+
};
|
package/src/mail.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// What the two mail modules share: the mailer they name in `deps`, the link mail, and the
|
|
2
|
+
// configuration checks (design 290). `notify/Send` is the mailer; its shape is stated here
|
|
3
|
+
// rather than imported, so `auth` alone carries nothing of notify, and the integration suite
|
|
4
|
+
// loads the real one to keep the two agreeing.
|
|
5
|
+
|
|
6
|
+
import type { Refusal } from '@aweftjs/server';
|
|
7
|
+
|
|
8
|
+
import { invalidConfig } from './props.ts';
|
|
9
|
+
|
|
10
|
+
/** What one channel of a send answered, as `notify/Send` records it. */
|
|
11
|
+
export type MailDelivery = { readonly ok: true } | { readonly ok: false; readonly error: string } | { readonly skipped: string };
|
|
12
|
+
|
|
13
|
+
/** The part of `notify/Send` these modules call. */
|
|
14
|
+
export interface Mailer {
|
|
15
|
+
send(options: {
|
|
16
|
+
readonly to: { readonly user: string };
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly body: string;
|
|
19
|
+
readonly html: string;
|
|
20
|
+
readonly channels: readonly ['email'];
|
|
21
|
+
}): Promise<{ readonly delivery: { readonly email?: MailDelivery | undefined } }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The link an application's `url` makes for a token. */
|
|
25
|
+
export type LinkUrl = (token: string) => string;
|
|
26
|
+
|
|
27
|
+
/** What a mail route's call answers: done, or the reasons. */
|
|
28
|
+
export type Outcome = { readonly ok: true } | { readonly refused: readonly Refusal[] };
|
|
29
|
+
|
|
30
|
+
const escape = (text: string): string =>
|
|
31
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
32
|
+
|
|
33
|
+
/** Non-empty text, or `invalid-config`. */
|
|
34
|
+
export const textOf = (module: string, config: Readonly<Record<string, unknown>>, key: string): string => {
|
|
35
|
+
const held: unknown = config[key];
|
|
36
|
+
if (typeof held !== 'string' || held === '') throw invalidConfig(module, `${key} ${JSON.stringify(held)}`, 'Give that setting some text.');
|
|
37
|
+
return held;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The `url` function, which has no default because a battery picks no URL. */
|
|
41
|
+
export const urlOf = (module: string, config: Readonly<Record<string, unknown>>): LinkUrl => {
|
|
42
|
+
const held: unknown = config.url;
|
|
43
|
+
if (typeof held !== 'function') {
|
|
44
|
+
throw invalidConfig(module, `url ${JSON.stringify(held)}`, 'Give url a function of the token answering the address of the page that takes it: (token) => `https://app.example/verify?token=${token}`.');
|
|
45
|
+
}
|
|
46
|
+
return held as LinkUrl;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `mail` refusal (design 293): `message` is the sentence a page shows the person, `detail`
|
|
51
|
+
* is what the mailer said, and `fix` is for whoever runs it. The route answers 502 and the
|
|
52
|
+
* token still stands.
|
|
53
|
+
*/
|
|
54
|
+
export interface MailRefusal extends Refusal {
|
|
55
|
+
readonly detail: string;
|
|
56
|
+
readonly fix: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const mailFailed = (detail: string): MailRefusal => ({
|
|
60
|
+
code: 'mail',
|
|
61
|
+
message: 'the mail could not be sent; try again later',
|
|
62
|
+
detail,
|
|
63
|
+
fix: 'Check the email setting notify/Send was given; what the mailer answered is in detail.',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mail a person one link. Answers the refusal when it did not go, and nothing when it did.
|
|
68
|
+
*
|
|
69
|
+
* The body is the sentence and the address as text; the HTML is the same with the address as a
|
|
70
|
+
* link, so a reader whose mail shows no HTML has the address to copy.
|
|
71
|
+
*/
|
|
72
|
+
export const mailLink = async (mailer: Mailer, user: string, subject: string, sentence: string, url: string): Promise<MailRefusal | undefined> => {
|
|
73
|
+
let delivery: MailDelivery | undefined;
|
|
74
|
+
try {
|
|
75
|
+
({ delivery: { email: delivery } } = await mailer.send({
|
|
76
|
+
to: { user },
|
|
77
|
+
title: subject,
|
|
78
|
+
body: `${sentence} ${url}`,
|
|
79
|
+
html: `<p>${escape(sentence)}</p><p><a href="${escape(url)}">${escape(url)}</a></p>`,
|
|
80
|
+
channels: ['email'],
|
|
81
|
+
}));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return mailFailed(String((error as Error)?.message ?? error));
|
|
84
|
+
}
|
|
85
|
+
if (delivery === null || typeof delivery !== 'object') return mailFailed('the mailer tried no email channel');
|
|
86
|
+
if ('skipped' in delivery) return mailFailed(String(delivery.skipped));
|
|
87
|
+
return delivery.ok === true ? undefined : mailFailed(String((delivery as { error?: unknown }).error ?? 'the mailer gave no reason'));
|
|
88
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// auth/Check: does anyone have this email. Public, so a sign-in form can ask before it asks
|
|
2
|
+
// for a password.
|
|
3
|
+
|
|
4
|
+
import { codecError } from '@aweftjs/codec';
|
|
5
|
+
import type { ModuleProps } from '@aweftjs/modules';
|
|
6
|
+
|
|
7
|
+
import { storeOf } from '../props.ts';
|
|
8
|
+
import { findUser, looksLikeEmail, normalEmail } from '../users.ts';
|
|
9
|
+
|
|
10
|
+
export interface Check {
|
|
11
|
+
readonly public: true;
|
|
12
|
+
exists(email: string): Promise<boolean>;
|
|
13
|
+
call(args: unknown): Promise<{ exists: boolean }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default (props: ModuleProps): Check => {
|
|
17
|
+
const store = storeOf(props);
|
|
18
|
+
const exists = async (email: string): Promise<boolean> =>
|
|
19
|
+
looksLikeEmail(normalEmail(email)) && (await findUser(store, email)) !== undefined;
|
|
20
|
+
return {
|
|
21
|
+
public: true,
|
|
22
|
+
exists,
|
|
23
|
+
call: async (args) => {
|
|
24
|
+
const email: unknown = (args as { email?: unknown } | null)?.email;
|
|
25
|
+
if (typeof email !== 'string') {
|
|
26
|
+
throw codecError('malformed', 'auth/Check was called without an email string', 'Call it as { email: "someone@example.com" }.');
|
|
27
|
+
}
|
|
28
|
+
return { exists: await exists(email) };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
};
|