Files
sso-manager-node/nodejs/routes/oauth.js
T
wmantly 4c6b1e38b1 Support wildcard redirect_uri patterns for OAuth clients
theta42/proxy fronts an arbitrary number of hosts behind SSO, each with its
own callback URL (https://<host>/__proxy_auth/callback) — proxy's own code
comment already assumed "a wildcard redirect URI covers all", but no
wildcard matching existed here, so every proxied host's callback had to be
registered on the shared OAuth client individually or /oauth/authorize
would reject it with InvalidRedirectURI.

Add `*` (one hostname label) / `**` (any number of labels) wildcard support
to redirect_uri matching, e.g. `https://**.example.com/__proxy_auth/callback`
now covers every host proxy fronts under example.com. Exact matches still
work exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:42:32 -04:00

460 lines
16 KiB
JavaScript

'use strict';
const crypto = require('crypto');
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 { Group } = require('../models/group_ldap');
const buildInfo = require('../utils/build_info');
const oauthConf = conf.oauth || {};
const issuer = oauthConf.issuer || `http://localhost:${conf.port || 3000}`;
const jwtSecret = oauthConf.jwtSecret || 'change-me-in-secrets';
const pageLocals = {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
...buildInfo,
};
// --- helpers ---
function makeError(name, message, status) {
const error = new Error(name);
error.name = name;
error.message = message;
error.status = status;
return error;
}
// A registered redirect_uri may use `*` (one hostname label, no '.' or '/') or
// `**` (anything) as a wildcard — e.g. `https://*.example.com/__proxy_auth/callback`
// covers every host theta42/proxy fronts under example.com, so operators don't
// have to register each proxied host's callback individually. Mirrors the
// */** wildcard convention proxy's own Host.host field already uses.
function redirectUriAllowed(patterns, uri) {
if (!Array.isArray(patterns) || !uri) return false;
for (const pattern of patterns) {
if (pattern === uri) return true;
if (typeof pattern !== 'string' || pattern.indexOf('*') === -1) continue;
const DOUBLE = 'WILDCARD_DOUBLE';
const SINGLE = 'WILDCARD_SINGLE';
const placeholderized = pattern.split('**').join(DOUBLE).split('*').join(SINGLE);
const escaped = placeholderized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexStr = '^' + escaped.split(DOUBLE).join('.*').split(SINGLE).join('[^./]+') + '$';
try {
if (new RegExp(regexStr).test(uri)) return true;
} catch (error) { /* malformed pattern, skip */ }
}
return false;
}
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,
};
}
// The LDAP group CNs a user belongs to (empty on any lookup failure).
async function userGroups(user) {
try {
return await Group.list(user.dn);
} catch(_) {
return [];
}
}
async 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;
}
if (scopes.includes('groups')) {
claims.groups = await userGroups(user);
}
return jwt.sign(claims, jwtSecret, { algorithm: 'HS256' });
}
async 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;
}
if (scopes.includes('groups')) {
claims.groups = await userGroups(user);
}
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 (!redirectUriAllowed(client.redirect_uris, 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 = await 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 = await 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(await userClaims(user, accessToken.scope));
} catch(error) {
next(error);
}
});
// RP-initiated logout — clears the SSO browser session, then returns the user
// to the requesting app's post_logout_redirect_uri (if it belongs to a
// registered client, to prevent this being used as an open redirect).
router.get('/logout', async function(req, res, next) {
try {
const { post_logout_redirect_uri, state } = req.query;
let target = '/';
if (post_logout_redirect_uri) {
let requested;
try {
requested = new URL(post_logout_redirect_uri);
} catch(_) {
return next(makeError('InvalidRequest', 'post_logout_redirect_uri is not a valid URL.', 400));
}
const clients = await OAuthClient.listDetail();
const allowed = clients.some(client =>
(client.redirect_uris || []).some(uri => {
try { return new URL(uri).origin === requested.origin; }
catch(_) { return false; }
})
);
if (!allowed) {
return next(makeError('InvalidRedirectURI', 'post_logout_redirect_uri origin is not registered for any client.', 400));
}
if (state) requested.searchParams.set('state', state);
target = requested.toString();
}
res.render('oauth_logout', { ...pageLocals, target });
} 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 (!redirectUriAllowed(client.redirect_uris, redirect_uri)) {
return next(makeError('InvalidRedirectURI', 'redirect_uri is not registered for this client.', 400));
}
// Group-based access control: if the client restricts to specific groups,
// only members of at least one of them may obtain an authorization code.
if (client.allowed_groups && client.allowed_groups.length) {
const groups = await userGroups(req.user);
if (!client.allowed_groups.some(g => groups.includes(g))) {
return next(makeError('AccessDenied', 'You are not a member of a group permitted to use this application.', 403));
}
}
// Only grant scopes the client is actually registered for (a direct API
// caller could otherwise request scopes the consent screen filtered out).
const grantedScope = (scope || 'openid')
.split(' ')
.filter(Boolean)
.filter(s => client.scopes.includes(s))
.join(' ') || 'openid';
const authCode = await OAuthCode.add({
username: req.user.uid,
client_id,
redirect_uri,
scope: grantedScope,
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`,
end_session_endpoint: `${base}/oauth/logout`,
scopes_supported: ['openid', 'profile', 'email', 'groups'],
claims_supported: ['sub', 'preferred_username', 'name', 'given_name', 'family_name', 'email', 'groups'],
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, redirectUriAllowed };