Skip to content

Pluggable authentication module #4069

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum Feature {
export enum AuthType {
Password = "password",
None = "none",
Custom = "custom",
}

export class Optional<T> {
Expand All @@ -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
Expand Down Expand Up @@ -117,6 +119,10 @@ const options: Options<Required<Args>> = {
"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,
Expand Down
19 changes: 19 additions & 0 deletions src/node/customAuth.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 7 additions & 3 deletions src/node/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -46,10 +47,10 @@ export const replaceTemplates = <T extends object>(
*/
export const ensureAuthenticated = async (
req: express.Request,
_?: express.Response,
res: express.Response,
next?: express.NextFunction,
): Promise<void> => {
const isAuthenticated = await authenticated(req)
const isAuthenticated = await authenticated(req, res)
if (!isAuthenticated) {
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
}
Expand All @@ -61,7 +62,7 @@ export const ensureAuthenticated = async (
/**
* Return true if authenticated via cookies.
*/
export const authenticated = async (req: express.Request): Promise<boolean> => {
export const authenticated = async (req: express.Request, res: express.Response): Promise<boolean> => {
switch (req.args.auth) {
case AuthType.None: {
return true
Expand All @@ -79,6 +80,9 @@ export const authenticated = async (req: express.Request): Promise<boolean> => {

return await isCookieValid(isCookieValidArgs)
}
case AuthType.Custom: {
return (await getCustomAuth()?.authenticated(req, res)) ?? false
}
default: {
throw new Error(`Unsupported auth type ${req.args.auth}`)
}
Expand Down
16 changes: 16 additions & 0 deletions src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -94,6 +95,18 @@ export const runCodeServer = async (args: DefaultedArgs): Promise<http.Server> =
)
}

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)
Expand All @@ -109,6 +122,9 @@ export const runCodeServer = async (args: DefaultedArgs): Promise<http.Server> =
} 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)" : ""}`)
}
Expand Down
6 changes: 3 additions & 3 deletions src/node/routes/domainProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions src/node/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
})
Expand Down Expand Up @@ -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, "/", {}))
Expand Down
2 changes: 1 addition & 1 deletion src/node/routes/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions src/node/routes/pathProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -47,11 +47,12 @@ export function proxy(

export async function wsProxy(
req: pluginapi.WebsocketRequest,
res: Response,
opts?: {
passthroughPath?: boolean
},
): Promise<void> {
await ensureAuthenticated(req)
await ensureAuthenticated(req, res)
_proxy.ws(req, req.ws, req.head, {
ignorePath: true,
target: getProxyTarget(req, opts?.passthroughPath),
Expand Down
5 changes: 2 additions & 3 deletions src/node/routes/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
}

Expand Down
9 changes: 7 additions & 2 deletions src/node/routes/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions typings/customauth.d.ts
Original file line number Diff line number Diff line change
@@ -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<void>

/**
* 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<boolean>
}