diff --git a/package-lock.json b/package-lock.json index c84f5146a0..c7d7a3ea0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -535,6 +535,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-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -1301,6 +1306,11 @@ "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==", "dev": true }, + "flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==" + }, "foreground-child": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", diff --git a/package.json b/package.json index 45abeeda37..a8570f5e47 100644 --- a/package.json +++ b/package.json @@ -99,5 +99,8 @@ "sourceMap": true, "instrument": true }, - "dependencies": {} + "dependencies": { + "cookie": "^0.4.0", + "flru": "^1.0.2" + } } 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..f80a81e9d9 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -3840,6 +3840,11 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/site/package.json b/site/package.json index 0b949f5da7..3b98cbb858 100644 --- a/site/package.json +++ b/site/package.json @@ -24,6 +24,7 @@ "polka": "^1.0.0-next.4", "prismjs": "^1.17.1", "sirv": "^0.4.2", + "uuid": "^3.3.2", "yootils": "0.0.16" }, "devDependencies": { 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 0cedfff91a..437c0db420 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}