Skip to content

Commit 42a4c9d

Browse files
committed
Codex attempt
1 parent d350d0d commit 42a4c9d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1871
-155
lines changed

src/authentication/auth-routes.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,45 @@
11
import {Dependencies} from '../dependencies';
2-
import {Safe, safe} from '../types/html';
2+
import {Config} from '../configuration';
3+
import {html, Safe, safe} from '../types/html';
34
import {Route, get, post} from '../types/route';
4-
import {auth, landing, callback, invalidLink, logIn, logOut} from './handlers';
5+
import {
6+
auth,
7+
landing,
8+
callback,
9+
invalidLink,
10+
logIn,
11+
logOut,
12+
verifyEmailCallback,
13+
} from './handlers';
514

615
export const logInPath: Safe = safe('/log-in');
716
const invalidLinkPath = '/auth/invalid-magic-link';
17+
const invalidVerificationLinkPath = '/auth/invalid-email-verification-link';
818

9-
export const authRoutes = (deps: Dependencies): ReadonlyArray<Route> => {
19+
export const authRoutes = (
20+
deps: Dependencies,
21+
conf: Config
22+
): ReadonlyArray<Route> => {
1023
return [
1124
get(logInPath, logIn(deps)),
1225
get('/log-out', logOut),
1326
post('/auth', auth),
1427
get('/auth', (_req, res) => res.redirect(logInPath)),
15-
get('/auth/landing', landing),
28+
get('/auth/landing', landing('/auth/callback')),
1629
get('/auth/callback', callback(invalidLinkPath)),
1730
get(invalidLinkPath, invalidLink(logInPath)),
31+
get('/auth/verify-email/landing', landing('/auth/verify-email/callback')),
32+
get(
33+
'/auth/verify-email/callback',
34+
verifyEmailCallback(deps, conf, invalidVerificationLinkPath)
35+
),
36+
get(
37+
invalidVerificationLinkPath,
38+
invalidLink(
39+
logInPath,
40+
html`The verification link you have used is (no longer) valid. Go
41+
back to the <a href=${logInPath}>log in</a> page.`
42+
)
43+
),
1844
];
1945
};

src/authentication/get-user-from-session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const getUserFromSession =
2626
),
2727
E.map(session => ({
2828
emailAddress: session.passport.user.emailAddress,
29+
primaryEmailAddress: session.passport.user.primaryEmailAddress,
2930
memberNumber: session.passport.user.memberNumber,
3031
})),
3132
O.fromEither

src/authentication/handlers.ts

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,25 @@ import {pipe} from 'fp-ts/lib/function';
44
import {parseEmailAddressFromBody} from './parse-email-address-from-body';
55
import * as E from 'fp-ts/Either';
66
import * as O from 'fp-ts/Option';
7+
import * as TE from 'fp-ts/TaskEither';
78
import {publish} from 'pubsub-js';
89
import passport from 'passport';
9-
import {magicLink} from './magic-link';
10+
import {emailVerificationLink, magicLink} from './magic-link';
1011
import {logInPage} from './log-in-page';
1112
import {checkYourMailPage} from './check-your-mail';
1213
import {oopsPage, isolatedPageTemplate} from '../templates';
1314
import {StatusCodes} from 'http-status-codes';
1415
import {getUserFromSession} from './get-user-from-session';
1516
import {Dependencies} from '../dependencies';
17+
import {Config} from '../configuration';
1618
import {
1719
html,
1820
HtmlSubstitution,
1921
CompleteHtmlDocument,
2022
sanitizeString,
2123
safe,
2224
} from '../types/html';
25+
import {verifyEmail} from '../commands/members/verify-email';
2326

2427
export const logIn =
2528
(deps: Dependencies) =>
@@ -58,37 +61,96 @@ export const auth = (req: Request, res: Response<CompleteHtmlDocument>) => {
5861
};
5962

6063
export const invalidLink =
61-
(logInPath: HtmlSubstitution) =>
64+
(linkPath: HtmlSubstitution, copy?: HtmlSubstitution) =>
6265
(req: Request, res: Response<CompleteHtmlDocument>) => {
6366
res
6467
.status(StatusCodes.UNAUTHORIZED)
6568
.send(
6669
oopsPage(
67-
html`The link you have used is (no longer) valid. Go back to the
68-
<a href=${logInPath}>log in</a> page.`
70+
copy ??
71+
html`The link you have used is (no longer) valid. Go back to the
72+
<a href=${linkPath}>log in</a> page.`
6973
)
7074
);
7175
};
7276

73-
export const landing = (req: Request, res: Response<CompleteHtmlDocument>) => {
74-
const index = req.originalUrl.indexOf('?');
75-
const suffix = index === -1 ? '' : req.originalUrl.slice(index);
76-
const url = '/auth/callback' + suffix;
77-
res.status(StatusCodes.OK).send(
78-
isolatedPageTemplate(sanitizeString('Redirecting...'))(html`
79-
<!doctype html>
80-
<html>
81-
<head>
82-
<meta http-equiv="refresh" content="0; url='${safe(url)}'" />
83-
</head>
84-
<body></body>
85-
</html>
86-
`)
87-
);
88-
};
77+
export const landing =
78+
(callbackPath: string) =>
79+
(req: Request, res: Response<CompleteHtmlDocument>) => {
80+
const index = req.originalUrl.indexOf('?');
81+
const suffix = index === -1 ? '' : req.originalUrl.slice(index);
82+
const url = callbackPath + suffix;
83+
res.status(StatusCodes.OK).send(
84+
isolatedPageTemplate(sanitizeString('Redirecting...'))(html`
85+
<!doctype html>
86+
<html>
87+
<head>
88+
<meta http-equiv="refresh" content="0; url='${safe(url)}'" />
89+
</head>
90+
<body></body>
91+
</html>
92+
`)
93+
);
94+
};
8995

9096
export const callback = (invalidLinkPath: string) =>
9197
passport.authenticate(magicLink.name, {
9298
failureRedirect: invalidLinkPath,
9399
successRedirect: '/',
94100
}) as RequestHandler;
101+
102+
export const verifyEmailCallback =
103+
(deps: Dependencies, conf: Config, invalidLinkPath: string): RequestHandler =>
104+
(req, res) => {
105+
pipe(
106+
req.query,
107+
emailVerificationLink.decodeFromQuery(deps.logger, conf),
108+
E.match(
109+
error => {
110+
deps.logger.info(
111+
{error},
112+
'Failed to decode email verification link'
113+
);
114+
res.redirect(invalidLinkPath);
115+
},
116+
payload => {
117+
const input = {
118+
memberNumber: payload.memberNumber,
119+
email: payload.emailAddress,
120+
};
121+
const resource = verifyEmail.resource(input);
122+
void pipe(
123+
deps.getResourceEvents(resource),
124+
TE.chain(({events, version}) => {
125+
const event = verifyEmail.process({
126+
command: {
127+
...input,
128+
actor: {tag: 'system'},
129+
},
130+
events,
131+
});
132+
if (O.isNone(event)) {
133+
return TE.right(undefined);
134+
}
135+
return pipe(
136+
deps.commitEvent(resource, version)(event.value),
137+
TE.map(() => undefined)
138+
);
139+
}),
140+
TE.match(
141+
failure => {
142+
deps.logger.error(
143+
{failure},
144+
'Failed to verify member email from callback'
145+
);
146+
res.redirect(invalidLinkPath);
147+
},
148+
() => {
149+
res.redirect('/me');
150+
}
151+
)
152+
)();
153+
}
154+
)
155+
);
156+
};

src/authentication/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {magicLink} from './magic-link';
2+
export {emailVerificationLink} from './magic-link';
23
export {authRoutes} from './auth-routes';
34
export {getUserFromSession} from './get-user-from-session';
45
export {startMagicLinkEmailPubSub} from './start-magic-link-email-pub-sub';

src/authentication/magic-link.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,44 @@ import {Strategy as CustomStrategy} from 'passport-custom';
66
import jwt from 'jsonwebtoken';
77
import {Config} from '../configuration';
88
import {Dependencies} from '../dependencies';
9+
import {EmailAddressCodec} from '../types';
910
import {User} from '../types/user';
1011
import {logPassThru} from '../util';
1112
import {Logger} from 'pino';
1213

14+
const LoginTokenPayload = t.strict({
15+
purpose: t.literal('log-in'),
16+
user: User,
17+
});
18+
19+
const VerifyEmailTokenPayload = t.strict({
20+
purpose: t.literal('verify-email'),
21+
memberNumber: t.number,
22+
emailAddress: EmailAddressCodec,
23+
});
24+
25+
export type VerifyEmailTokenPayload = t.TypeOf<typeof VerifyEmailTokenPayload>;
26+
27+
const createSignedToken =
28+
(conf: Config) =>
29+
(payload: object): string =>
30+
jwt.sign(payload, conf.TOKEN_SECRET, {expiresIn: '10m'});
31+
1332
const createMagicLink = (conf: Config) => (user: User) =>
1433
pipe(
15-
jwt.sign(user, conf.TOKEN_SECRET, {expiresIn: '10m'}),
34+
LoginTokenPayload.encode({purpose: 'log-in', user}),
35+
createSignedToken(conf),
1636
token => `${conf.PUBLIC_URL}/auth/landing?token=${token}`
1737
);
1838

39+
const createEmailVerificationLink =
40+
(conf: Config) => (payload: VerifyEmailTokenPayload) =>
41+
pipe(
42+
VerifyEmailTokenPayload.encode(payload),
43+
createSignedToken(conf),
44+
token => `${conf.PUBLIC_URL}/auth/verify-email/landing?token=${token}`
45+
);
46+
1947
const MagicLinkQuery = t.strict({
2048
token: t.string,
2149
});
@@ -26,14 +54,30 @@ const verifyToken = (token: string, secret: Config['TOKEN_SECRET']) =>
2654
failure('Could not verify token')
2755
);
2856

29-
const decodeMagicLinkFromQuery =
57+
const decodeTokenFromQuery =
3058
(logger: Logger, conf: Config) => (input: unknown) =>
3159
pipe(
3260
input,
3361
logPassThru(logger, 'Attempting to decode magic link from query'), // Logging is required as a basic form of auth enumeration detection.
3462
MagicLinkQuery.decode,
35-
E.chainW(({token}) => verifyToken(token, conf.TOKEN_SECRET)),
36-
E.chainW(User.decode)
63+
E.chainW(({token}) => verifyToken(token, conf.TOKEN_SECRET))
64+
);
65+
66+
export const decodeMagicLinkFromQuery =
67+
(logger: Logger, conf: Config) => (input: unknown) =>
68+
pipe(
69+
input,
70+
decodeTokenFromQuery(logger, conf),
71+
E.chainW(LoginTokenPayload.decode),
72+
E.map(payload => payload.user)
73+
);
74+
75+
export const decodeEmailVerificationFromQuery =
76+
(logger: Logger, conf: Config) => (input: unknown) =>
77+
pipe(
78+
input,
79+
decodeTokenFromQuery(logger, conf),
80+
E.chainW(VerifyEmailTokenPayload.decode)
3781
);
3882

3983
const strategy = (deps: Dependencies, conf: Config) => {
@@ -60,3 +104,8 @@ export const magicLink = {
60104
strategy,
61105
create: createMagicLink,
62106
};
107+
108+
export const emailVerificationLink = {
109+
create: createEmailVerificationLink,
110+
decodeFromQuery: decodeEmailVerificationFromQuery,
111+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as TE from 'fp-ts/TaskEither';
2+
import {pipe} from 'fp-ts/lib/function';
3+
import mjml2html from 'mjml';
4+
import {Config} from '../configuration';
5+
import {Dependencies} from '../dependencies';
6+
import {Email, EmailAddress, Failure} from '../types';
7+
import {emailVerificationLink} from './magic-link';
8+
9+
const toEmail =
10+
(emailAddress: EmailAddress) =>
11+
(verificationLink: string): Email => ({
12+
recipient: emailAddress,
13+
subject: 'Verify your Makespace email address',
14+
text: `
15+
Hi,
16+
17+
Verify this email address for your Makespace account by opening the link below:
18+
19+
${verificationLink}
20+
`,
21+
html: mjml2html(`
22+
<mjml>
23+
<mj-body width="800px">
24+
<mj-section background-color="#fa990e">
25+
<mj-column>
26+
<mj-text align="center" color="#111" font-size="40px">MakeSpace</mj-text>
27+
<mj-text font-style="italic" align="center" color="#111" font-size="30px">Member App</mj-text>
28+
</mj-column>
29+
</mj-section>
30+
<mj-section>
31+
<mj-column width="400px">
32+
<mj-text font-size="20px" line-height="1.3" color="#111" align="center">
33+
Verify this email address for your Makespace account
34+
</mj-text>
35+
<mj-button
36+
color="#111"
37+
background-color="#7FC436"
38+
href="${verificationLink}"
39+
font-weight="800"
40+
>
41+
Verify email
42+
</mj-button>
43+
</mj-column>
44+
</mj-section>
45+
</mj-body>
46+
</mjml>
47+
`).html,
48+
});
49+
50+
export const sendEmailVerification = (
51+
deps: Pick<Dependencies, 'sendEmail' | 'rateLimitSendingOfEmails'>,
52+
conf: Config
53+
) => (memberNumber: number, emailAddress: EmailAddress): TE.TaskEither<Failure, string> => {
54+
const email = toEmail(emailAddress)(
55+
emailVerificationLink.create(conf)({
56+
purpose: 'verify-email',
57+
memberNumber,
58+
emailAddress,
59+
})
60+
);
61+
return pipe(
62+
deps.rateLimitSendingOfEmails(email),
63+
TE.chain(deps.sendEmail),
64+
TE.map(() => `Sent email verification to ${emailAddress}`)
65+
);
66+
};

0 commit comments

Comments
 (0)