oidc: remove one level of indent by making provider global

This commit is contained in:
Girish Ramakrishnan
2025-06-11 20:40:18 +02:00
parent ef22387440
commit 1091142614

View File

@@ -52,7 +52,7 @@ const OIDC_CLIENTS_FIELDS = [ 'id', 'secret', 'name', 'appId', 'loginRedirectUri
const ROUTE_PREFIX = '/openid';
const DEFAULT_TOKEN_SIGNATURE_ALGORITHM='RS256';
let gHttpServer = null;
let gHttpServer = null, gOidcProvider = null;
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
@@ -391,274 +391,250 @@ class CloudronAdapter {
}
}
function renderInteractionPage(provider) {
assert.strictEqual(typeof provider, 'object');
async function renderInteractionPage(req, res) {
try {
const { uid, prompt, params, session } = await gOidcProvider.interactionDetails(req, res);
return async function (req, res) {
try {
const { uid, prompt, params, session } = await provider.interactionDetails(req, res);
const client = await getClient(params.client_id);
const client = await getClient(params.client_id);
let app = null;
if (client.appId) app = await apps.get(client.appId);
let app = null;
if (client.appId) app = await apps.get(client.appId);
switch (prompt.name) {
case 'login': {
const options = {
SUBMIT_URL: `${ROUTE_PREFIX}/interaction/${uid}/login`,
ICON_URL: '/api/v1/cloudron/avatar',
NAME: client?.name || await branding.getCloudronName(),
FOOTER: marked.parse(await branding.renderFooter()),
NOTE: (client.id === tokens.ID_WEBADMIN && constants.DEMO) ? '<div style="text-align: center;">This is a demo. Username and password is "cloudron"</div>' : ''
};
switch (prompt.name) {
case 'login': {
const options = {
SUBMIT_URL: `${ROUTE_PREFIX}/interaction/${uid}/login`,
ICON_URL: '/api/v1/cloudron/avatar',
NAME: client?.name || await branding.getCloudronName(),
FOOTER: marked.parse(await branding.renderFooter()),
NOTE: (client.id === tokens.ID_WEBADMIN && constants.DEMO) ? '<div style="text-align: center;">This is a demo. Username and password is "cloudron"</div>' : ''
};
if (app) {
options.NAME = app.label || app.fqdn;
options.ICON_URL = app.iconUrl;
}
// great ejs replacement!
let html = fs.readFileSync(__dirname + '/../dashboard/dist/login.html', 'utf-8');
Object.keys(options).forEach(key => {
html = html.replaceAll(`##${key}##`, options[key]);
});
return res.send(html);
if (app) {
options.NAME = app.label || app.fqdn;
options.ICON_URL = app.iconUrl;
}
case 'consent': {
let hasAccess = false;
const data = {
ICON_URL: '/api/v1/cloudron/avatar',
NAME: client?.name || '',
FOOTER: marked.parse(await branding.renderFooter())
};
// great ejs replacement!
let html = fs.readFileSync(__dirname + '/../dashboard/dist/login.html', 'utf-8');
Object.keys(options).forEach(key => {
html = html.replaceAll(`##${key}##`, options[key]);
});
// check if user has access to the app if client refers to an app
if (app) {
const user = await users.get(session.accountId);
data.NAME = app.label || app.fqdn;
data.ICON_URL = app.iconUrl;
hasAccess = apps.canAccess(app, user);
} else {
hasAccess = true;
}
data.SUBMIT_URL = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
let html = fs.readFileSync(path.join(__dirname, hasAccess ? '/../dashboard/dist/oidc_interaction_confirm.html' : '/../dashboard/dist/oidc_interaction_abort.html'), 'utf8');
Object.keys(data).forEach(key => {
html = html.replaceAll(`##${key}##`, data[key]);
});
return res.send(html);
}
default:
return undefined;
}
} catch (error) {
debug('route interaction get error', error);
return res.send(html);
}
case 'consent': {
let hasAccess = false;
const data = {
ICON_URL: '/api/v1/cloudron/avatar',
NAME: 'Cloudron',
ERROR_MESSAGE: error.error_description || 'Internal error',
NAME: client?.name || '',
FOOTER: marked.parse(await branding.renderFooter())
};
let html = fs.readFileSync(path.join(__dirname, '/../dashboard/dist/oidc_error.html'), 'utf8');
// check if user has access to the app if client refers to an app
if (app) {
const user = await users.get(session.accountId);
data.NAME = app.label || app.fqdn;
data.ICON_URL = app.iconUrl;
hasAccess = apps.canAccess(app, user);
} else {
hasAccess = true;
}
data.SUBMIT_URL = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
let html = fs.readFileSync(path.join(__dirname, hasAccess ? '/../dashboard/dist/oidc_interaction_confirm.html' : '/../dashboard/dist/oidc_interaction_abort.html'), 'utf8');
Object.keys(data).forEach(key => {
html = html.replaceAll(`##${key}##`, data[key]);
});
res.set('Content-Type', 'text/html');
return res.send(html);
}
};
default:
return undefined;
}
} catch (error) {
debug('route interaction get error', error);
const data = {
ICON_URL: '/api/v1/cloudron/avatar',
NAME: 'Cloudron',
ERROR_MESSAGE: error.error_description || 'Internal error',
FOOTER: marked.parse(await branding.renderFooter())
};
let html = fs.readFileSync(path.join(__dirname, '/../dashboard/dist/oidc_error.html'), 'utf8');
Object.keys(data).forEach(key => {
html = html.replaceAll(`##${key}##`, data[key]);
});
res.set('Content-Type', 'text/html');
return res.send(html);
}
}
function interactionLogin(provider) {
assert.strictEqual(typeof provider, 'object');
async function interactionLogin(req, res, next) {
const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res));
if (detailsError) {
if (detailsError.error_description === 'interaction session not found') return next(new HttpError(410, 'session timeout'));
return next(new HttpError(400, detailsError));
}
return async function(req, res, next) {
const [detailsError, details] = await safe(provider.interactionDetails(req, res));
if (detailsError) {
if (detailsError.error_description === 'interaction session not found') return next(new HttpError(410, 'session timeout'));
return next(new HttpError(400, detailsError));
}
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
const userAgent = req.headers['user-agent'] || '';
const clientId = details.params.client_id;
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
const userAgent = req.headers['user-agent'] || '';
const clientId = details.params.client_id;
debug(`interactionLogin: for OpenID client ${clientId} from ${ip}`);
debug(`interactionLogin: for OpenID client ${clientId} from ${ip}`);
// This is the auto login via token hack
if (req.body.autoLoginToken) {
if (typeof req.body.autoLoginToken !== 'string') return next(new HttpError(400, 'autoLoginToken must be string if provided'));
// This is the auto login via token hack
if (req.body.autoLoginToken) {
if (typeof req.body.autoLoginToken !== 'string') return next(new HttpError(400, 'autoLoginToken must be string if provided'));
const token = await tokens.getByAccessToken(req.body.autoLoginToken);
if (!token) return next(new HttpError(401, 'No such token'));
const token = await tokens.getByAccessToken(req.body.autoLoginToken);
if (!token) return next(new HttpError(401, 'No such token'));
const user = await users.get(token.identifier);
if (!user) return next(new HttpError(401,'User not found'));
if (!user.active) return next(new HttpError(401,'User not active'));
const user = await users.get(token.identifier);
if (!user) return next(new HttpError(401,'User not found'));
if (!user.active) return next(new HttpError(401,'User not active'));
const result = {
login: {
accountId: user.id,
},
};
const [interactionFinishError, redirectTo] = await safe(provider.interactionResult(req, res, result));
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
const auditSource = AuditSource.fromOidcRequest(req);
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: clientId });
await safe(users.notifyLoginLocation(user, ip, userAgent, auditSource), { debug });
// clear token as it is one-time use
await tokens.delByAccessToken(req.body.autoLoginToken);
return res.status(200).send({ redirectTo });
}
if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'A username must be non-empty string'));
if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'A password must be non-empty string'));
if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' ));
const { username, password, totpToken } = req.body;
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
if (verifyError) return next(new HttpError(500, verifyError));
if (!user) return next(new HttpError(401, 'Username and password does not match'));
// TODO we may have to check what else the Account class provides, in which case we have to map those things
const result = {
login: {
accountId: user.id,
},
};
const [interactionFinishError, redirectTo] = await safe(provider.interactionResult(req, res, result));
const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result));
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
res.status(200).send({ redirectTo });
const auditSource = AuditSource.fromOidcRequest(req);
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: clientId });
await safe(users.notifyLoginLocation(user, ip, userAgent, auditSource), { debug });
// clear token as it is one-time use
await tokens.delByAccessToken(req.body.autoLoginToken);
return res.status(200).send({ redirectTo });
}
if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'A username must be non-empty string'));
if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'A password must be non-empty string'));
if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' ));
const { username, password, totpToken } = req.body;
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
if (verifyError) return next(new HttpError(500, verifyError));
if (!user) return next(new HttpError(401, 'Username and password does not match'));
// TODO we may have to check what else the Account class provides, in which case we have to map those things
const result = {
login: {
accountId: user.id,
},
};
const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result));
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
res.status(200).send({ redirectTo });
}
function interactionConfirm(provider) {
assert.strictEqual(typeof provider, 'object');
return async function (req, res, next) {
async function raiseLoginEvent(user, clientId) {
try {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
const userAgent = req.headers['user-agent'] || '';
const auditSource = AuditSource.fromOidcRequest(req);
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: clientId });
await users.notifyLoginLocation(user, ip, userAgent, auditSource);
} catch (e) {
console.error('oidc: Failed to raise login event.', e);
}
}
async function interactionConfirm(req, res, next) {
async function raiseLoginEvent(user, clientId) {
try {
const interactionDetails = await provider.interactionDetails(req, res);
const { grantId, uid, prompt: { name, details }, params, session: { accountId } } = interactionDetails;
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
const userAgent = req.headers['user-agent'] || '';
const auditSource = AuditSource.fromOidcRequest(req);
debug(`route interaction confirm post uid:${uid} prompt.name:${name} accountId:${accountId}`);
assert.equal(name, 'consent');
const client = await getClient(params.client_id);
const user = await users.get(accountId);
// Check if user has access to the app if client refers to an app
// In most cases the user interaction already ends in the consent screen (see above)
if (client.appId) {
const app = await apps.get(client.appId);
if (!apps.canAccess(app, user)) {
const result = {
error: 'access_denied',
error_description: 'User has no access to this app',
};
await raiseLoginEvent(user, client.appId);
return await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
}
}
let grant;
if (grantId) {
grant = await provider.Grant.find(grantId);
} else {
grant = new provider.Grant({
accountId,
clientId: params.client_id,
});
}
if (details.missingOIDCScope) {
grant.addOIDCScope(details.missingOIDCScope.join(' '));
}
if (details.missingOIDCClaims) {
grant.addOIDCClaims(details.missingOIDCClaims);
}
if (details.missingResourceScopes) {
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
grant.addResourceScope(indicator, scopes.join(' '));
}
}
const savedGrantId = await grant.save();
const consent = {};
if (!interactionDetails.grantId) consent.grantId = savedGrantId;
await raiseLoginEvent(user, params.client_id);
const result = { consent };
await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
} catch (err) {
next(err);
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: clientId });
await users.notifyLoginLocation(user, ip, userAgent, auditSource);
} catch (e) {
console.error('oidc: Failed to raise login event.', e);
}
};
}
try {
const interactionDetails = await gOidcProvider.interactionDetails(req, res);
const { grantId, uid, prompt: { name, details }, params, session: { accountId } } = interactionDetails;
debug(`route interaction confirm post uid:${uid} prompt.name:${name} accountId:${accountId}`);
assert.equal(name, 'consent');
const client = await getClient(params.client_id);
const user = await users.get(accountId);
// Check if user has access to the app if client refers to an app
// In most cases the user interaction already ends in the consent screen (see above)
if (client.appId) {
const app = await apps.get(client.appId);
if (!apps.canAccess(app, user)) {
const result = {
error: 'access_denied',
error_description: 'User has no access to this app',
};
await raiseLoginEvent(user, client.appId);
return await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
}
}
let grant;
if (grantId) {
grant = await gOidcProvider.Grant.find(grantId);
} else {
grant = new gOidcProvider.Grant({
accountId,
clientId: params.client_id,
});
}
if (details.missingOIDCScope) {
grant.addOIDCScope(details.missingOIDCScope.join(' '));
}
if (details.missingOIDCClaims) {
grant.addOIDCClaims(details.missingOIDCClaims);
}
if (details.missingResourceScopes) {
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
grant.addResourceScope(indicator, scopes.join(' '));
}
}
const savedGrantId = await grant.save();
const consent = {};
if (!interactionDetails.grantId) consent.grantId = savedGrantId;
await raiseLoginEvent(user, params.client_id);
const result = { consent };
await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
} catch (err) {
next(err);
}
}
function interactionAbort(provider) {
assert.strictEqual(typeof provider, 'object');
return async function (req, res, next) {
try {
const result = {
error: 'access_denied',
error_description: 'End-User aborted interaction',
};
await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
} catch (err) {
next(err);
}
};
async function interactionAbort(req, res, next) {
try {
const result = {
error: 'access_denied',
error_description: 'End-User aborted interaction',
};
await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
} catch (err) {
next(err);
}
}
/**
* @param use - can either be "id_token" or "userinfo", depending on
* where the specific claims are intended to be put in.
* @param scope - the intended scope, while oidc-provider will mask
* claims depending on the scope automatically you might want to skip
* loading some claims from external resources etc. based on this detail
* or not return them in id tokens but only userinfo and so on.
*/
async function claims(userId/*, use, scope*/) {
const [error, user] = await safe(users.get(userId));
if (error) return { error: 'user not found' };
@@ -715,15 +691,13 @@ async function renderError(ctx, out, error) {
}
async function start() {
assert(gHttpServer === null, 'OIDC aerver already started');
assert(gHttpServer === null, 'OIDC server already started');
assert(gOidcProvider === null, 'OIDC provider already started');
const app = express();
gHttpServer = http.createServer(app);
const Provider = (await import('oidc-provider')).default;
// TODO we may want to rotate those in the future
const jwksKeys = [];
let keyEdDsa = await blobs.getString(blobs.OIDC_KEY_EDDSA);
@@ -846,10 +820,12 @@ async function start() {
const { subdomain, domain } = await dashboard.getLocation();
const fqdn = dns.fqdn(subdomain, domain);
debug(`start: create provider for ${fqdn} at ${ROUTE_PREFIX}`);
const provider = new Provider(`https://${fqdn}${ROUTE_PREFIX}`, configuration);
const Provider = (await import('oidc-provider')).default;
gOidcProvider = new Provider(`https://${fqdn}${ROUTE_PREFIX}`, configuration);
app.enable('trust proxy');
provider.proxy = true;
gOidcProvider.proxy = true;
const json = express.json({ strict: true, limit: '2mb' });
function setNoCache(req, res, next) {
@@ -857,12 +833,12 @@ async function start() {
next();
}
app.get (`${ROUTE_PREFIX}/interaction/:uid`, setNoCache, renderInteractionPage(provider));
app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin(provider));
app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm(provider));
app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort(provider));
app.get (`${ROUTE_PREFIX}/interaction/:uid`, setNoCache, renderInteractionPage);
app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin);
app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm);
app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort);
app.use(ROUTE_PREFIX, provider.callback());
app.use(ROUTE_PREFIX, gOidcProvider.callback());
app.use(middleware.lastMile());
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.OIDC_PORT, '127.0.0.1');
@@ -873,4 +849,5 @@ async function stop() {
await util.promisify(gHttpServer.close.bind(gHttpServer))();
gHttpServer = null;
gOidcProvider = null;
}