diff --git a/src/node/cli.ts b/src/node/cli.ts index a2fac4180beb..e655c91c25b7 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -14,6 +14,7 @@ export enum Feature { export enum AuthType { Password = "password", None = "none", + Custom = "custom", } export class Optional { @@ -35,6 +36,7 @@ export interface Args extends VsArgs { auth?: AuthType password?: string "hashed-password"?: string + "custom-auth-module"?: string cert?: OptionalString "cert-host"?: string "cert-key"?: string @@ -117,6 +119,10 @@ const options: Options> = { "The password hashed with argon2 for password authentication (can only be passed in via $HASHED_PASSWORD or the config file). \n" + "Takes precedence over 'password'.", }, + "custom-auth-module": { + type: "string", + description: "Path to a node module containing custom authentication code", + }, cert: { type: OptionalString, path: true, diff --git a/src/node/customAuth.ts b/src/node/customAuth.ts new file mode 100644 index 000000000000..94ed121020f7 --- /dev/null +++ b/src/node/customAuth.ts @@ -0,0 +1,19 @@ +import { CodeServerCustomAuth } from "../../typings/customauth" + +let customAuth: CodeServerCustomAuth | undefined = undefined + +/** + * Set the custom authentication module to use. + * Only one such module can be used at a time. + */ +export async function registerCustomAuth(auth: CodeServerCustomAuth) { + await auth.initialize() + customAuth = auth +} + +/** + * Get the last registered custom authentication module. + */ +export function getCustomAuth(): CodeServerCustomAuth | undefined { + return customAuth +} diff --git a/src/node/http.ts b/src/node/http.ts index d7ffa1f144d4..f6cb41f7cd70 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -6,6 +6,7 @@ import { HttpCode, HttpError } from "../common/http" import { normalize, Options } from "../common/util" import { AuthType, DefaultedArgs } from "./cli" import { commit, rootPath } from "./constants" +import { getCustomAuth } from "./customAuth" import { Heart } from "./heart" import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml } from "./util" @@ -46,10 +47,10 @@ export const replaceTemplates = ( */ export const ensureAuthenticated = async ( req: express.Request, - _?: express.Response, + res: express.Response, next?: express.NextFunction, ): Promise => { - const isAuthenticated = await authenticated(req) + const isAuthenticated = await authenticated(req, res) if (!isAuthenticated) { throw new HttpError("Unauthorized", HttpCode.Unauthorized) } @@ -61,7 +62,7 @@ export const ensureAuthenticated = async ( /** * Return true if authenticated via cookies. */ -export const authenticated = async (req: express.Request): Promise => { +export const authenticated = async (req: express.Request, res: express.Response): Promise => { switch (req.args.auth) { case AuthType.None: { return true @@ -79,6 +80,9 @@ export const authenticated = async (req: express.Request): Promise => { return await isCookieValid(isCookieValidArgs) } + case AuthType.Custom: { + return (await getCustomAuth()?.authenticated(req, res)) ?? false + } default: { throw new Error(`Unsupported auth type ${req.args.auth}`) } diff --git a/src/node/main.ts b/src/node/main.ts index e00364135f50..b31faada45e8 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -8,6 +8,7 @@ import { createApp, ensureAddress } from "./app" import { AuthType, DefaultedArgs, Feature } from "./cli" import { coderCloudBind } from "./coder_cloud" import { commit, version } from "./constants" +import { registerCustomAuth } from "./customAuth" import { register } from "./routes" import { humanPath, isFile, open } from "./util" @@ -94,6 +95,18 @@ export const runCodeServer = async (args: DefaultedArgs): Promise = ) } + const customAuthModulePath = args["custom-auth-module"] + if (args.auth === AuthType.Custom && !customAuthModulePath) { + throw new Error("Please pass in a custom-auth-module when using custom authentication") + } + if (args.auth === AuthType.Custom && customAuthModulePath) { + const customAuthModule = require(customAuthModulePath) + if (!customAuthModule.customAuth) { + throw new Error("The passed in custom-auth-module must export a 'customAuth' property") + } + await registerCustomAuth(customAuthModule.customAuth) + } + const [app, wsApp, server] = await createApp(args) const serverAddress = ensureAddress(server) await register(app, wsApp, server, args) @@ -109,6 +122,9 @@ export const runCodeServer = async (args: DefaultedArgs): Promise = } else { logger.info(` - Using password from ${humanPath(args.config)}`) } + } else if (args.auth === AuthType.Custom) { + logger.info(" - Authentication is enabled") + logger.info(` - using custom authentication module at ${customAuthModulePath ?? ""}`) } else { logger.info(` - Authentication is disabled ${args.link ? "(disabled by --link)" : ""}`) } diff --git a/src/node/routes/domainProxy.ts b/src/node/routes/domainProxy.ts index 56b0ea1bb37f..bb7695739f8e 100644 --- a/src/node/routes/domainProxy.ts +++ b/src/node/routes/domainProxy.ts @@ -39,7 +39,7 @@ router.all("*", async (req, res, next) => { } // Must be authenticated to use the proxy. - const isAuthenticated = await authenticated(req) + const isAuthenticated = await authenticated(req, res) if (!isAuthenticated) { // Let the assets through since they're used on the login page. if (req.path.startsWith("/static/") && req.method === "GET") { @@ -74,14 +74,14 @@ router.all("*", async (req, res, next) => { export const wsRouter = WsRouter() -wsRouter.ws("*", async (req, _, next) => { +wsRouter.ws("*", async (req, res, next) => { const port = maybeProxy(req) if (!port) { return next() } // Must be authenticated to use the proxy. - await ensureAuthenticated(req) + await ensureAuthenticated(req, res) proxy.ws(req, req.ws, req.head, { ignorePath: true, diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 42edbe117a24..4462247fde26 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -11,6 +11,7 @@ import { HttpCode, HttpError } from "../../common/http" import { plural } from "../../common/util" import { AuthType, DefaultedArgs } from "../cli" import { rootPath } from "../constants" +import { getCustomAuth } from "../customAuth" import { Heart } from "../heart" import { ensureAuthenticated, redirect, replaceTemplates } from "../http" import { PluginAPI } from "../plugin" @@ -98,8 +99,8 @@ export const register = async ( app.all("/proxy/(:port)(/*)?", (req, res) => { pathProxy.proxy(req, res) }) - wsApp.get("/proxy/(:port)(/*)?", async (req) => { - await pathProxy.wsProxy(req as pluginapi.WebsocketRequest) + wsApp.get("/proxy/(:port)(/*)?", async (req, res) => { + await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, res) }) // These two routes pass through the path directly. // So the proxied app must be aware it is running @@ -109,8 +110,8 @@ export const register = async ( passthroughPath: true, }) }) - wsApp.get("/absproxy/(:port)(/*)?", async (req) => { - await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, { + wsApp.get("/absproxy/(:port)(/*)?", async (req, res) => { + await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, res, { passthroughPath: true, }) }) @@ -138,6 +139,12 @@ export const register = async ( if (args.auth === AuthType.Password) { app.use("/login", login.router) app.use("/logout", logout.router) + } else if (args.auth === AuthType.Custom) { + const customAuth = getCustomAuth() + if (customAuth) { + app.use("/login", customAuth.loginRouter) + app.use("/logout", customAuth.logoutRouter) + } } else { app.all("/login", (req, res) => redirect(req, res, "/", {})) app.all("/logout", (req, res) => redirect(req, res, "/", {})) diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index 999b8dfaf5b9..e7316b7f876b 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -51,7 +51,7 @@ export const router = Router() router.use(async (req, res, next) => { const to = (typeof req.query.to === "string" && req.query.to) || "/" - if (await authenticated(req)) { + if (await authenticated(req, res)) { return redirect(req, res, to, { to: undefined }) } next() diff --git a/src/node/routes/pathProxy.ts b/src/node/routes/pathProxy.ts index e32001743e19..d3e71d07b1a2 100644 --- a/src/node/routes/pathProxy.ts +++ b/src/node/routes/pathProxy.ts @@ -22,7 +22,7 @@ export function proxy( passthroughPath?: boolean }, ): void { - if (!authenticated(req)) { + if (!authenticated(req, res)) { // If visiting the root (/:port only) redirect to the login page. if (!req.params[0] || req.params[0] === "/") { const to = normalize(`${req.baseUrl}${req.path}`) @@ -47,11 +47,12 @@ export function proxy( export async function wsProxy( req: pluginapi.WebsocketRequest, + res: Response, opts?: { passthroughPath?: boolean }, ): Promise { - await ensureAuthenticated(req) + await ensureAuthenticated(req, res) _proxy.ws(req, req.ws, req.head, { ignorePath: true, target: getProxyTarget(req, opts?.passthroughPath), diff --git a/src/node/routes/static.ts b/src/node/routes/static.ts index 29a1ad3bc7ed..1c2bd6ca5303 100644 --- a/src/node/routes/static.ts +++ b/src/node/routes/static.ts @@ -18,7 +18,7 @@ router.get("/(:commit)(/*)?", async (req, res) => { // Used by VS Code to load extensions into the web worker. const tar = getFirstString(req.query.tar) if (tar) { - await ensureAuthenticated(req) + await ensureAuthenticated(req, res) let stream: Readable = tarFs.pack(pathToFsPath(tar)) if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) { logger.debug("gzipping tar", field("path", tar)) @@ -43,8 +43,7 @@ router.get("/(:commit)(/*)?", async (req, res) => { // Make sure it's in code-server if you aren't authenticated. This lets // unauthenticated users load the login assets. - const isAuthenticated = await authenticated(req) - if (!resourcePath.startsWith(rootPath) && !isAuthenticated) { + if (!resourcePath.startsWith(rootPath) && !(await authenticated(req, res))) { throw new HttpError("Unauthorized", HttpCode.Unauthorized) } diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index 160f5a432d8b..318c57bddc8b 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -19,7 +19,7 @@ export const router = Router() const vscode = new VscodeProvider() router.get("/", async (req, res) => { - const isAuthenticated = await authenticated(req) + const isAuthenticated = await authenticated(req, res) if (!isAuthenticated) { return redirect(req, res, "login", { // req.baseUrl can be blank if already at the root. @@ -198,7 +198,7 @@ router.get("/fetch-callback", ensureAuthenticated, async (req, res) => { export const wsRouter = WsRouter() -wsRouter.ws("/", ensureAuthenticated, async (req) => { +wsRouter.ws("/", ensureAuthenticated, async (req, res) => { const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" const reply = crypto .createHash("sha1") @@ -211,6 +211,11 @@ wsRouter.ws("/", ensureAuthenticated, async (req) => { "Connection: Upgrade", `Sec-WebSocket-Accept: ${reply}`, ] + // if any cookie was set by a middleware, add it to the response + const setCookie = res.get("set-cookie") + if (setCookie) { + responseHeaders.push(`Set-Cookie: ${setCookie}`) + } // See if the browser reports it supports web socket compression. // TODO: Parse this header properly. diff --git a/typings/customauth.d.ts b/typings/customauth.d.ts new file mode 100644 index 000000000000..747285c0788a --- /dev/null +++ b/typings/customauth.d.ts @@ -0,0 +1,32 @@ +import type { Request, Response, Router } from "express" + +/** + * Modules assigned to the custom-auth-module configuration option + * must export a "customAuth" property implementing this interface. + */ +export interface CodeServerCustomAuth { + /** + * A GET request to the "/" path of the loginRouter is made when the user needs to login. + */ + readonly loginRouter: Router + + /** + * A GET request to the "/" path of the logoutRouter is made when the user needs to logout. + */ + readonly logoutRouter: Router + + /** + * Runs once when code-server starts. It will block startup until the returned + * promise resolves. + */ + initialize(): Promise + + /** + * Tells if the user is authenticated and authorized. + * + * @param req the request that needs to be authorized. + * @param res the current response. + * @returns true if the user is authorized, false otherwise. + */ + authenticated(req: Request, res: Response): Promise +}