From 3a98d856a50f4013be066c270a1118cb37623553 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 14:26:47 -0500 Subject: [PATCH] Handle authentication with proxy The cookie will be set for the proxy domain so it'll work for all of its subdomains. --- src/node/app/proxy.ts | 60 ++++++++++++++++++++++--------------------- src/node/entry.ts | 18 ++++--------- src/node/http.ts | 32 +++++++++++++++++++++-- 3 files changed, 66 insertions(+), 44 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 757d0083..dd8f6bda 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -1,50 +1,52 @@ import * as http from "http" import { HttpCode, HttpError } from "../../common/http" -import { AuthType, HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" +import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http" /** * Proxy HTTP provider. */ -export class ProxyHttpProvider extends HttpProvider { - public constructor(options: HttpProviderOptions, private readonly proxyDomains: string[]) { +export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider { + public readonly proxyDomains: string[] + + public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) { super(options) + this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) } - public async handleRequest(route: Route): Promise { - if (this.options.auth !== AuthType.Password || route.requestPath !== "/index.html") { - throw new HttpError("Not found", HttpCode.NotFound) + public async handleRequest(route: Route, request: http.IncomingMessage): Promise { + if (!this.authenticated(request)) { + if (route.requestPath === "/index.html") { + return { redirect: "/login", query: { to: route.fullPath } } + } + throw new HttpError("Unauthorized", HttpCode.Unauthorized) } + const payload = this.proxy(route.base.replace(/^\//, "")) - if (!payload) { - throw new HttpError("Not found", HttpCode.NotFound) + if (payload) { + return payload } - return payload + + throw new HttpError("Not found", HttpCode.NotFound) } - public async getRoot(route: Route, error?: Error): Promise { - const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html") - response.content = response.content.replace(/{{ERROR}}/, error ? `
${error.message}
` : "") - return this.replaceTemplates(route, response) - } - - /** - * Return a response if the request should be proxied. Anything that ends in a - * proxy domain and has a subdomain should be proxied. The port is found in - * the top-most subdomain. - * - * For example, if the proxy domain is `coder.com` then `8080.coder.com` and - * `test.8080.coder.com` will both proxy to `8080` but `8080.test.coder.com` - * will have an error because `test` isn't a port. If the proxy domain was - * `test.coder.com` then it would work. - */ - public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { - const host = request.headers.host + public getProxyDomain(host?: string): string | undefined { if (!host || !this.proxyDomains) { return undefined } - const proxyDomain = this.proxyDomains.find((d) => host.endsWith(d)) - if (!proxyDomain) { + return this.proxyDomains.find((d) => host.endsWith(d)) + } + + public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { + // No proxy until we're authenticated. This will cause the login page to + // show as well as let our assets keep loading normally. + if (!this.authenticated(request)) { + return undefined + } + + const host = request.headers.host + const proxyDomain = this.getProxyDomain(host) + if (!host || !proxyDomain) { return undefined } diff --git a/src/node/entry.ts b/src/node/entry.ts index 4e667293..e4f59834 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -56,19 +56,11 @@ const main = async (args: Args): Promise => { throw new Error("--cert-key is missing") } - /** - * Domains can be in the form `coder.com` or `*.coder.com`. Either way, - * `[number].coder.com` will be proxied to `number`. - */ - const proxyDomains = args["proxy-domain"] - ? args["proxy-domain"].map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) - : [] - const httpServer = new HttpServer(options) const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) - const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, proxyDomains) + const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, args["proxy-domain"]) httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) @@ -102,11 +94,11 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } - if (proxyDomains.length === 1) { - logger.info(` - Proxying *.${proxyDomains[0]}`) - } else if (proxyDomains && proxyDomains.length > 1) { + if (proxy.proxyDomains.length === 1) { + logger.info(` - Proxying *.${proxy.proxyDomains[0]}`) + } else if (proxy.proxyDomains.length > 1) { logger.info(" - Proxying the following domains:") - proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) + proxy.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) } logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) diff --git a/src/node/http.ts b/src/node/http.ts index 49621693..4303ae02 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -395,7 +395,29 @@ export interface HttpProvider3 { } export interface HttpProxyProvider { + /** + * Return a response if the request should be proxied. Anything that ends in a + * proxy domain and has a subdomain should be proxied. The port is found in + * the top-most subdomain. + * + * For example, if the proxy domain is `coder.com` then `8080.coder.com` and + * `test.8080.coder.com` will both proxy to `8080` but `8080.test.coder.com` + * will have an error because `test` isn't a port. If the proxy domain was + * `test.coder.com` then it would work. + */ maybeProxy(request: http.IncomingMessage): HttpResponse | undefined + + /** + * Get the matching proxy domain based on the provided host. + */ + getProxyDomain(host: string): string | undefined + + /** + * Domains can be provided in the form `coder.com` or `*.coder.com`. Either + * way, `.coder.com` will be proxied to `number`. The domains are + * stored here without the `*.`. + */ + readonly proxyDomains: string[] } /** @@ -538,7 +560,13 @@ export class HttpServer { "Set-Cookie": [ `${payload.cookie.key}=${payload.cookie.value}`, `Path=${normalize(payload.cookie.path || "/", true)}`, - request.headers.host ? `Domain=${request.headers.host}` : undefined, + // Set the cookie against the host so it can be used in + // subdomains. Use a matching proxy domain if possible so + // requests to any of those subdomains will already be + // authenticated. + request.headers.host + ? `Domain=${(this.proxy && this.proxy.getProxyDomain(request.headers.host)) || request.headers.host}` + : undefined, // "HttpOnly", "SameSite=strict", ] @@ -566,8 +594,8 @@ export class HttpServer { try { const payload = - (this.proxy && this.proxy.maybeProxy(request)) || this.maybeRedirect(request, route) || + (this.proxy && this.proxy.maybeProxy(request)) || (await route.provider.handleRequest(route, request)) if (!payload) { throw new HttpError("Not found", HttpCode.NotFound)