diff --git a/package.json b/package.json index 45abeeda37..a36ead856e 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,5 @@ ], "sourceMap": true, "instrument": true - }, - "dependencies": {} + } } diff --git a/site/.env.example b/site/.env.example index c59f6f7272..4de2f6111a 100644 --- a/site/.env.example +++ b/site/.env.example @@ -5,8 +5,4 @@ BASEURL= DATABASE_URL= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -MAPBOX_ACCESS_TOKEN= - -JWT_EXP=30d -JWT_ALG=HS512 -JWT_KEY= +MAPBOX_ACCESS_TOKEN= \ No newline at end of file diff --git a/site/migrations/002-create-sessions.js b/site/migrations/002-create-sessions.js new file mode 100644 index 0000000000..c24fc69115 --- /dev/null +++ b/site/migrations/002-create-sessions.js @@ -0,0 +1,15 @@ +exports.up = DB => { + DB.sql(` + create table if not exists sessions ( + uid uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + user_id integer REFERENCES users(id) not null, + expiry timestamp without time zone DEFAULT now() + interval '1 year' + ); + `); +}; + +exports.down = DB => { + DB.sql(` + drop table if exists sessions; + `); +}; diff --git a/site/package-lock.json b/site/package-lock.json index 373c0505cb..a36dea4aeb 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1647,6 +1647,11 @@ "safe-buffer": "~5.1.1" } }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, "core-js": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz", @@ -1906,6 +1911,11 @@ "is-buffer": "~2.0.3" } }, + "flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", diff --git a/site/package.json b/site/package.json index 0b949f5da7..c9ecacbf0e 100644 --- a/site/package.json +++ b/site/package.json @@ -15,8 +15,10 @@ "dependencies": { "@polka/redirect": "^1.0.0-next.0", "@polka/send": "^1.0.0-next.3", + "cookie": "^0.4.0", "devalue": "^2.0.0", "do-not-zip": "^1.0.0", + "flru": "^1.0.2", "httpie": "^1.1.2", "jsonwebtoken": "^8.5.1", "marked": "^0.7.0", diff --git a/site/src/backend/auth.js b/site/src/backend/auth.js deleted file mode 100644 index 7792f4ee37..0000000000 --- a/site/src/backend/auth.js +++ /dev/null @@ -1,150 +0,0 @@ -import polka from 'polka'; -import devalue from 'devalue'; -import send from '@polka/send'; -import { get, post } from 'httpie'; -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='') { - const error = msg.charAt(0).toUpperCase() + msg.substring(1); - send(res, code, { error }); -} - -function onError(err, req, res) { - const error = err.message || err; - const code = err.code || err.status || 500; - res.headersSent || send(res, code, { error }); -} - -/** - * 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, avatar } = obj; - const token = sign({ uid, username }); - return { uid, username, name, avatar, token }; -} - -export function API() { - const app = polka({ onError }); - - if (GITHUB_CLIENT_ID) { - app.get('/auth/login', (req, res) => { - try { - 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) { - console.error('GET /auth/login', err); - send(res, 500); - } - }); - - 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, github_token) - values ($1, $2, $3, $4, $5) on conflict (uid) do update - set (name, username, avatar, github_token, updated_at) = ($2, $3, $4, $5, now()) - returning * - `, [id, name, login, avatar_url, access_token]); - - send(res, 200, ` - - `, { - 'Content-Type': 'text/html; charset=utf-8' - }); - } catch (err) { - console.error('GET /auth/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 and create a local .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.

-

See also here

- - `, { - 'Content-Type': 'text/html; charset=utf-8' - }); - }); - } - - return app; -} diff --git a/site/src/backend/token.js b/site/src/backend/token.js deleted file mode 100644 index 9adf7d24f4..0000000000 --- a/site/src/backend/token.js +++ /dev/null @@ -1,22 +0,0 @@ -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/routes/auth/_config.js b/site/src/routes/auth/_config.js new file mode 100644 index 0000000000..d155e56de8 --- /dev/null +++ b/site/src/routes/auth/_config.js @@ -0,0 +1,6 @@ +export const oauth = 'https://github.com/login/oauth'; +export const baseurl = process.env.BASEURL; +export const secure = baseurl.startsWith('https:'); + +export const client_id = process.env.GITHUB_CLIENT_ID; +export const client_secret = process.env.GITHUB_CLIENT_SECRET; \ No newline at end of file diff --git a/site/src/routes/auth/callback.js b/site/src/routes/auth/callback.js new file mode 100644 index 0000000000..7979b1c21f --- /dev/null +++ b/site/src/routes/auth/callback.js @@ -0,0 +1,54 @@ +import send from '@polka/send'; +import devalue from 'devalue'; +import * as cookie from 'cookie'; +import * as httpie from 'httpie'; +import { parse, stringify } from 'querystring'; +import { sanitize_user, create_user, create_session } from '../../utils/auth'; +import { oauth, secure, client_id, client_secret } from './_config.js'; + +export async function get(req, res) { + try { + // Trade "code" for "access_token" + const r1 = await httpie.post(`${oauth}/access_token?` + stringify({ + code: req.query.code, + client_id, + client_secret, + })); + + // Now fetch User details + const { access_token } = parse(r1.data); + const r2 = await httpie.get('https://api.github.com/user', { + headers: { + 'User-Agent': 'svelte.dev', + Authorization: `token ${access_token}` + } + }); + + const user = await create_user(r2.data, access_token); + const session = await create_session(user); + + res.writeHead(200, { + 'Set-Cookie': cookie.serialize('sid', session.uid, { + maxAge: 31536000, + path: '/', + httpOnly: true, + secure + }), + 'Content-Type': 'text/html; charset=utf-8' + }); + + res.end(` + + `); + } catch (err) { + console.error('GET /auth/callback', err); + send(res, 500, err.data, { + 'Content-Type': err.headers['content-type'], + 'Content-Length': err.headers['content-length'] + }); + } +} \ No newline at end of file diff --git a/site/src/routes/auth/login.js b/site/src/routes/auth/login.js new file mode 100644 index 0000000000..7240498418 --- /dev/null +++ b/site/src/routes/auth/login.js @@ -0,0 +1,27 @@ +import send from '@polka/send'; +import { stringify } from 'querystring'; +import { oauth, baseurl, client_id } from './_config.js'; + +export const get = client_id + ? (req, res) => { + const Location = `${oauth}/authorize?` + stringify({ + scope: 'read:user', + client_id, + redirect_uri: `${baseurl}/auth/callback`, + }); + + send(res, 302, Location, { Location }); + } + : (req, res) => { + send(res, 500, ` + +

Missing .env file

+

In order to use GitHub authentication, you will need to register an OAuth application and create a local .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.

+

See also here

+ + `, { + 'Content-Type': 'text/html; charset=utf-8' + }); + }; \ No newline at end of file diff --git a/site/src/routes/auth/logout.js b/site/src/routes/auth/logout.js new file mode 100644 index 0000000000..7e132f0a41 --- /dev/null +++ b/site/src/routes/auth/logout.js @@ -0,0 +1,17 @@ +import send from '@polka/send'; +import * as cookie from 'cookie'; +import { secure } from './_config.js'; +import { delete_session } from '../../utils/auth.js'; + +export async function get(req, res) { + await delete_session(req.cookies.sid); + + send(res, 200, '', { + 'Set-Cookie': cookie.serialize('sid', '', { + maxAge: -1, + path: '/', + httpOnly: true, + secure + }) + }); +} \ No newline at end of file diff --git a/site/src/routes/auth/me.json.js b/site/src/routes/auth/me.json.js deleted file mode 100644 index f77eaf5b94..0000000000 --- a/site/src/routes/auth/me.json.js +++ /dev/null @@ -1,8 +0,0 @@ -import send from '@polka/send'; -import { isUser, toUser } from '../../backend/auth'; - -export async function get(req, res) { - const user = await isUser(req, res); - res.setHeader('Cache-Control', 'private, no-cache, no-store'); - return send(res, 200, user ? toUser(user) : null); -} diff --git a/site/src/routes/repl/[id]/_components/AppControls/UserMenu.svelte b/site/src/routes/repl/[id]/_components/AppControls/UserMenu.svelte index 1404c1d5fc..9b54e80f92 100644 --- a/site/src/routes/repl/[id]/_components/AppControls/UserMenu.svelte +++ b/site/src/routes/repl/[id]/_components/AppControls/UserMenu.svelte @@ -1,15 +1,24 @@
{name} - {name} avatar + {name} avatar {#if showMenu}