Skip to content

Commit

Permalink
Merge branch 'feature/ui-789-custom-auth' into 'main'
Browse files Browse the repository at this point in the history
feat: pluggable authentication system

See merge request ui/code-server!1
  • Loading branch information
sdissegna-maystreet committed Aug 30, 2021
2 parents d26858b + b9fe118 commit a96812e
Show file tree
Hide file tree
Showing 11 changed files with 107 additions and 18 deletions.
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>
}

0 comments on commit a96812e

Please sign in to comment.