From f22c22c6b39123b755232a98fca80caf1d977238 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 25 Apr 2019 21:27:52 -0700 Subject: [PATCH] feat: handle GitHub OAuth directly, thru JWTs --- site/.env.example | 4 + site/package.json | 3 + site/src/backend/auth.js | 149 ++++++++++++++++++++++++++++++++++++++ site/src/backend/token.js | 22 ++++++ site/src/server.js | 145 ++++++++----------------------------- 5 files changed, 208 insertions(+), 115 deletions(-) create mode 100644 site/src/backend/auth.js create mode 100644 site/src/backend/token.js diff --git a/site/.env.example b/site/.env.example index b587a81f72..c59f6f7272 100644 --- a/site/.env.example +++ b/site/.env.example @@ -6,3 +6,7 @@ DATABASE_URL= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= MAPBOX_ACCESS_TOKEN= + +JWT_EXP=30d +JWT_ALG=HS512 +JWT_KEY= diff --git a/site/package.json b/site/package.json index 689ef58b21..7ca063c3bc 100644 --- a/site/package.json +++ b/site/package.json @@ -15,10 +15,13 @@ "testsrc": "mocha -r esm test/**" }, "dependencies": { + "@polka/parse": "^1.0.0-next.1", "@polka/send": "^1.0.0-next.2", "devalue": "^1.1.0", "do-not-zip": "^1.0.0", "golden-fleece": "^1.0.9", + "httpie": "^1.1.1", + "jsonwebtoken": "^8.5.1", "limax": "^1.7.0", "marked": "^0.6.1", "pg": "^7.10.0", diff --git a/site/src/backend/auth.js b/site/src/backend/auth.js new file mode 100644 index 0000000000..787cd66b4b --- /dev/null +++ b/site/src/backend/auth.js @@ -0,0 +1,149 @@ +import polka from 'polka'; +import devalue from 'devalue'; +import send from '@polka/send'; +import { get, post } from 'httpie'; +import { json } from '@polka/parse'; +import { parse, stringify } from 'querystring'; +import { decode, sign, verify } from './token'; +import { find, query } from '../utils/db'; + +const { + BASEURL, + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, +} = process.env; + +const OAuth = 'https://github.com/login/oauth'; + +function exit(res, code, msg='') { + send(res, code, msg.charAt(0).toUpperCase() + msg.substring(1)); +} + +function onError(err, req, res) { + const code = err.code || err.status || 500; + res.headersSent || send(res, code, err.message || err); +} + +/** + * Middleware to determine User validity + */ +export async function isUser(req, res) { + const abort = exit.bind(null, res, 401); + + const auth = req.headers.authorization; + if (!auth) return abort('Missing Authorization header'); + + const [scheme, token] = auth.split(' '); + if (scheme !== 'Bearer' || !token) return abort('Invalid Authorization format'); + + let data; + const decoded = decode(token, { complete:true }); + if (!decoded || !decoded.header) return abort('Invalid token'); + + try { + data = await verify(token); + } catch (err) { + return abort(err.message); + } + + const { uid, username } = data; + if (!uid || !username) return abort('Invalid token payload'); + + try { + const row = await find(`select * from users where uid = $1 and username = $2 limit 1`, [uid, username]); + return row || abort('Invalid token'); + } catch (err) { + console.error('Auth.isUser', err); + return send(res, 500, 'Unknown error occurred'); + } +} + +export function toUser(obj={}) { + const { uid, username, name:displayName, avatar } = obj; + const token = sign({ uid, username }); + return { uid, username, displayName, avatar, token }; +} + +export function API() { + const app = polka({ onError }).use(json()); + + if (GITHUB_CLIENT_ID) { + app.get('/auth/login', (req, res) => { + try { + console.log('inside'); + + const Location = `${OAuth}/authorize?` + stringify({ + scope: 'read:user', + client_id: GITHUB_CLIENT_ID, + redirect_uri: `${BASEURL}/auth/callback`, + }); + + send(res, 302, Location, { Location }); + } catch (err) { + // + } + }); + + app.get('/auth/callback', async (req, res) => { + try { + // Trade "code" for "access_token" + const r1 = await post(`${OAuth}/access_token?` + stringify({ + code: req.query.code, + client_id: GITHUB_CLIENT_ID, + client_secret: GITHUB_CLIENT_SECRET, + })); + + // Now fetch User details + const { access_token } = parse(r1.data); + const r2 = await get('https://api.github.com/user', { + headers: { + 'User-Agent': 'svelte.dev', + Authorization: `token ${access_token}` + } + }); + + const { id, name, avatar_url, login } = r2.data; + + // Upsert `users` table + const [user] = await query(` + insert into users(uid, name, username, avatar, token) + values ($1, $2, $3, $4, $5) on conflict (uid) do update + set (name, username, avatar, token) = ($2, $3, $4, $5) + returning * + `, [id, name, login, avatar_url, access_token]); + + send(res, 200, ` + + `, { + 'Content-Type': 'text/html; charset=utf-8' + }); + } catch (err) { + console.error('OAuth Callback', err); + send(res, 500, err.data, { + 'Content-Type': err.headers['content-type'], + 'Content-Length': err.headers['content-length'] + }); + } + }); + } else { + // Print "Misconfigured" error + app.get('/auth/login', (req, res) => { + send(res, 500, ` + +

Missing .env file

+

In order to use GitHub authentication, you will need to register an OAuth application with gist and read:user scopes, and create a .env file:

+
GITHUB_CLIENT_ID=[YOUR_APP_ID]\nGITHUB_CLIENT_SECRET=[YOUR_APP_SECRET]\nBASEURL=http://localhost:3000
+

The BASEURL variable should match the callback URL specified for your app.

+ + `, { + 'Content-Type': 'text/html; charset=utf-8' + }); + }); + } + + return app; +} diff --git a/site/src/backend/token.js b/site/src/backend/token.js new file mode 100644 index 0000000000..9adf7d24f4 --- /dev/null +++ b/site/src/backend/token.js @@ -0,0 +1,22 @@ +import * as jwt from 'jsonwebtoken'; + +const { JWT_KEY, JWT_ALG, JWT_EXP } = process.env; + +const CONFIG = { + expiresIn: JWT_EXP, + issuer: 'https://svelte.dev', + audience: 'https://svelte.dev', + algorithm: JWT_ALG, +}; + +export const decode = jwt.decode; + +export function sign(obj, opts, cb) { + opts = Object.assign({}, opts, CONFIG); + return jwt.sign(obj, JWT_KEY, opts, cb); +} + +export function verify(str, opts, cb) { + opts = Object.assign({}, opts, CONFIG); + return jwt.verify(str, JWT_KEY, opts, cb); +} diff --git a/site/src/server.js b/site/src/server.js index 3413e218d8..41d814a359 100644 --- a/site/src/server.js +++ b/site/src/server.js @@ -1,118 +1,33 @@ -import 'dotenv/config'; import sirv from 'sirv'; -import polka from 'polka'; -import devalue from 'devalue'; -import session from 'express-session'; -import passport from 'passport'; -import { Strategy } from 'passport-github'; -import sessionFileStore from 'session-file-store'; import * as sapper from '@sapper/server'; - -const app = polka(); - -if (process.env.GITHUB_CLIENT_ID) { - const FileStore = sessionFileStore(session); - - passport.use(new Strategy({ - clientID: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: `${process.env.BASEURL}/auth/callback`, - userAgent: 'svelte.dev' - }, (accessToken, refreshToken, profile, callback) => { - return callback(null, { - token: accessToken, - id: profile.id, - username: profile.username, - displayName: profile.displayName, - photo: profile.photos && profile.photos[0] && profile.photos[0].value - }); - })); - - passport.serializeUser((user, cb) => { - cb(null, user); - }); - - passport.deserializeUser((obj, cb) => { - cb(null, obj); - }); - - app - .use(session({ - secret: 'svelte', - resave: true, - saveUninitialized: true, - cookie: { - maxAge: 31536000 - }, - store: new FileStore({ - path: process.env.NOW ? `/tmp/sessions` : `.sessions` - }) - })) - - .use(passport.initialize()) - .use(passport.session()) - - .get('/auth/login', (req, res, next) => { - const { returnTo } = req.query; - req.session.returnTo = returnTo ? decodeURIComponent(returnTo) : '/'; - next(); - }, passport.authenticate('github', { scope: ['gist', 'read:user'] })) - - .post('/auth/logout', (req, res) => { - req.logout(); - res.end('ok'); +import { API } from './backend/auth'; + +const { PORT=3000 } = process.env; + +API() + .use( + sirv('static', { + setHeaders(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.hasHeader('Cache-Control') || res.setHeader('Cache-Control', 'max-age=600'); // 10min default + } + }), + + sapper.middleware({ + // TODO update Sapper so that we can pass props to the client + props: req => { + const user = req.user; + + return { + user: user && { + // strip access token + id: user.id, + username: user.username, + displayName: user.displayName, + photo: user.photo + } + }; + } }) - - .get('/auth/callback', passport.authenticate('github', { failureRedirect: '/auth/error' }), (req, res) => { - const { id, username, displayName, photo } = req.session.passport && req.session.passport.user; - - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - - res.end(` - - `); - }); -} else { - app.get('/auth/login', (req, res) => { - res.writeHead(500); - res.end(` - -

Missing .env file

-

In order to use GitHub authentication, you will need to register an OAuth application with gist and read:user scopes, and create a .env file:

- -
GITHUB_CLIENT_ID=[YOUR_APP_ID]\nGITHUB_CLIENT_SECRET=[YOUR_APP_SECRET]\nBASEURL=http://localhost:3000
- -

The BASEURL variable should match the callback URL specified for your app.

- - `); - }); -} - -app.use( - sirv('static', { - setHeaders(res) { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.hasHeader('Cache-Control') || res.setHeader('Cache-Control', 'max-age=600'); // 10min default - } - }), - sapper.middleware({ - // TODO update Sapper so that we can pass props to the client - props: req => { - const user = req.session && req.session.passport && req.session.passport.user; - - return { - user: user && { - // strip access token - id: user.id, - username: user.username, - displayName: user.displayName, - photo: user.photo - } - }; - } - }) -).listen(process.env.PORT); + ) + .listen(PORT);