2020-11-09 20:34:48 -08:00
'use strict' ;
// heavily inspired from https://gock.net/blog/2020/nginx-subrequest-authentication-server/ and https://github.com/andygock/auth-server
exports = module . exports = {
start ,
stop
} ;
2020-11-11 00:21:51 -08:00
const apps = require ( './apps.js' ) ,
2025-08-14 11:17:38 +05:30
assert = require ( 'node:assert' ) ,
2022-02-01 17:56:40 -08:00
blobs = require ( './blobs.js' ) ,
2020-11-09 20:34:48 -08:00
constants = require ( './constants.js' ) ,
2024-04-15 19:20:00 +02:00
dashboard = require ( './dashboard.js' ) ,
2020-11-10 09:59:28 -08:00
debug = require ( 'debug' ) ( 'box:proxyAuth' ) ,
2025-07-16 16:16:06 +02:00
ejs = require ( 'ejs' ) ,
2020-11-09 20:34:48 -08:00
express = require ( 'express' ) ,
2025-08-14 11:17:38 +05:30
fs = require ( 'node:fs' ) ,
path = require ( 'node:path' ) ,
2025-07-16 16:16:06 +02:00
paths = require ( './paths.js' ) ,
2020-11-10 17:10:57 -08:00
hat = require ( './hat.js' ) ,
2025-08-14 11:17:38 +05:30
http = require ( 'node:http' ) ,
2025-07-10 11:00:31 +02:00
HttpError = require ( '@cloudron/connect-lastmile' ) . HttpError ,
HttpSuccess = require ( '@cloudron/connect-lastmile' ) . HttpSuccess ,
2020-11-09 20:34:48 -08:00
jwt = require ( 'jsonwebtoken' ) ,
middleware = require ( './middleware' ) ,
2025-06-11 22:00:09 +02:00
oidcServer = require ( './oidcserver.js' ) ,
2020-11-11 00:21:51 -08:00
safe = require ( 'safetydance' ) ,
2025-07-16 16:16:06 +02:00
settings = require ( './settings.js' ) ,
2021-08-22 16:19:22 -07:00
users = require ( './users.js' ) ,
2025-08-14 11:17:38 +05:30
util = require ( 'node:util' ) ;
2020-11-09 20:34:48 -08:00
let gHttpServer = null ;
2022-02-01 17:16:25 -08:00
let gTokenSecret = null ;
2020-11-09 20:34:48 -08:00
function jwtVerify ( req , res , next ) {
const token = req . cookies . authToken ;
if ( ! token ) return next ( ) ;
2022-02-01 17:16:25 -08:00
jwt . verify ( token , gTokenSecret , function ( error , decoded ) {
2020-11-09 20:34:48 -08:00
if ( error ) {
2022-02-01 17:56:40 -08:00
debug ( 'jwtVerify: malformed token or bad signature' , error . message ) ;
req . user = null ;
} else {
req . user = decoded . user || null ;
2020-11-09 20:34:48 -08:00
}
next ( ) ;
} ) ;
}
2025-02-15 16:56:40 +01:00
function basicAuth ( req ) {
const CREDENTIALS _REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/ ;
const USER _PASS _REGEXP = /^([^:]*):(.*)$/ ;
const header = req . headers . authorization ;
if ( ! header ) return null ;
const match = CREDENTIALS _REGEXP . exec ( header ) ;
if ( ! match ) return null ;
const decodedHeader = Buffer . from ( match [ 1 ] , 'base64' ) . toString ( ) ;
const userPass = USER _PASS _REGEXP . exec ( decodedHeader ) ;
if ( ! userPass ) return null ;
return { username : userPass [ 1 ] , password : userPass [ 2 ] } ;
}
2022-08-25 16:12:41 +02:00
async function authorizationHeader ( req , res , next ) {
2020-11-11 13:25:52 -08:00
const appId = req . headers [ 'x-app-id' ] || '' ;
2022-08-25 16:12:41 +02:00
if ( ! appId ) return next ( ) ;
if ( ! req . headers . authorization ) return next ( ) ;
2020-11-11 13:25:52 -08:00
2021-08-20 09:19:44 -07:00
const [ error , app ] = await safe ( apps . get ( appId ) ) ;
if ( error ) return next ( new HttpError ( 503 , error . message ) ) ;
2022-08-25 16:12:41 +02:00
if ( ! app ) return next ( new HttpError ( 503 , 'Error getting app' ) ) ;
2022-08-25 16:36:57 +02:00
// only if the app supports bearer auth, pass it through to the app. without this flag, anyone can access the app with Bearer auth!
2022-08-25 16:12:41 +02:00
if ( req . headers . authorization . startsWith ( 'Bearer ' ) && app . manifest . addons . proxyAuth . supportsBearerAuth ) return next ( new HttpSuccess ( 200 , { } ) ) ;
const credentials = basicAuth ( req ) ;
if ( ! credentials ) return next ( ) ;
2020-11-11 13:25:52 -08:00
2022-08-25 16:12:41 +02:00
if ( ! app . manifest . addons . proxyAuth . basicAuth ) return next ( ) ; // this is a flag because this allows auth to bypass 2FA
2021-02-23 21:52:58 -08:00
2025-02-15 16:56:40 +01:00
const verifyFunc = credentials . username . indexOf ( '@' ) !== - 1 ? users . verifyWithEmail : users . verifyWithUsername ;
const [ verifyError , user ] = await safe ( verifyFunc ( credentials . username , credentials . password , appId , { skipTotpCheck : true } ) ) ;
2021-08-20 09:19:44 -07:00
if ( verifyError ) return next ( new HttpError ( 403 , 'Invalid username or password' ) ) ;
2021-02-23 21:52:58 -08:00
2021-08-20 09:19:44 -07:00
req . user = user ;
next ( ) ;
2020-11-11 13:25:52 -08:00
}
2021-01-26 23:25:56 -08:00
// someday this can be more sophisticated and check for a real browser
function isBrowser ( req ) {
const userAgent = req . get ( 'user-agent' ) ;
if ( ! userAgent ) return false ;
2021-02-09 13:44:34 -08:00
// https://github.com/docker/engine/blob/master/dockerversion/useragent.go#L18
2025-06-14 09:16:47 +02:00
return ! /docker|container|libpod|skopeo/i . test ( userAgent ) ;
2021-01-26 23:25:56 -08:00
}
// called by nginx to authorize any protected route. this route must return only 2xx or 401/403 (http://nginx.org/en/docs/http/ngx_http_auth_request_module.html)
2020-11-09 20:34:48 -08:00
function auth ( req , res , next ) {
2021-01-26 23:25:56 -08:00
if ( ! req . user ) {
2022-02-01 17:56:40 -08:00
res . clearCookie ( 'authToken' ) ;
2021-01-26 23:25:56 -08:00
if ( isBrowser ( req ) ) return next ( new HttpError ( 401 , 'Unauthorized' ) ) ;
// the header has to be generated here and cannot be set in nginx config - https://forum.nginx.org/read.php?2,171461,171469#msg-171469
res . set ( 'www-authenticate' , 'Basic realm="Cloudron"' ) ;
return next ( new HttpError ( 401 , 'Unauthorized' ) ) ;
}
2020-11-09 20:34:48 -08:00
// user is already authenticated, refresh cookie
2022-02-01 17:16:25 -08:00
const token = jwt . sign ( { user : req . user } , gTokenSecret , { expiresIn : ` ${ constants . DEFAULT _TOKEN _EXPIRATION _DAYS } d ` } ) ;
2020-11-09 20:34:48 -08:00
res . cookie ( 'authToken' , token , {
httpOnly : true ,
2021-04-30 10:31:09 -07:00
maxAge : constants . DEFAULT _TOKEN _EXPIRATION _MSECS ,
2020-11-09 20:34:48 -08:00
secure : true
} ) ;
2022-04-21 15:47:50 -07:00
res . set ( 'x-remote-user' , req . user . username ) ;
res . set ( 'x-remote-email' , req . user . email ) ;
2023-01-30 12:27:58 +01:00
// ensure ascii in header, node will crash with ERR_INVALID_CHAR otherwise
// eslint-disable-next-line no-control-regex
res . set ( 'x-remote-name' , /^[\x00-\x7F]*$/ . test ( req . user . displayName ) ? req . user . displayName : req . user . username ) ;
2020-11-09 20:34:48 -08:00
2022-08-25 16:12:41 +02:00
next ( new HttpSuccess ( 200 , { } ) ) ;
2020-11-09 20:34:48 -08:00
}
2025-07-16 16:16:06 +02:00
const TEMPLATE _PROXYAUTH = fs . readFileSync ( path . join ( paths . DASHBOARD _DIR , 'proxyauth.html' ) , 'utf-8' ) ;
2024-04-16 12:45:23 +02:00
async function login ( req , res , next ) {
const appId = req . headers [ 'x-app-id' ] || '' ;
if ( ! appId ) return next ( new HttpError ( 503 , 'Nginx misconfiguration' ) ) ;
const [ error , app ] = await safe ( apps . get ( appId ) ) ;
if ( error ) return next ( new HttpError ( 403 , 'No such app' ) ) ;
const dashboardFqdn = ( await dashboard . getLocation ( ) ) . fqdn ;
2025-07-13 13:14:32 +02:00
if ( typeof req . query . redirect === 'string' ) {
2024-06-11 18:00:07 +02:00
res . cookie ( 'cloudronProxyAuthRedirect' , req . query . redirect , {
httpOnly : true ,
maxAge : constants . DEFAULT _TOKEN _EXPIRATION _MSECS ,
secure : true
} ) ;
}
2025-07-13 15:53:46 +02:00
const proxyAuthClientId = ` ${ app . id } -proxyauth ` ;
2025-07-16 16:16:06 +02:00
const data = {
loginUrl : ` https:// ${ dashboardFqdn } /openid/auth?client_id= ${ proxyAuthClientId } &scope=openid profile email&response_type=code&redirect_uri=https:// ${ app . fqdn } /callback ` ,
2025-07-21 16:26:22 +02:00
iconUrl : app . iconUrl ? ` https:// ${ dashboardFqdn } ${ app . iconUrl } ` : ` https:// ${ dashboardFqdn } /img/appicon_fallback.png ` ,
2025-07-16 16:16:06 +02:00
name : app . label || app . subdomain || app . fqdn ,
language : await settings . get ( settings . LANGUAGE _KEY ) ,
2025-07-16 17:12:49 +02:00
apiOrigin : ` https:// ${ dashboardFqdn } ` ,
2025-07-16 16:16:06 +02:00
} ;
return res . send ( ejs . render ( TEMPLATE _PROXYAUTH , data ) ) ;
2024-04-16 12:45:23 +02:00
}
2024-04-15 12:35:03 +02:00
async function callback ( req , res , next ) {
2025-07-13 13:14:32 +02:00
if ( typeof req . query . code !== 'string' ) return next ( new HttpError ( 400 , 'missing query argument "code"' ) ) ;
2024-04-15 12:35:03 +02:00
debug ( ` callback: with code ${ req . query . code } ` ) ;
2025-07-09 18:06:50 +02:00
const username = await oidcServer . consumeAuthCode ( req . query . code ) ;
if ( ! username ) return next ( new HttpError ( 400 , 'invalid "code"' ) ) ;
req . user = await users . getByUsername ( username ) ;
2024-04-15 12:35:03 +02:00
next ( ) ;
}
2021-08-20 09:19:44 -07:00
async function authorize ( req , res , next ) {
2020-11-20 17:54:17 -08:00
const appId = req . headers [ 'x-app-id' ] || '' ;
if ( ! appId ) return next ( new HttpError ( 503 , 'Nginx misconfiguration' ) ) ;
2020-11-09 20:34:48 -08:00
2021-08-20 09:19:44 -07:00
const [ error , app ] = await safe ( apps . get ( appId ) ) ;
if ( error ) return next ( new HttpError ( 403 , 'No such app' ) ) ;
2020-11-20 17:54:17 -08:00
2021-09-21 10:00:47 -07:00
if ( ! apps . canAccess ( app , req . user ) ) return next ( new HttpError ( 403 , 'Forbidden' ) ) ;
2020-11-20 17:54:17 -08:00
2022-02-01 17:16:25 -08:00
const token = jwt . sign ( { user : users . removePrivateFields ( req . user ) } , gTokenSecret , { expiresIn : ` ${ constants . DEFAULT _TOKEN _EXPIRATION _DAYS } d ` } ) ;
2020-11-20 17:54:17 -08:00
2021-08-20 09:19:44 -07:00
res . cookie ( 'authToken' , token , {
httpOnly : true ,
maxAge : constants . DEFAULT _TOKEN _EXPIRATION _MSECS ,
secure : true
2020-11-09 20:34:48 -08:00
} ) ;
2021-08-20 09:19:44 -07:00
2024-06-11 18:00:07 +02:00
const redirect = req . cookies . cloudronProxyAuthRedirect || '/' ;
res . clearCookie ( 'cloudronProxyAuthRedirect' ) ;
res . redirect ( 302 , redirect ) ;
2020-11-09 20:34:48 -08:00
}
2024-04-15 18:52:07 +02:00
async function logout ( req , res , next ) {
2020-12-19 12:30:06 -08:00
const appId = req . headers [ 'x-app-id' ] || '' ;
if ( ! appId ) return next ( new HttpError ( 503 , 'Nginx misconfiguration' ) ) ;
2021-08-20 09:19:44 -07:00
const [ error , app ] = await safe ( apps . get ( appId ) ) ;
if ( error ) return next ( new HttpError ( 503 , error . message ) ) ;
2020-12-19 12:30:06 -08:00
2021-08-20 09:19:44 -07:00
res . clearCookie ( 'authToken' ) ;
2020-12-19 12:30:06 -08:00
2021-08-20 09:19:44 -07:00
// when we have no path, redirect to the login page. we cannot redirect to '/' because browsers will immediately serve up the cached page
// if a path is set, we can assume '/' is a public page
2024-04-16 12:45:23 +02:00
res . redirect ( 302 , app . manifest . addons . proxyAuth . path ? '/' : '/login' ) ;
2020-11-09 20:34:48 -08:00
}
// provides webhooks for the auth wall
function initializeAuthwallExpressSync ( ) {
2021-09-07 09:57:49 -07:00
const app = express ( ) ;
const httpServer = http . createServer ( app ) ;
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
const REQUEST _TIMEOUT = 10000 ; // timeout for all requests
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
const router = new express . Router ( ) ;
2020-11-09 20:34:48 -08:00
router . del = router . delete ; // amend router.del for readability further on
app
. use ( middleware . timeout ( REQUEST _TIMEOUT ) )
. use ( middleware . cookieParser ( ) )
. use ( router )
. use ( middleware . lastMile ( ) ) ;
2024-04-16 12:45:23 +02:00
router . get ( '/login' , login ) ;
2024-04-15 12:35:03 +02:00
router . get ( '/callback' , callback , authorize ) ;
2022-08-25 16:12:41 +02:00
router . get ( '/auth' , jwtVerify , authorizationHeader , auth ) ; // called by nginx before accessing protected page
2024-04-15 18:52:07 +02:00
router . get ( '/logout' , logout ) ;
router . post ( '/logout' , logout ) ;
2020-11-09 20:34:48 -08:00
return httpServer ;
}
2021-09-07 09:57:49 -07:00
async function start ( ) {
2020-11-09 20:34:48 -08:00
assert . strictEqual ( gHttpServer , null , 'Authwall is already up and running.' ) ;
2022-02-01 17:16:25 -08:00
gTokenSecret = await blobs . getString ( blobs . PROXY _AUTH _TOKEN _SECRET ) ;
if ( ! gTokenSecret ) {
debug ( 'start: generating new token secret' ) ;
gTokenSecret = hat ( 64 ) ;
await blobs . setString ( blobs . PROXY _AUTH _TOKEN _SECRET , gTokenSecret ) ;
2020-11-10 17:10:57 -08:00
}
2020-11-09 20:34:48 -08:00
gHttpServer = initializeAuthwallExpressSync ( ) ;
2021-09-07 09:57:49 -07:00
await util . promisify ( gHttpServer . listen . bind ( gHttpServer ) ) ( constants . AUTHWALL _PORT , '127.0.0.1' ) ;
2020-11-09 20:34:48 -08:00
}
2021-09-07 09:57:49 -07:00
async function stop ( ) {
if ( ! gHttpServer ) return ;
2020-11-09 20:34:48 -08:00
2021-09-07 09:57:49 -07:00
await util . promisify ( gHttpServer . close . bind ( gHttpServer ) ) ( ) ;
2020-11-09 20:34:48 -08:00
gHttpServer = null ;
}