diff --git a/bin/server b/bin/server index a6bba9d8..7ebec06d 100755 --- a/bin/server +++ b/bin/server @@ -6,7 +6,7 @@ import optimist from 'optimist'; import log from 'book'; import Debug from 'debug'; -import CreateServer from '../server'; +import CreateServer from '../server.js'; const debug = Debug('localtunnel'); diff --git a/lib/Client.test.js b/lib/Client.test.js index 5a0afd03..6f88b59c 100644 --- a/lib/Client.test.js +++ b/lib/Client.test.js @@ -4,7 +4,7 @@ import { Duplex } from 'stream'; import WebSocket from 'ws'; import net from 'net'; -import Client from './Client'; +import Client from './Client.js'; class DummySocket extends Duplex { constructor(options) { diff --git a/lib/ClientManager.js b/lib/ClientManager.js index e1a78386..a87f79ad 100644 --- a/lib/ClientManager.js +++ b/lib/ClientManager.js @@ -1,8 +1,8 @@ import { hri } from 'human-readable-ids'; import Debug from 'debug'; -import Client from './Client'; -import TunnelAgent from './TunnelAgent'; +import Client from './Client.js'; +import TunnelAgent from './TunnelAgent.js'; // Manage sets of clients // diff --git a/lib/ClientManager.test.js b/lib/ClientManager.test.js index d85abe55..33d3cf15 100644 --- a/lib/ClientManager.test.js +++ b/lib/ClientManager.test.js @@ -1,7 +1,7 @@ import assert from 'assert'; import net from 'net'; -import ClientManager from './ClientManager'; +import ClientManager from './ClientManager.js'; describe('ClientManager', () => { it('should construct with no tunnels', () => { diff --git a/lib/TunnelAgent.test.js b/lib/TunnelAgent.test.js index 9505f35f..fc19e89f 100644 --- a/lib/TunnelAgent.test.js +++ b/lib/TunnelAgent.test.js @@ -2,7 +2,7 @@ import http from 'http'; import net from 'net'; import assert from 'assert'; -import TunnelAgent from './TunnelAgent'; +import TunnelAgent from './TunnelAgent.js'; describe('TunnelAgent', () => { it('should create an empty agent', async () => { diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 00000000..5656402f --- /dev/null +++ b/lib/auth.js @@ -0,0 +1,37 @@ +import { initializeApp, applicationDefault, cert } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; +import crypto from 'crypto'; +import { getDb } from './db.js'; + +let firebaseAuth; +if (process.env.FIREBASE_SERVICE_ACCOUNT) { + const config = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT); + initializeApp({ credential: cert(config) }); + firebaseAuth = getAuth(); +} else if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + initializeApp({ credential: applicationDefault() }); + firebaseAuth = getAuth(); +} + +export async function loginWithFirebase(idToken) { + if (!firebaseAuth) { + throw new Error('Firebase not configured'); + } + const decoded = await firebaseAuth.verifyIdToken(idToken); + const db = await getDb(); + if (!db) { + throw new Error('MongoDB not configured'); + } + const token = crypto.randomBytes(20).toString('hex'); + await db.collection('tokens').insertOne({ uid: decoded.uid, token }); + return token; +} + +export async function verifyTunnelToken(token) { + const db = await getDb(); + if (!db) { + return true; + } + const entry = await db.collection('tokens').findOne({ token }); + return !!entry; +} diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 00000000..981ed0a5 --- /dev/null +++ b/lib/db.js @@ -0,0 +1,15 @@ +import { MongoClient } from 'mongodb'; + +let client; + +export async function getDb() { + const uri = process.env.MONGODB_URI; + if (!uri) { + return null; + } + if (!client) { + client = new MongoClient(uri); + await client.connect(); + } + return client.db(); +} diff --git a/package.json b/package.json index 0693b8dd..30cdd81c 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,18 @@ "type": "git", "url": "git://github.com/localtunnel/server.git" }, + "type": "module", "dependencies": { "book": "1.3.3", "debug": "3.1.0", "esm": "3.0.34", + "firebase-admin": "^11.11.1", "human-readable-ids": "1.0.3", "koa": "2.5.1", + "koa-bodyparser": "^4.4.1", "koa-router": "7.4.0", "localenv": "0.2.2", + "mongodb": "^5.7.0", "optimist": "0.6.1", "pump": "3.0.0", "tldjs": "2.3.1" @@ -27,7 +31,7 @@ "ws": "5.1.1" }, "scripts": { - "test": "mocha --check-leaks --require esm './**/*.test.js'", + "test": "mocha --check-leaks --require esm 'server.test.js' 'lib/*.test.js'", "start": "./bin/server", "dev": "node-dev bin/server --port 3000" } diff --git a/server.js b/server.js index 21a4af69..91751a31 100644 --- a/server.js +++ b/server.js @@ -5,8 +5,11 @@ import Debug from 'debug'; import http from 'http'; import { hri } from 'human-readable-ids'; import Router from 'koa-router'; +import bodyParser from 'koa-bodyparser'; -import ClientManager from './lib/ClientManager'; +import { loginWithFirebase, verifyTunnelToken } from './lib/auth.js'; + +import ClientManager from './lib/ClientManager.js'; const debug = Debug('localtunnel:server'); @@ -27,6 +30,7 @@ export default function(opt) { const app = new Koa(); const router = new Router(); + app.use(bodyParser()); router.get('/api/status', async (ctx, next) => { const stats = manager.stats; @@ -36,6 +40,21 @@ export default function(opt) { }; }); + router.post('/api/login', async (ctx) => { + const idToken = ctx.request.body && ctx.request.body.idToken; + if (!idToken) { + ctx.throw(400, 'idToken required'); + return; + } + try { + const token = await loginWithFirebase(idToken); + ctx.body = { token }; + } catch (err) { + ctx.status = 401; + ctx.body = { message: err.message }; + } + }); + router.get('/api/tunnels/:id/status', async (ctx, next) => { const clientId = ctx.params.id; const client = manager.getClient(clientId); @@ -65,6 +84,14 @@ export default function(opt) { const isNewClientRequest = ctx.query['new'] !== undefined; if (isNewClientRequest) { + const authToken = ctx.headers['authorization'] && ctx.headers['authorization'].startsWith('Bearer ') + ? ctx.headers['authorization'].slice(7) + : ctx.query['token']; + const valid = await verifyTunnelToken(authToken); + if (!valid) { + ctx.throw(401, 'invalid token'); + return; + } const reqId = hri.random(); debug('making new client with id %s', reqId); const info = await manager.newClient(reqId); @@ -92,6 +119,15 @@ export default function(opt) { return; } + const authToken = ctx.headers['authorization'] && ctx.headers['authorization'].startsWith('Bearer ') + ? ctx.headers['authorization'].slice(7) + : ctx.query['token']; + const validToken = await verifyTunnelToken(authToken); + if (!validToken) { + ctx.throw(401, 'invalid token'); + return; + } + const reqId = parts[1]; // limit requested hostnames to 63 characters diff --git a/server.test.js b/server.test.js index 10bf8bcf..1d754c11 100644 --- a/server.test.js +++ b/server.test.js @@ -1,10 +1,10 @@ import request from 'supertest'; import assert from 'assert'; -import { Server as WebSocketServer } from 'ws'; import WebSocket from 'ws'; +const WebSocketServer = WebSocket.Server; import net from 'net'; -import createServer from './server'; +import createServer from './server.js'; describe('Server', () => { it('server starts and stops', async () => {