-
Notifications
You must be signed in to change notification settings - Fork 3
/
federated-auth.ts
160 lines (135 loc) · 6.58 KB
/
federated-auth.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import sqlite3 from 'better-sqlite3';
import KnexSession from 'connect-session-knex';
import {randomBytes} from 'crypto';
import * as express from 'express';
import {Express, NextFunction, Request, RequestHandler, Response} from 'express';
import Knex, {Knex as KnexType} from 'knex';
import passport from 'passport';
import GitHubStrategy from 'passport-github';
import {Strategy as BearerStrategy} from 'passport-http-bearer';
import {promisify} from 'util';
import * as Table from './DbTablesV3';
import {Env, FullRow, Selected, tokenPath, tokensPath, uniqueConstraintError} from './pathsInterfaces';
type Db = ReturnType<typeof sqlite3>;
const BEARER_NAME = 'bearerStrategy';
const randomBytesP = promisify(randomBytes);
function findOrCreateGithub(db: Db, profile: GitHubStrategy.Profile, allowlist: '*'|Set<string>): undefined|
FullRow<Table.userRow> {
if (typeof allowlist === 'object') {
if (!allowlist.has(profile.id)) { return undefined; }
}
const githubId = typeof profile.id === 'number' ? profile.id : parseInt(profile.id);
const row: Selected<Table.userRow> =
db.prepare<{githubId: number}>(`select * from user where githubId=$githubId`).get({githubId});
if (row) { return row; }
const user: Table.userRow = {displayName: profile.username || profile.displayName || '', githubId};
const res =
db.prepare<Table.userRow>(`insert into user (displayName, githubId) values ($displayName, $githubId)`).run(user);
if (res.changes > 0) { return {...user, id: res.lastInsertRowid}; }
return undefined;
}
function findToken(db: Db, token: string) {
const res: {userId: number|bigint} =
db.prepare<{token: string}>(`select userId from token where token=$token`).get({token});
if (res && 'userId' in res) { return getUser(db, res.userId); }
return undefined;
}
function getUser(db: Db, serialized: number|bigint|string): Selected<Table.userRow> {
return db.prepare<{id: number | bigint | string}>(`select * from user where id=$id`).get({id: serialized});
}
export function reqToUser(req: express.Request): FullRow<Table.userRow> {
if (!req.user || !('id' in req.user)) { throw new Error('unauthenticated should not reach here'); }
return req.user;
}
export function passportSetup(db: Db, app: Express, sessionFilename: string): {knex: KnexType} {
const decoded = Env.decode(require('dotenv').config()?.parsed);
if (decoded._tag === 'Left') { throw new Error('.env failed to decode'); }
const env = decoded.right;
const githubAllowlist =
(env.GITHUB_ID_ALLOWLIST === '*') ? '*' as const: new Set(env.GITHUB_ID_ALLOWLIST.split(',').map(s => s.trim()));
passport.use(new GitHubStrategy(
{
clientID: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/github/callback`,
},
// This function converts the GitHub profile into our app's object representing the user
(accessToken, refreshToken, profile, cb) => cb(null, findOrCreateGithub(db, profile, githubAllowlist))));
// Tell Passport we want to use Bearer (API token) auth, and *name* this strategy: we'll use this name below
passport.use(BEARER_NAME, new BearerStrategy((token, cb) => cb(null, findToken(db, token))));
// Serialize an IUser into something we'll store in the user's session (very tiny)
passport.serializeUser(function(user: any|FullRow<Table.userRow>, cb) { cb(null, user.id); });
// Take the data we stored in the session (`id`) and resurrect the full IUser object
passport.deserializeUser(function(obj: number|string, cb) { cb(null, getUser(db, obj)); });
const knex = Knex({client: "sqlite3", useNullAsDefault: true, connection: {filename: sessionFilename}});
const store = new (KnexSession(require('express-session')))({knex});
app.use(require('express-session')({
cookie: process.env.NODE_ENV === 'development' ? {secure: false, sameSite: 'lax'}
: {secure: true, sameSite: 'none'},
secret: env.SESSION_SECRET,
resave: false, // not needed since Knex store implements `touch`
maxAge: 1e3 * 3600 * 24 * 365 * 2,
rolling: true,
saveUninitialized: true,
store,
}));
// FIRST init express' session (above), THEN passport's (below)
app.use(passport.initialize());
app.use(passport.session());
// All done with passport shenanigans. Set up some routes.
app.get('/auth/github', ensureUnauthenticated, passport.authenticate('github'));
app.get('/auth/github/callback', ensureUnauthenticated, passport.authenticate('github', {failureRedirect: '/'}),
(req, res) => res.redirect('/welcomeback'));
app.get('/logout', (req, res) => {
req.logout();
res.redirect('/');
});
app.get('/loginstatus', ensureAuthenticated, (req, res) => {
// console.log(req.user);
res.send(`You're logged in! <a href="/">Go back</a>`);
});
// tokens
// get a new token
app.get(tokenPath.pattern, ensureAuthenticated, async (req, res) => {
const userId = reqToUser(req).id;
if (!userId) { throw new Error('should be authenticated'); }
const statement = db.prepare<Table.tokenRow>(
`insert into token (userId, description, token) values ($userId, $description, $token)`);
let rowsInserted = 0;
let token = '';
while (rowsInserted === 0) {
// chances of random collissions with 20+ bytes are infinitessimal but let's be safe
token = (await randomBytesP(20)).toString('base64url');
try {
const res = statement.run({userId, description: '', token});
rowsInserted += res.changes;
} catch (e) {
if (!uniqueConstraintError(e)) { throw e; }
}
}
res.json({token}).send();
});
// delete ALL tokens
app.delete(tokensPath.pattern, ensureAuthenticated, (req, res) => {
const userId = reqToUser(req).id;
db.prepare<{userId: number | bigint}>(`delete from token where userId=$userId`).run({userId});
res.status(200).send();
});
return {knex};
}
// The name "bearer" here matches the name we gave the strategy above. See
// https://dsackerman.com/passportjs-using-multiple-strategies-on-the-same-endpoint/
const bearerAuthentication = passport.authenticate(BEARER_NAME, {session: false});
export function ensureAuthenticated<P>(req: Request<P>, res: Response, next: NextFunction) {
// check session (i.e., GitHub, etc.)
if (req.isAuthenticated && req.isAuthenticated()) {
next();
} else {
bearerAuthentication(req, res, next);
}
};
// Via @jaredhanson: https://gist.github.com/joshbirk/1732068#gistcomment-80892
export const ensureUnauthenticated: RequestHandler = (req, res, next) => {
if (req.isAuthenticated()) { return res.redirect('/'); }
next();
};