OAuth是一个安全授权框架,支持在不同程序(或者微服务)间的资源访问,也可以在OAuth体系下实现单点登录的功能。OAuth安全框架中包括四个角色:资源拥有者,授权服务器,资源服务器,客户端程序。OAuth安全框架主要说明了这四个角色间的交互流程,以及授权服务器应该具有的能力。OAuth框架是非常灵活和高度可扩展的,一些组件,如token结构组件或者加密算法都是可插拔,可以根据场景需求进行集成。OAuth框架由众多的RFC协议定义,其核心是授权服务器的能力和token的使用,由于其灵活性,其适用范围比较广泛,但是带来的挑战是,如果使用不当,很可能会造成安全隐患。
Spring 对OAuth框架的支持经历了从Spring Security OAuth到Spring Security的迁移,现在社区正在基于新功能开发Spring Authorization Server。 纵观整个过程稍显混乱。
本文基于OAuth in action一书上的设计思想,使用JavaScript语言实现授权服务器的功能,包括:基于授权码,客户端密钥,和用户名密码的授权;动态客户端注册,反注册;基于RSA非对称加密的JWT格式的token的一致性检查;token的内省,注销等功能。
基于Node.js的工程目录如下:
main.js文件主要时定义了授权服务器的RESTful接口,其内容如下:
const express = require("express");
const bodyParser = require("body-parser");
const cons = require("consolidate");
const {oauthServerPort} = require("./const");
const parseArgv = require("./argv").parseArgv
const {initController, registerCallback, approveCallback, authorizeCallback, tokenCallback, getClientConfigCallback,
deleteClientConfigCallback, introspectCallback, revokeCallback, pubJwkCallback} = require("./controller");
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use("/", express.static("static"));
app.engine("html", cons.underscore);
app.set("view engine", "html");
app.set("views", "static");
app.set("json spaces", 2);
app.post("/register", registerCallback);
app.get("/authorize", authorizeCallback);
app.post("/approve", approveCallback);
app.post("/token", tokenCallback);
app.post("/introspect", introspectCallback);
app.post("/revoke", revokeCallback);
app.get("/pubjwk", pubJwkCallback);
app.get("/register/:clientId", getClientConfigCallback);
// Not support this operation, as an alternative you can delete the exist client and register a new one.
// app.put("/register/:clientId", updateClientConfigCallback);
app.delete("/register/:clientId", deleteClientConfigCallback);
parseArgv();
initController();
const server = app.listen(oauthServerPort, function() {
let address = server.address().address;
let port = server.address().port;
console.log("OAuth server is listening at http://%s:%s", address, port);
});
controller.js是RESTful接口中注册的回调实现,是授权服务器能力的主要实现,其内容为:
const randomstring = require("randomstring");
const __ = require("underscore");
__.string = require("underscore.string");
const crypto = require("crypto");
const base64url = require("base64url");
const {md5} = require("request/lib/helpers");
const assert = require("assert");
const consts = require("./const");
const {buildUrl, decodeClientCredential} = require("./common");
const {initCrypto, signJwtToken, getPubJwk} = require("./crypto");
let {clients, tokens, users} = require("./db");
const {initDb, insertClient, insertToken, findAndDeleteTokenByRefreshToken, findClientById, deleteClientById, updateClientEntry,
deleteTokenByAccessToken, findTokenByAccessToken, deleteTokenByClientIdAndGrantType, findUserByName} = require("./db");
// Authorize request and code, I think, not necessary write to database, as the token request
// will come in a short while after these requests.
const codes = {};
const requests = {};
const checkClientRegisterData = function(req, res) {
console.log("Checking register client data...");
const register = {};
if (!checkRegisterAllElementLength(req.body)) {
res.status(consts.httpCode400).json({error: 'Register info too long.'});
console.error("Register info too long.");
return;
}
for (let ele_type in req.body) {
if (!checkRegisterElementLength(req.body[ele_type], ele_type)) {
res.status(consts.httpCode400).json({error: 'Register info too long of:' + ele_type});
console.error("Register info too long of:" + ele_type);
return;
}
}
register.token_endpoint_auth_method = req.body.token_endpoint_auth_method;
const allowedClientSecretSendMode = [consts.clientSecretSendModeByHeader, consts.clientSecretSendModeByForm];
if (!__.isString(register.token_endpoint_auth_method) || !__.contains(allowedClientSecretSendMode, register.token_endpoint_auth_method)) {
res.status(consts.httpCode400).json({error: 'Invalid client secret send mode.'});
console.error("Invalid token_endpoint_auth_method:" + register.token_endpoint_auth_method);
return;
}
register.grant_types = req.body.grant_types;
if (!__.isArray(register.grant_types) || __.isEmpty(register.grant_types)) {
res.status(consts.httpCode400).json({error: 'Invalid grant types.'});
console.error("Invalid grant types:" + register.grant_types);
return;
} else {
assert(__.isArray(register.grant_types))
const allowedGrantType = [consts.grantTypeAuthorizationCode, consts.grantTypeRefreshToken,
consts.grantTypeClientCredentials, consts.grantTypePassword];
for (let grantType of register.grant_types) {
if (!__.contains(allowedGrantType, grantType)) {
res.status(consts.httpCode400).json({error: 'Invalid grant types.'});
console.error("Invalid grant types:" + register.grant_types);
return;
}
}
}
if (__.contains(register.grant_types, consts.grantTypeAuthorizationCode)) {
if (!req.body.redirect_uris || !__.isArray(req.body.redirect_uris) || __.isEmpty(req.body.redirect_uris)) {
res.status(consts.httpCode400).json({error: 'invalid redirect uri.'});
console.error("Grant type 'authorization_code' has invalid redirect uri:" + req.body.redirect_uris);
return;
} else {
// Redirect uri should have path section.
// TODO: Query section, I think, is not allowed.
register.redirect_uris = req.body.redirect_uris;
for (let redirectUri of register.redirect_uris) {
let urlObj = new URL(redirectUri);
if (__.isEmpty(urlObj.pathname) || urlObj.pathname === '/') {
res.status(consts.httpCode400).json({error: 'invalid redirect uri.'});
console.error("Redirect uri SHOULD have specific path section:" + redirectUri);
return;
}
}
if (isRedirectUrisInBlackList(register.redirect_uris)) {
res.status(consts.httpCode400).json({error: 'invalid redirect uri.'});
console.error("Redirect uri in black list.");
return;
}
}
}
if (typeof (req.body.client_name) === 'string' && !__.isEmpty(req.body.client_name)) {
register.client_name = req.body.client_name;
} else {
res.status(consts.httpCode400).json({error: 'invalid client name.'});
console.error("Invalid client name:" + req.body.client_name);
return;
}
// Scope is optional.
register.scope = ""
if (typeof (req.body.scope) === 'string') {
register.scope = req.body.scope;
} else if (req.body.scope !== undefined) {
res.status(consts.httpCode400).json({error: 'invalid scope format.'});
console.error("Invalid scope format:" + req.body.scope);
return;
}
if (!__.isEmpty(req.body.client_uri)) {
if (typeof (req.body.client_uri) === 'string') {
register.client_uri = req.body.client_uri;
if (!__.isEmpty(register.redirect_uris)) {
for (let redirectUri of register.redirect_uris) {
if (!__.string.startsWith(redirectUri, register.client_uri)) {
res.status(consts.httpCode400).json({error: 'redirect uri SHOULD have the prefix of client uri.'});
console.error("redirect uri SHOULD have the prefix of client uri.");
return;
}
}
}
} else {
res.status(consts.httpCode400).json({error: 'invalid client uri format.'});
console.error("Invalid client uri format.");
return;
}
}
return register;
};
function isRedirectUrisInBlackList(redirectUris) {
return false;
}
const getScopesFromForm = function(body) {
return __.filter(__.keys(body), function(s) { return __.string.startsWith(s, 'scope_'); })
.map(function(s) { return s.slice('scope_'.length); });
};
const getClientById = async function(clientId) {
const client = __.find(clients, function (client) {
return client.client_id === clientId;
});
if (client) {
return client;
}
return await findClientById(clientId);
};
const authorizeClientManageRequest = async function(req, res) {
const clientId = req.params.clientId;
const client = await getClientById(clientId);
if (!client) {
console.error("Invalid client id:" + clientId);
res.status(consts.httpCode404).end();
return;
}
const auth = req.headers['authorization'];
if (auth && auth.toLowerCase().indexOf('bearer') === 0) {
const regToken = auth.slice('bearer '.length);
if (regToken === client.registration_access_token) {
req.client = client;
return req;
} else {
console.error("Registration access token mismatch. expected:%s got:%s client id:%s",
client.registration_access_token, regToken, clientId);
res.status(consts.httpCode403).end();
}
} else {
console.error("Invalid auth:" + auth);
res.status(consts.httpCode401).end();
}
};
///
// register endpoint.
function registerCallback(req, res) {
console.log("Register client request is coming.");
const register = checkClientRegisterData(req, res);
if (!register) {
// Already send error response.
return;
}
register.client_id = randomstring.generate();
register.client_secret = randomstring.generate();
register.client_id_created_at = Math.floor(Date.now() / 1000);
register.client_secret_expires_at = 0;
register.registration_access_token = randomstring.generate();
// TODO: Replace the localhost with ip address.
register.registration_client_uri = 'http://localhost:9001/register/' + register.client_id;
saveClientRegisterData(register);
res.status(consts.httpCode201).json(register);
console.log("Register the client:\n" + JSON.stringify(register, null, " "));
}
function saveClientRegisterData(register) {
clients.push(register);
const length = clients.length;
console.log("Cached client count:" + length);
if (length > consts.maxCountOfCachedClients) {
clients.shift();
}
insertClient(register);
}
function checkRegisterAllElementLength(body) {
return JSON.stringify(body).length < consts.maxSizeOfRegisterObject;
}
function checkRegisterElementLength(element, type) {
switch (type) {
case "client_name":
return element.length < consts.maxSizeOfRegisterKeyOfClientName;
case "redirect_uris":
return JSON.stringify(element).length < consts.maxSizeOfRegisterKeyOfRedirectUris;
case "scope":
return element.length < consts.maxSizeOfRegisterKeyOfScope;
default:
return JSON.stringify(element).length < consts.maxSizeOfRegisterDefaultKey;
}
}
///
// Approve endpoint.
async function approveCallback(req, res) {
console.log("User submitted ack form.");
const reqid = req.body.reqid;
const query = requests[reqid];
delete requests[reqid];
if (!query) {
res.render('error', {error: 'Mismatch authorize request.'});
return;
}
if (req.body.approve) {
console.log("User approved the access.");
const rscope = getScopesFromForm(req.body);
const client = await getClientById(query.client_id);
assert(client);
const cscope = client.scope ? client.scope.split(' ') : undefined;
if (__.difference(rscope, cscope).length > 0) {
const urlParsed = buildUrl(query.redirect_uri, {
error: 'invalid_scope'
});
res.redirect(urlParsed);
return;
}
// Corner case: If user not select any scope and click the approve button.
if (__.isEmpty(rscope)) {
const urlParsed = buildUrl(query.redirect_uri, {
error: 'access_denied'
});
res.redirect(urlParsed);
return;
}
// Transfer the array to string.
const scope = rscope.join(" ");
// Generate the authorize code.
const code = randomstring.generate(8);
// Save the code and request.
codes[code] = {request: query, scope: scope};
const urlParsed = buildUrl(query.redirect_uri, {
code: code,
state: query.state
});
res.redirect(urlParsed);
} else {
// User denied access.
console.log("User denied the access.");
const urlParsed = buildUrl(query.redirect_uri, {
error: 'access_denied'
});
res.redirect(urlParsed);
}
}
// authorize endpoint.
async function authorizeCallback(req, res) {
console.log("Authorize request is coming.");
const checked = await checkAuthorizeReq(req, res);
if (__.isEmpty(checked)) {
console.error("authorizeCallback x");
return;
}
const reqId = randomstring.generate(8);
requests[reqId] = req.query;
res.render('approve', {client: checked.client, reqid: reqId, scope: checked.scope});
}
async function checkAuthorizeReq(req, res) {
const client = await getClientById(req.query.client_id);
if (__.isEmpty(client)) {
console.error('Unknown client %s.', req.query.client_id);
res.render('error', {error: 'Unknown client'});
return;
}
if (req.query.response_type !== consts.responseTypeOfAuthorizationCode) {
console.error('Authorize request with invalid response type:' + req.query.response_type);
res.render('error', {error: 'invalid response type.'});
return;
}
if (__.isEmpty(req.query.state)) {
console.error('Authorize request without state section.');
res.render('error', {error: 'missing state.'});
return;
}
if (!__.contains(client.redirect_uris, req.query.redirect_uri)) {
console.error('Mismatched redirect URI, expected %s got %s', client.redirect_uris, req.query.redirect_uri);
res.render('error', {error: 'Invalid redirect URI'});
return;
}
const rscope = req.query.scope ? req.query.scope.split(' ') : undefined;
const cscope = client.scope ? client.scope.split(' ') : undefined;
if (__.difference(rscope, cscope).length > 0) {
let urlParsed = buildUrl(req.query.redirect_uri, {
error: 'invalid_scope'
});
res.redirect(urlParsed);
return;
}
// Use array format to render in approve html page.
const scope = rscope || cscope;
return {client: client, scope: scope};
}
///
// token endpoint.
async function tokenCallback(req, res) {
console.log("Token post request is coming with grant type:" + req.body.grant_type);
const client = await checkClientCredential(req, res);
if (!client) {
return;
}
const clientId = client.client_id;
// Check if the grant type is allowed for the client.
if (!__.contains(client.grant_types, req.body.grant_type)) {
console.error('Token post request with unsupported grant type:' + req.body.grant_type +
"\nclient:\n" + JSON.stringify(client));
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
if (req.body.grant_type === consts.grantTypeAuthorizationCode) {
const code = codes[req.body.code];
if (code) {
delete codes[req.body.code];
if (code.request.client_id === clientId) {
deleteTokenByClientIdAndGrantType(clientId, consts.grantTypeAuthorizationCode);
// PKCE verify.
if (code.request.code_challenge) {
console.log("Pkce verify. code challenge:%s method:%s", code.request.code_challenge, code.request.code_challenge_method);
let code_challenge;
if (code.request.code_challenge_method === consts.pkceCodeChallengeMethodPlain) {
code_challenge = req.body.code_verifier;
} else if (code.request.code_challenge_method === consts.pkceCodeChallengeMethodS256) {
code_challenge = base64url.fromBase64(crypto.createHash('sha256').update(req.body.code_verifier).digest('base64'));
} else {
console.error('Unknown code challenge method:', code.request.code_challenge_method);
res.status(consts.httpCode400).json({error: 'invalid_request'});
return;
}
if (code.request.code_challenge !== code_challenge) {
console.error('Code challenge mismatch, expected %s got %s', code.request.code_challenge, code_challenge);
res.status(consts.httpCode400).json({error: 'invalid_request'});
return;
}
}
let scope = code.scope;
if (__.isArray(scope)) {
scope = scope.join(" ");
}
const accessTokenFormat = consts.tokenFormatJWT;
const expire = generateTokenExpire();
const accessToken = generateAccessToken(accessTokenFormat, scope, expire);
const refreshToken = generateRefreshToken();
const tokenInfo = constructTokenInfoWithAuthorizationCode(clientId, scope, accessToken, refreshToken, expire, accessTokenFormat);
saveTokenInfo(tokenInfo);
const tokenResponse = {
access_token: accessToken,
token_type: consts.tokenType,
refresh_token: refreshToken,
scope: scope
};
res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
console.log('Issuing access token: %s, refresh token: %s for code:%s', accessToken, refreshToken, req.body.code);
} else {
console.error('Token post request found client mismatch, expected %s got %s', code.request.client_id, clientId);
res.status(consts.httpCode400).json({error: 'invalid_grant'});
}
} else {
console.error('Token post request with unknown code: %s', req.body.code);
res.status(consts.httpCode400).json({error: 'invalid_grant'});
}
} else if (req.body.grant_type === consts.grantTypeRefreshToken) { // Notice: Allowing the refresh request if the access token is still valid.
const p = getAndDeleteTokenByRefreshToken(req.body.refresh_token);
p.then(doc => {
if (__.isEmpty(doc)) {
console.error('Not found refresh token: %s in database.', req.body.refresh_token);
res.status(consts.httpCode400).json({error: 'invalid_grant'});
} else {
console.log("Found refresh token in record:\n%s", JSON.stringify(doc, null, " "));
if (clientId !== doc.client_id) {
res.status(consts.httpCode400).json({error: 'invalid_grant'});
console.error("Refresh token belongs to client: %s, but from client: %s", doc.client_id, clientId);
// TODO: Take any action to handle these two clients?
return;
}
// The original grant type, it must be authorization code or password.
const grantType = doc.grant_type;
assert(grantType in [consts.grantTypeAuthorizationCode, consts.grantTypePassword]);
const accessTokenFormat = consts.tokenFormatJWT;
const expire = generateTokenExpire();
const accessToken = generateAccessToken(accessTokenFormat, doc.scope, expire);
const refreshToken = generateRefreshToken();
let tokenInfo = null;
if (grantType === consts.grantTypeAuthorizationCode) {
tokenInfo = constructTokenInfoWithAuthorizationCode(clientId, doc.scope, accessToken, refreshToken, expire, accessTokenFormat);
} else {
tokenInfo = constructTokenInfoWithPassword(clientId, doc.user_name, doc.scope, accessToken, refreshToken, expire, accessTokenFormat);
}
saveTokenInfo(tokenInfo);
const tokenResponse = {
access_token: accessToken,
token_type: consts.tokenType,
refresh_token: refreshToken,
scope: doc.scope
};
res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
}
}).catch(e => {
console.error("Refresh error by:%s msg:%s.", req.body.refresh_token, e.message);
res.status(consts.httpCode500).json({error: 'internal error.'});
});
} else if (req.body.grant_type === consts.grantTypeClientCredentials) {
// Notice: Allowing the request if the access token is still valid.
// Not necessary to generate refresh token for 'client_credentials' request.
const rscope = req.body.scope ? req.body.scope.split(' ') : undefined;
const cscope = client.scope ? client.scope.split(' ') : undefined;
if (__.difference(rscope, cscope).length > 0) {
res.status(consts.httpCode400).json({error: 'invalid_scope'});
console.error("Invalid scope.");
return;
}
// If the request scope is valid, grant it.
const tokenScope = req.body.scope || client.scope;
deleteTokenByClientIdAndGrantType(clientId, consts.grantTypeClientCredentials);
const accessTokenFormat = consts.tokenFormatJWT;
const expire = generateTokenExpire();
const accessToken = generateAccessToken(accessTokenFormat, tokenScope, expire);
const tokenInfo = constructTokenInfoWithClientCredentials(clientId, tokenScope, accessToken, expire, accessTokenFormat);
saveTokenInfo(tokenInfo);
const tokenResponse = {access_token: accessToken, token_type: consts.tokenType, scope: tokenScope};
res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
} else if (req.body.grant_type === consts.grantTypePassword) {
const userName = req.body.username;
const p = getUser(userName);
p.then(user => {
if (!user) {
res.status(consts.httpCode401).json({error: 'invalid_grant'});
console.error("Token post request with invalid user name:" + userName);
return;
}
// Check password.
const password = req.body.password;
if (!checkPasswordForTokenRequest(password, user)) {
console.error('Check password error, user name:%s password:%s', userName, password);
res.status(consts.httpCode401).json({error: 'invalid_grant'});
return;
}
// For grant password, scope is stored in user database.
const rscope = req.body.scope ? req.body.scope.split(' ') : undefined;
const uscope = user.scope ? user.scope.split(' ') : undefined;
if (__.difference(rscope, uscope).length > 0) {
res.status(consts.httpCode401).json({error: 'invalid_scope'});
console.error("Token post request with unsupported scope:" + req.body.scope);
return;
}
deleteTokenByClientIdAndGrantType(clientId, consts.grantTypePassword);
// The request scope may narrow the allowed scope.
const tokenScope = req.body.scope || user.scope;
const accessTokenFormat = consts.tokenFormatJWT;
const expire = generateTokenExpire();
const accessToken = generateAccessToken(accessTokenFormat, tokenScope, expire);
const refreshToken = generateRefreshToken();
const tokenInfo = constructTokenInfoWithPassword(clientId, userName, tokenScope, accessToken, refreshToken, expire, accessTokenFormat);
saveTokenInfo(tokenInfo);
const tokenResponse = {
access_token: accessToken,
token_type: consts.tokenType,
refresh_token: refreshToken,
scope: tokenScope
};
res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
}).catch(e => {
console.error("Get user error by name:%s msg:%s.", userName, e.message);
res.status(consts.httpCode500).json({error: 'internal error.'});
});
} else {
console.error('Unknown grant type %s', req.body.grant_type);
res.status(consts.httpCode400).json({error: 'unsupported grant type'});
}
}
function saveTokenInfo(token) {
tokens.push(token);
const length = tokens.length;
if (length > consts.maxCountOfCachedTokens) {
tokens.shift();
}
insertToken(token);
}
// Notice: Return value is a promise, use 'then()' callback to handle response.
function getAndDeleteTokenByRefreshToken(refreshToken) {
tokens = __.reject(tokens, __.matches({refresh_token: refreshToken}));
return findAndDeleteTokenByRefreshToken(refreshToken);
}
///
// introspection endpoint.
async function introspectCallback(req, res) {
console.log("Introspect request is coming.");
const auth = req.headers['authorization'];
if (!auth) {
console.error("No auth header.");
res.status(consts.httpCode404).end();
return;
}
const credential = decodeClientCredential(auth);
const clientId = credential.id;
const clientSecret = credential.secret;
const client = await getResourceById(clientId);
if (!client) {
console.error('Unknown resource server id: %s', clientId);
res.status(consts.httpCode404).end();
return;
}
if (client.client_secret !== clientSecret) {
console.error('Resource secret error, expected %s got %s', client.client_secret, clientSecret);
res.status(consts.httpCode401).end();
return;
}
const accessToken = req.body.token;
const tokenInfo = await getTokenByAccessToken(accessToken);
let expired = false;
if (tokenInfo) {
console.log("Found access token:%s.", accessToken);
expired = isTokenExpired(tokenInfo);
if (!expired) {
const introspectionResponse = {
active: true,
username: tokenInfo.user_name,
scope: tokenInfo.scope,
client_id: tokenInfo.client_id,
exp: tokenInfo.expire
};
res.status(consts.httpCode200).json(introspectionResponse);
return;
}
}
if (expired) {
console.warn("Access token:%s expired.", accessToken);
} else {
console.error("Not found access token:%s.", accessToken);
}
const introspectionResponse = {
active: false
};
res.status(consts.httpCode200).json(introspectionResponse);
}
async function getTokenByAccessToken(accessToken) {
accessToken = md5(accessToken);
const token = __.find(tokens, function (token) {
return token.access_token === accessToken;
});
if (token) {
return token;
}
return await findTokenByAccessToken(accessToken);
}
function isTokenExpired(token) {
assert(!__.isEmpty(token.expire));
return Date.now() > token.expire;
}
async function getResourceById(resourceId) {
return await getClientById(resourceId);
}
///
// revoke endpoint.
async function revokeCallback(req, res) {
console.log("Revoke request is coming.");
const auth = req.headers['authorization'];
let clientId;
let clientSecret;
if (auth) {
const credential = decodeClientCredential(auth);
clientId = credential.id;
clientSecret = credential.secret;
}
if (req.body.client_id) {
if (clientId) {
console.warn('Duplicated client secret.');
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
clientId = req.body.client_id;
clientSecret = req.body.client_secret;
}
const client = await getClientById(clientId);
if (!client) {
console.error('Unknown client: %s', clientId);
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
if (client.client_secret !== clientSecret) {
console.error('Client secret error, expected %s got %s', client.client_secret, clientSecret);
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
const accessToken = req.body.token;
const tokenInfo = await getTokenByAccessToken(accessToken);
// Notice: Always return successful response in case of token probe.
if (!tokenInfo) {
console.error("Not found access token:" + accessToken);
} else {
removeTokenByAccessToken(accessToken);
}
res.status(consts.httpCode204).end();
}
function removeTokenByAccessToken(accessToken) {
accessToken = md5(accessToken);
tokens = __.reject(tokens, __.matches({access_token: accessToken}));
deleteTokenByAccessToken(accessToken);
}
///
// public JWK endpoint.
async function pubJwkCallback(req, res) {
console.log("Public JWK request is coming.");
await checkClientCredential();
const pubjwk = getPubJwk();
res.status(consts.httpCode200).json({pubjwk: pubjwk});
}
// Client credential can be send both in the header and post body, but only one way is present in the same request.
async function checkClientCredential(req, res) {
const auth = req.headers['authorization'];
let clientId;
let clientSecret;
if (auth) {
const credential = decodeClientCredential(auth);
clientId = credential.id;
clientSecret = credential.secret;
}
if (req.body.client_id) {
if (clientId) {
console.warn('Duplicated client secret.');
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
clientId = req.body.client_id;
clientSecret = req.body.client_secret;
}
const client = await getClientById(clientId);
if (!client) {
console.error('Unknown client: %s', clientId);
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
if (client.client_secret !== clientSecret) {
console.error('Client secret error, expected %s got %s', client.client_secret, clientSecret);
res.status(consts.httpCode401).json({error: 'invalid_client'});
return;
}
return client;
}
function getUser(userName) {
return findUserByName(userName);
}
function generateTokenExpire(expire = consts.tokenDefaultExpire) {
return Date.now() + expire;
}
function constructTokenInfoWithAuthorizationCode(clientId, scope, accessToken, refreshToken, expire, format) {
return constructTokenInfo(clientId, "", scope, consts.grantTypeAuthorizationCode, format, expire, accessToken, refreshToken);
}
function constructTokenInfoWithClientCredentials(clientId, scope, accessToken, expire, format) {
return constructTokenInfo(clientId, "", scope, consts.grantTypeClientCredentials, format, expire, accessToken, "");
}
function constructTokenInfoWithPassword(clientId, userName, scope, accessToken, refreshToken, expire, format) {
return constructTokenInfo(clientId, userName, scope, consts.grantTypePassword, format, expire, accessToken, refreshToken);
}
function generateAccessToken(format = consts.tokenFormatJWT, scope, expire) {
if (format === consts.tokenFormatJWT) {
return generateJwtAccessToken(scope, expire);
} else {
return randomstring.generate();
}
}
function generateRefreshToken() {
return randomstring.generate();
}
function generateJwtAccessToken(scope, expire) {
const header = { "typ": "JWT", "alg": "RS256" };
const payload = {
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(expire / 1000),
scope: scope,
jti: randomstring.generate(8)
};
return signJwtToken(header.alg, JSON.stringify(header), JSON.stringify(payload));
}
// Token info: client id, user(depends on grant type), scope, grant type, expire, format, access token, refresh token(optional).
function constructTokenInfo(clientId, userName, scope, grantType, format, expire, accessToken, refreshToken) {
accessToken = md5(accessToken);
return {client_id: clientId, user_name: userName, scope: scope, grant_type: grantType, format: format, expire: expire,
access_token: accessToken, refresh_token: refreshToken};
}
///
// client config management endpoint.
async function getClientConfigCallback(req, res) {
console.log("Client config get request is coming.");
const checked = await authorizeClientManageRequest(req, res);
if (checked) {
// Update the client secret and registration access token each get request.
req.client.client_secret = randomstring.generate();
req.registration_access_token = randomstring.generate();
// Update clients cache and database.
updateClient(req.client, "client_secret", "registration_access_token");
res.status(consts.httpCode200).json(req.client);
}
}
async function updateClient(clientNew, ...keys) {
const client = __.find(clients, function (client) {
return client.client_id === clientNew.client_id;
});
if (client) {
for (let key in keys) {
client[key] = clientNew[key];
}
}
await updateClientEntry(clientNew, keys);
}
async function deleteClientConfigCallback(req, res) {
console.log("Client config delete request is coming.");
const checked = await authorizeClientManageRequest(req, res);
if (checked) {
clients = __.reject(clients, __.matches({client_id: req.client.client_id}));
deleteClientById(req.client.client_id)
res.status(consts.httpCode204).end();
}
}
// TODO:
function removeTokenOfClient(clientId, grantType) {
assert(grantType in [consts.grantTypeAuthorizationCode, consts.grantTypeClientCredentials, consts.grantTypePassword]);
console.error("Not implemented.");
}
function checkPasswordForTokenRequest(password, user) {
// TODO: Not implemented yet.
// Load password from user database and compare them.
return true;
}
function initController() {
initCrypto();
initDb();
}
module.exports = {initController, registerCallback, approveCallback, authorizeCallback, tokenCallback,
getClientConfigCallback, deleteClientConfigCallback, introspectCallback, revokeCallback, pubJwkCallback};
argv.js文件是程序启动时的命令行参数解析,其内容为:
const assert = require("assert");
const argv = {}
function isDevMode() {
return argv.deployMode === "dev";
}
function isProdMode() {
return argv.deployMode === "prod";
}
function parseItem(item) {
const kv = item.trim().split("=");
assert(kv.length === 2);
return kv;
}
function parseArgv() {
const argvLength = process.argv.length;
assert(argvLength > 2)
for (let i = 2; i < argvLength; i++) {
let item = process.argv[i];
let kv = parseItem(item);
switch (kv[0]) {
case "deploy":
argv.deployMode = kv[1];
break;
case "mongo_type":
argv.mongoType = kv[1];
break;
case "mongo_atlas_user":
argv.mongoAtlasUser = kv[1];
break;
case "mongo_atlas_password":
argv.mongoAtlasPassword = kv[1];
break;
case "mongo_local_user":
argv.mongoLocalUser = kv[1];
break;
case "mongo_local_password":
argv.mongoLocalPassword = kv[1];
break;
case "mongo_local_uri":
argv.mongoLocalUri = kv[1];
break;
case "del_col":
argv.deleteCollection = kv[1];
break;
default:
console.warn("Unsupported input parameter:" + kv[0]);
break;
}
}
if (isDevMode()) {
console.log("In dev mode.");
} else {
console.log("In prod mode.");
}
}
exports.argv = argv;
exports.parseArgv = parseArgv;
exports.isDevMode = isDevMode;
exports.isProdMode = isProdMode;
文件const.js是一些常量定义,其内容为:
const oauthServerPort = 9001;
const httpCode200 = 200;
const httpCode201 = 201;
const httpCode204 = 204;
const httpCode400 = 400;
const httpCode401 = 401;
const httpCode403 = 403;
const httpCode404 = 404;
const httpCode500 = 500;
// It is the access token expire,
// no expire for refresh token as we always allocate a new refresh token when grant type is refresh_token.
const tokenDefaultExpire = 86400000 * 2; // 2 days.
const tokenFormatPlain = "plain";
const tokenFormatJWT = "jwt";
const tokenType = "Bearer";
const grantTypeAuthorizationCode = "authorization_code";
const grantTypeRefreshToken = "refresh_token";
const grantTypeClientCredentials = "client_credentials";
const grantTypePassword = "password";
const httpResHeaderKeyOfTokenExpire = "expire-in";
const responseTypeOfAuthorizationCode = "code";
const maxSizeOfRegisterObject = 4096;
const maxSizeOfRegisterKeyOfClientName = 64;
const maxSizeOfRegisterKeyOfRedirectUris = 1024;
const maxSizeOfRegisterKeyOfScope = 2048;
const maxSizeOfRegisterDefaultKey = 1024;
const clientSecretSendModeByHeader = "secret_basic";
const clientSecretSendModeByForm = "secret_post";
const maxCountOfCachedClients = 1000;
const maxCountOfCachedTokens = 1000;
const maxCountOfCachedUsers = 2000;
const pkceCodeChallengeMethodPlain = "plain";
const pkceCodeChallengeMethodS256 = "S256";
module.exports = {oauthServerPort, tokenDefaultExpire, tokenFormatPlain, tokenFormatJWT, tokenType, grantTypeAuthorizationCode,
grantTypeClientCredentials, grantTypeRefreshToken, grantTypePassword, httpResHeaderKeyOfTokenExpire,
responseTypeOfAuthorizationCode, maxSizeOfRegisterObject, maxSizeOfRegisterKeyOfClientName,
maxSizeOfRegisterKeyOfRedirectUris, maxSizeOfRegisterKeyOfScope, maxSizeOfRegisterDefaultKey,
clientSecretSendModeByHeader, clientSecretSendModeByForm, httpCode200, httpCode201, httpCode204, httpCode400, httpCode401,
httpCode403, httpCode404, httpCode500, maxCountOfCachedClients, maxCountOfCachedTokens, maxCountOfCachedUsers,
pkceCodeChallengeMethodPlain, pkceCodeChallengeMethodS256};
文件crypto.js是支持JWT格式token的加密和完整性检查的逻辑,其内容为:
const rs = require("jsrsasign");
const rsu = require("jsrsasign-util");
const __ = require("underscore");
const assert = require("assert");
const base64url = require("base64url");
const pubKeyPemFile = "crypto.pub.pem";
const prvKeyPemFile = "crypto.prv.pem";
let prvKey;
let pubJWK;
function initCrypto() {
console.log("Init crypto...");
const pubPemStr = rsu.readFile(pubKeyPemFile);
const pubKeyLoaded = rs.KEYUTIL.getKey(pubPemStr);
pubJWK = rs.KEYUTIL.getJWKFromKey(pubKeyLoaded);
const prvPemStr = rsu.readFile(prvKeyPemFile);
const prvKeyLoaded = rs.KEYUTIL.getKey(prvPemStr);
const prvJWK = rs.KEYUTIL.getJWKFromKey(prvKeyLoaded);
prvKey = rs.KEYUTIL.getKey(prvJWK);
}
function signJwtToken(alg, header, payload) {
assert(!__.isEmpty(prvKey));
return rs.jws.JWS.sign(alg, header, payload, prvKey);
}
function getPubJwk() {
assert(!__.isEmpty(pubJWK));
return JSON.stringify(pubJWK);
}
module.exports = {initCrypto, signJwtToken, getPubJwk};
文件db.js是OAuth授权服务器后端存储,本文使用MongoDB存储动态客户端数据,token数据,用户凭据信息等等。其内容为:
const __ = require("underscore");
const { MongoClient } = require('mongodb');
const assert = require("assert");
const argv = require("./argv").argv
const {maxCountOfCachedClients, maxCountOfCachedTokens, maxCountOfCachedUsers} = require("./const");
let clientDriver;
let oauthCollectionClient;
let oauthCollectionToken;
let userCollectionInfo;
// These 3 array are the memory cache of the given database store.
// Notice: If the resource server check token using introspection, it should register itself to
// authorization server like client when startup. Here we use the same cache to save these two kind of
// register information.
let clients = [];
let tokens = [];
let users = [];
function useMongoAtlas() {
return argv.mongoType === "atlas";
}
function useMongoLocal() {
return argv.mongoType === "local";
}
async function getClientDriver() {
let uri = null;
if (useMongoAtlas()) {
console.log("Using mongo atlas.");
assert(typeof argv.mongoAtlasUser === "string");
assert(typeof argv.mongoAtlasPassword === "string");
uri = `mongodb+srv://${argv.mongoAtlasUser}:${argv.mongoAtlasPassword}@cluster-fred.emwlw.mongodb.net/?retryWrites=true&w=majority`;
} else {
assert(false, "Mongo local NOT deploy yet.");
console.log("Using mongo local.");
assert(typeof argv.mongoLocalUser === "string");
assert(typeof argv.mongoLocalPassword === "string");
assert(typeof argv.mongoLocalUri === "string");
}
console.log("Create mongo driver client from uri:" + uri);
clientDriver = new MongoClient(uri);
try {
await clientDriver.connect();
} catch (e) {
console.error("Connection db error:" + e);
process.exit(1);
}
}
function OpenOauthDb() {
const oauthDb = clientDriver.db("oauth");
oauthCollectionClient = oauthDb.collection("client");
oauthCollectionToken = oauthDb.collection("token");
console.log("Open oauth database.");
}
function OpenUserDb() {
const userDb = clientDriver.db("user");
userCollectionInfo = userDb.collection("info");
console.log("Open user database.");
}
async function dropCollectionIfNecessary() {
if (argv.deleteCollection !== undefined) {
assert(typeof (argv.deleteCollection) === "string");
const items = argv.deleteCollection.trim().split("&");
console.log("Clear collection:%s when startup.", argv.deleteCollection);
assert(items.length > 0);
for (let item of items) {
const pair = item.split(":");
try {
await clientDriver.db(pair[0]).dropCollection(pair[1]);
} catch (e) {
console.error("Drop collection:%s:%s error:%s.", pair[0], pair[1], e.message);
}
}
}
}
async function insertClient(client) {
const result = await oauthCollectionClient.insertOne(client);
console.log(`Client inserted with id: ${result.insertedId}`);
}
async function insertToken(token) {
const result = await oauthCollectionToken.insertOne(token);
console.log(`Token inserted with id: ${result.insertedId}`);
}
// For refresh token grant type, always allocate the new access token and refresh token,
// so we delete the previous token record.
// Notice: the return value is a promise, call the 'then()' to handle the callback of database.
function findAndDeleteTokenByRefreshToken(refreshToken) {
const query = { refresh_token: refreshToken };
return oauthCollectionToken.findOneAndDelete(query);
}
async function findTokenByAccessToken(accessToken) {
const query = {access_token: accessToken};
const result = await oauthCollectionToken.findOne(query);
console.log("Find " + (result == null ? 0 : 1) + " token by access token:" + accessToken);
return result;
}
async function deleteTokenByRefreshToken(refreshToken) {
const query = { refresh_token: refreshToken };
const result = await oauthCollectionToken.deleteOne(query);
console.log("Deleted " + result.deletedCount + " token by refresh token:" + refreshToken);
}
async function deleteTokenByAccessToken(accessToken) {
const query = {access_token: accessToken};
const result = await oauthCollectionToken.deleteOne(query);
console.log("Deleted " + result.deletedCount + " token by access token:" + accessToken);
}
async function deleteTokenByClientId(clientId) {
const query = { client_id: clientId };
const result = await oauthCollectionToken.deleteMany(query);
console.log("Deleted " + result.deletedCount + " tokens by client id:" + clientId);
}
async function deleteTokenByClientIdAndGrantType(clientId, grantType) {
const query = { client_id: clientId, grant_type: grantType };
const result = await oauthCollectionToken.deleteMany(query);
console.log("Deleted " + result.deletedCount + " tokens by client id:" + clientId + " and grant type:" + grantType);
}
async function deleteClientById(clientId) {
await deleteTokenByClientId(clientId)
const query = { client_id: clientId };
const result = await oauthCollectionClient.deleteOne(query);
console.log("Deleted " + result.deletedCount + " client by client id:" + clientId);
}
async function updateClientEntry(client, keys) {
assert(!__.isEmpty(keys));
assert(__.isArray(keys));
const query = {client_id: client.client_id};
const kv = {};
for (let key of keys) {
kv[key] = client[key];
}
const updateDocument = {
$set: kv,
};
const result = await oauthCollectionClient.updateOne(query, updateDocument);
console.log("Update matched client count:" + result.matchedCount);
}
async function findClientById(clientId) {
const query = { client_id: clientId };
const result = await oauthCollectionClient.findOne(query);
console.log("Find " + (result == null ? 0 : 1) + " client by client id:" + clientId);
return result;
}
// Notice: the return value is a promise, call the 'then()' to handle the callback of database.
function findUserByName(userName) {
const query = {user_name: userName};
return userCollectionInfo.findOne(query);
}
async function loadCollections() {
const loads = {};
const name = ["client", "token", "user"];
const clientLoadOptions = {sort: { client_id_created_at: -1 }, limit: maxCountOfCachedClients};
const clientCursor = oauthCollectionClient.find({}, clientLoadOptions);
const tokenLoadOptions = {sort: { expire: 1 }, limit: maxCountOfCachedTokens};
const tokenCursor = oauthCollectionToken.find({}, tokenLoadOptions);
const userLoadOptions = {limit: maxCountOfCachedUsers};
const userCursor = userCollectionInfo.find({}, userLoadOptions);
const p = Promise.allSettled([clientCursor, tokenCursor, userCursor]);
p.then(async cursors => {
for (let i = 0; i < 3; i++) {
if (cursors[i].status === "rejected") {
console.error("Load collection:%s error, reason:%s.", name[i], cursors[i].reason);
}
switch (i) {
case 0:
if (cursors[i].status === "fulfilled") {
loads.client = await cursors[i].value.toArray();
}
await cursors[i].value.close();
break;
case 1:
if (cursors[i].status === "fulfilled") {
loads.token = await cursors[i].value.toArray();
}
await cursors[i].value.close();
break;
case 2:
if (cursors[i].status === "fulfilled") {
loads.user = await cursors[i].value.toArray();
}
await cursors[i].value.close();
break;
default:
console.error("Internal logic error of collection data load.");
break;
}
}
if (!__.isEmpty(loads.client)) {
if (__.isArray(loads.client)) {
clients.push(...loads.client);
} else {
clients.push(loads.client);
}
}
if (!__.isEmpty(loads.token)) {
if (__.isArray(loads.token)) {
tokens.push(...loads.token);
} else {
tokens.push(loads.token);
}
}
if (!__.isEmpty(loads.user)) {
if (__.isArray(loads.user)) {
users.push(...loads.user);
} else {
users.push(loads.user);
}
}
console.log("Loaded database collections. client count:%d, token count:%d, user count:%d.",
clients.length, tokens.length, users.length);
}).catch(e => {
console.error("Load collection error:" + e.message);
});
}
async function initDb() {
await getClientDriver();
await dropCollectionIfNecessary();
OpenOauthDb();
OpenUserDb();
return loadCollections();
}
module.exports = {clients, tokens, users, initDb, findClientById, insertClient, deleteClientById, updateClientEntry,
insertToken, findTokenByAccessToken, findAndDeleteTokenByRefreshToken, deleteTokenByAccessToken,
deleteTokenByClientIdAndGrantType, findUserByName};