Files
sso-manager-node/nodejs/routes/oauth.js
T
2026-07-01 11:10:09 -04:00

369 lines
12 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const { execSync } = require('child_process');
const jwt = require('jsonwebtoken');
const express = require('express');
const conf = require('@simpleworkjs/conf');
const { OAuthClient } = require('../models/oauth_client');
const { OAuthCode, OAuthAccessToken, OAuthRefreshToken } = require('../models/oauth_code');
const { User } = require('../models/user');
const oauthConf = conf.oauth || {};
const issuer = oauthConf.issuer || `http://localhost:${conf.port || 3000}`;
const jwtSecret = oauthConf.jwtSecret || 'change-me-in-secrets';
const { version: buildVersion } = require('../package.json');
let buildHash = 'unknown';
try { buildHash = execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); } catch(_) {}
const pageLocals = {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
buildVersion,
buildHash,
buildYear: new Date().getFullYear(),
};
// --- helpers ---
function makeError(name, message, status) {
const error = new Error(name);
error.name = name;
error.message = message;
error.status = status;
return error;
}
function verifyPKCE(code_verifier, code_challenge, method) {
if (method !== 'S256') return false;
const hash = crypto.createHash('sha256').update(code_verifier).digest();
const b64 = hash.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return b64 === code_challenge;
}
function parseClientAuth(req) {
// Support Basic auth header and POST body
const auth = req.headers['authorization'];
if (auth && auth.startsWith('Basic ')) {
const [client_id, client_secret] = Buffer.from(auth.slice(6), 'base64').toString().split(':');
return { client_id, client_secret };
}
return {
client_id: req.body.client_id,
client_secret: req.body.client_secret,
};
}
function buildIdToken(user, client, scope, now) {
const scopes = scope.split(' ');
const claims = {
iss: issuer,
sub: user.uid,
aud: client.client_id,
iat: Math.floor(now / 1000),
exp: Math.floor(now / 1000) + (client.token_lifetime.access_token || 3600),
preferred_username: user.uid,
};
if (scopes.includes('profile')) {
claims.name = `${user.givenName} ${user.sn}`;
claims.given_name = user.givenName;
claims.family_name = user.sn;
}
if (scopes.includes('email')) {
claims.email = user.mail;
}
return jwt.sign(claims, jwtSecret, { algorithm: 'HS256' });
}
function userClaims(user, scope) {
const scopes = scope.split(' ');
const claims = { sub: user.uid };
if (scopes.includes('profile')) {
claims.name = `${user.givenName} ${user.sn}`;
claims.given_name = user.givenName;
claims.family_name = user.sn;
claims.preferred_username = user.uid;
}
if (scopes.includes('email')) {
claims.email = user.mail;
}
return claims;
}
// --- page router (mounted at /oauth) ---
const router = express.Router();
// Consent page — validates OAuth params and renders the consent UI.
// The actual code issuance happens via POST /api/oauth/authorize (authenticated).
router.get('/authorize', async function(req, res, next) {
try {
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
if (response_type !== 'code') {
return next(makeError('UnsupportedResponseType', 'Only response_type=code is supported.', 400));
}
if (!code_challenge) {
return next(makeError('PKCERequired', 'code_challenge is required.', 400));
}
if ((code_challenge_method || 'S256') !== 'S256') {
return next(makeError('UnsupportedMethod', 'Only code_challenge_method=S256 is supported.', 400));
}
let client;
try {
client = await OAuthClient.get(client_id);
} catch(_) {
return next(makeError('InvalidClient', 'Unknown client_id.', 400));
}
if (!client.is_valid) {
return next(makeError('InvalidClient', 'Client is disabled.', 400));
}
if (!client.redirect_uris.includes(redirect_uri)) {
return next(makeError('InvalidRedirectURI', 'redirect_uri is not registered for this client.', 400));
}
const requestedScopes = (scope || '').split(' ').filter(Boolean);
const allowedScopes = requestedScopes.filter(s => client.scopes.includes(s));
res.render('oauth_authorize', {
...pageLocals,
oauthClient: {
client_id: client.client_id,
name: client.name,
description: client.description,
},
params: {
response_type,
client_id,
redirect_uri,
scope: allowedScopes.join(' '),
state: state || '',
code_challenge,
code_challenge_method: code_challenge_method || 'S256',
},
scopes: allowedScopes,
});
} catch(error) {
next(error);
}
});
// Token endpoint — exchanges auth codes and refresh tokens.
router.post('/token', express.urlencoded({ extended: false }), async function(req, res, next) {
try {
const { grant_type, code, redirect_uri, code_verifier, refresh_token } = req.body;
const { client_id, client_secret } = parseClientAuth(req);
// Authenticate the client
let client;
try {
client = await OAuthClient.get(client_id);
} catch(_) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Unknown client.' });
}
if (!client.is_valid || !(await client.verifySecret(client_secret))) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Client authentication failed.' });
}
const now = Date.now();
if (grant_type === 'authorization_code') {
let authCode;
try {
authCode = await OAuthCode.get(code);
} catch(_) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code not found.' });
}
if (!authCode.is_valid || authCode.isExpired) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code has expired or been used.' });
}
if (authCode.client_id !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code was not issued to this client.' });
}
if (authCode.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri does not match.' });
}
if (authCode.code_challenge && !verifyPKCE(code_verifier, authCode.code_challenge, authCode.code_challenge_method)) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed.' });
}
// Consume the code
await authCode.update({ is_valid: false });
const user = await User.get(authCode.username);
const accessExpires = now + (client.token_lifetime.access_token * 1000);
const refreshExpires = now + (client.token_lifetime.refresh_token * 1000);
const [accessToken, refreshToken] = await Promise.all([
OAuthAccessToken.add({ username: authCode.username, client_id, scope: authCode.scope, expires_at: accessExpires }),
OAuthRefreshToken.add({ username: authCode.username, client_id, scope: authCode.scope, expires_at: refreshExpires }),
]);
const response = {
access_token: accessToken.token,
token_type: 'Bearer',
expires_in: client.token_lifetime.access_token,
refresh_token: refreshToken.token,
};
if (authCode.scope.split(' ').includes('openid')) {
response.id_token = buildIdToken(user, client, authCode.scope, now);
}
return res.json(response);
} else if (grant_type === 'refresh_token') {
let oldRefreshToken;
try {
oldRefreshToken = await OAuthRefreshToken.get(refresh_token);
} catch(_) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token not found.' });
}
if (!oldRefreshToken.is_valid || oldRefreshToken.isExpired) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token has expired or been revoked.' });
}
if (oldRefreshToken.client_id !== client_id) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token was not issued to this client.' });
}
// Rotate — invalidate old, issue new
await oldRefreshToken.update({ is_valid: false });
const user = await User.get(oldRefreshToken.username);
const accessExpires = now + (client.token_lifetime.access_token * 1000);
const refreshExpires = now + (client.token_lifetime.refresh_token * 1000);
const [accessToken, newRefreshToken] = await Promise.all([
OAuthAccessToken.add({ username: oldRefreshToken.username, client_id, scope: oldRefreshToken.scope, expires_at: accessExpires }),
OAuthRefreshToken.add({ username: oldRefreshToken.username, client_id, scope: oldRefreshToken.scope, expires_at: refreshExpires }),
]);
const response = {
access_token: accessToken.token,
token_type: 'Bearer',
expires_in: client.token_lifetime.access_token,
refresh_token: newRefreshToken.token,
};
if (oldRefreshToken.scope.split(' ').includes('openid')) {
response.id_token = buildIdToken(user, client, oldRefreshToken.scope, now);
}
return res.json(response);
} else {
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `grant_type '${grant_type}' is not supported.` });
}
} catch(error) {
next(error);
}
});
// UserInfo endpoint — accepts a Bearer access token and returns user claims.
router.get('/userinfo', async function(req, res, next) {
try {
const auth = req.headers['authorization'];
if (!auth || !auth.startsWith('Bearer ')) {
res.set('WWW-Authenticate', 'Bearer');
return res.status(401).json({ error: 'invalid_token', error_description: 'Bearer token required.' });
}
const tokenStr = auth.slice(7);
let accessToken;
try {
accessToken = await OAuthAccessToken.get(tokenStr);
} catch(_) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
return res.status(401).json({ error: 'invalid_token', error_description: 'Access token not found.' });
}
if (!accessToken.is_valid || accessToken.isExpired) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
return res.status(401).json({ error: 'invalid_token', error_description: 'Access token has expired.' });
}
const user = await User.get(accessToken.username);
return res.json(userClaims(user, accessToken.scope));
} catch(error) {
next(error);
}
});
// --- authenticated API router (mounted at /api/oauth with auth middleware) ---
const authRouter = express.Router();
// Issues an authorization code after the user approves the consent form.
authRouter.post('/authorize', async function(req, res, next) {
try {
const { client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_type } = req.body;
if (response_type !== 'code') {
return next(makeError('UnsupportedResponseType', 'Only response_type=code is supported.', 400));
}
let client;
try {
client = await OAuthClient.get(client_id);
} catch(_) {
return next(makeError('InvalidClient', 'Unknown client_id.', 400));
}
if (!client.is_valid) {
return next(makeError('InvalidClient', 'Client is disabled.', 400));
}
if (!client.redirect_uris.includes(redirect_uri)) {
return next(makeError('InvalidRedirectURI', 'redirect_uri is not registered for this client.', 400));
}
const authCode = await OAuthCode.add({
username: req.user.uid,
client_id,
redirect_uri,
scope: scope || 'openid',
code_challenge: code_challenge || '',
code_challenge_method: code_challenge_method || 'S256',
});
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', authCode.token);
if (state) redirectUrl.searchParams.set('state', state);
return res.json({ redirect_url: redirectUrl.toString() });
} catch(error) {
next(error);
}
});
// --- discovery document (used directly in app.js) ---
function discovery(req, res) {
const base = issuer.replace(/\/$/, '');
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
userinfo_endpoint: `${base}/oauth/userinfo`,
scopes_supported: ['openid', 'profile', 'email'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'],
});
}
module.exports = { router, authRouter, discovery };