add jwt model, logger wrapper, prettier formatting

This commit is contained in:
Evert Prants 2022-03-07 21:32:20 +02:00
parent 0f726741f9
commit f8640e40d1
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
18 changed files with 484 additions and 203 deletions

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 80
}

23
package-lock.json generated
View File

@ -1,11 +1,11 @@
{ {
"name": "@icynet/oauth2", "name": "@icynet/oauth2-provider",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@icynet/oauth2", "name": "@icynet/oauth2-provider",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -16,6 +16,7 @@
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"prettier": "^2.5.1",
"typescript": "^4.5.5" "typescript": "^4.5.5"
} }
}, },
@ -470,6 +471,18 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
}, },
"node_modules/prettier": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
"integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1038,6 +1051,12 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
}, },
"prettier": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
"integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
"dev": true
},
"proxy-addr": { "proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",

View File

@ -15,6 +15,7 @@
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"prettier": "^2.5.1",
"typescript": "^4.5.5" "typescript": "^4.5.5"
}, },
"dependencies": { "dependencies": {

View File

@ -24,30 +24,38 @@ export const authorization = wrap(async (req, res) => {
const { oauth2 } = req; const { oauth2 } = req;
if (!req.query.redirect_uri) { if (!req.query.redirect_uri) {
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); throw new InvalidRequest(
'redirect_uri field is mandatory for authorization endpoint'
);
} }
redirectUri = req.query.redirect_uri as string; redirectUri = req.query.redirect_uri as string;
console.debug('Parameter redirect uri is', redirectUri); req.oauth2.logger.debug('Parameter redirect uri is', redirectUri);
if (!req.query.client_id) { if (!req.query.client_id) {
throw new InvalidRequest('client_id field is mandatory for authorization endpoint'); throw new InvalidRequest(
'client_id field is mandatory for authorization endpoint'
);
} }
// Check for client_secret (prevent passing it) // Check for client_secret (prevent passing it)
if (req.query.client_secret) { if (req.query.client_secret) {
throw new InvalidRequest('client_secret field should not be passed to the authorization endpoint'); throw new InvalidRequest(
'client_secret field should not be passed to the authorization endpoint'
);
} }
clientId = req.query.client_id as string; clientId = req.query.client_id as string;
console.debug('Parameter client_id is', clientId); req.oauth2.logger.debug('Parameter client_id is', clientId);
if (!req.query.response_type) { if (!req.query.response_type) {
throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); throw new InvalidRequest(
'response_type field is mandatory for authorization endpoint'
);
} }
responseType = req.query.response_type as string; responseType = req.query.response_type as string;
console.debug('Parameter response_type is', responseType); req.oauth2.logger.debug('Parameter response_type is', responseType);
// Support multiple types // Support multiple types
const responseTypes = responseType.split(' '); const responseTypes = responseType.split(' ');
@ -59,24 +67,32 @@ export const authorization = wrap(async (req, res) => {
case 'token': case 'token':
grantTypes.push('implicit'); grantTypes.push('implicit');
break; break;
// case 'id_token': case 'id_token':
grantTypes.push('id_token');
break;
case 'none': case 'none':
grantTypes.push(responseTypes[i]); grantTypes.push(responseTypes[i]);
break; break;
default: default:
throw new UnsupportedResponseType('Unknown response_type parameter passed'); throw new UnsupportedResponseType(
'Unknown response_type parameter passed'
);
} }
} }
// Filter out duplicates // Filter out duplicates
grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index); grantTypes = grantTypes.filter(
(value, index, self) => self.indexOf(value) === index
);
// "None" type cannot be combined with others // "None" type cannot be combined with others
if (grantTypes.length > 1 && grantTypes.indexOf('none') !== -1) { if (grantTypes.length > 1 && grantTypes.indexOf('none') !== -1) {
throw new InvalidRequest('Grant type "none" cannot be combined with other grant types'); throw new InvalidRequest(
'Grant type "none" cannot be combined with other grant types'
);
} }
console.debug('Parameter grant_type is', grantTypes.join(' ')); req.oauth2.logger.debug('Parameter grant_type is', grantTypes.join(' '));
const client = await oauth2.model.client.fetchById(clientId); const client = await oauth2.model.client.fetchById(clientId);
if (!client) { if (!client) {
@ -89,21 +105,26 @@ export const authorization = wrap(async (req, res) => {
} else if (!oauth2.model.client.checkRedirectUri(client, redirectUri)) { } else if (!oauth2.model.client.checkRedirectUri(client, redirectUri)) {
throw new InvalidRequest('Wrong RedirectUri provided'); throw new InvalidRequest('Wrong RedirectUri provided');
} }
console.debug('redirect_uri check passed'); req.oauth2.logger.debug('redirect_uri check passed');
// The client needs to support all grant types // The client needs to support all grant types
for (const grantType of grantTypes) { for (const grantType of grantTypes) {
if (!oauth2.model.client.checkGrantType(client, grantType) && grantType !== 'none') { if (
throw new UnauthorizedClient('This client does not support grant type ' + grantType); !oauth2.model.client.checkGrantType(client, grantType) &&
grantType !== 'none'
) {
throw new UnauthorizedClient(
'This client does not support grant type ' + grantType
);
} }
} }
console.debug('Grant type check passed'); req.oauth2.logger.debug('Grant type check passed');
scope = oauth2.model.client.transformScope(req.query.scope as string); scope = oauth2.model.client.transformScope(req.query.scope as string);
if (!oauth2.model.client.checkScope(client, scope)) { if (!oauth2.model.client.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} }
console.debug('Scope check passed'); req.oauth2.logger.debug('Scope check passed');
user = await oauth2.model.user.fetchFromRequest(req); user = await oauth2.model.user.fetchFromRequest(req);
if (!user) { if (!user) {
@ -112,10 +133,10 @@ export const authorization = wrap(async (req, res) => {
if (!user.username) { if (!user.username) {
throw new AccessDenied(user.username); throw new AccessDenied(user.username);
} }
console.debug('User fetched from request') req.oauth2.logger.debug('User fetched from request');
} }
let resObj = {}; let resObj: Record<string, string | number> = {};
let consented = false; let consented = false;
if (req.method === 'GET') { if (req.method === 'GET') {
@ -128,24 +149,31 @@ export const authorization = wrap(async (req, res) => {
); );
// Ask for consent // Ask for consent
if (!consented) return oauth2.decision(req, res, client, scope, user, redirectUri); if (!consented)
return oauth2.decision(req, res, client, scope, user, redirectUri);
} }
// Consent pushed, ensure valid session // Consent pushed, ensure valid session
const { session: { csrf } } = req; const {
if (req.method === 'POST' && csrf && !(req.body.csrf && req.body.csrf === csrf)) { session: { csrf },
} = req;
if (
req.method === 'POST' &&
csrf &&
!(req.body.csrf && req.body.csrf === csrf)
) {
throw new InvalidRequest('Invalid session'); throw new InvalidRequest('Invalid session');
} }
// Save consent // Save consent
if (!consented) { if (!consented) {
if (!req.body || (typeof req.body.decision) === 'undefined') { if (!req.body || typeof req.body.decision === 'undefined') {
throw new InvalidRequest('No decision parameter passed'); throw new InvalidRequest('No decision parameter passed');
} else if (req.body.decision === '0') { } else if (req.body.decision === '0') {
throw new AccessDenied('User denied access to the resource'); throw new AccessDenied('User denied access to the resource');
} }
console.debug('Decision check passed'); req.oauth2.logger.debug('Decision check passed');
await oauth2.model.user.consent( await oauth2.model.user.consent(
oauth2.model.user.getId(user), oauth2.model.user.getId(user),
@ -155,17 +183,17 @@ export const authorization = wrap(async (req, res) => {
} }
for (const i in grantTypes) { for (const i in grantTypes) {
let data = null let data = null;
switch (grantTypes[i]) { switch (grantTypes[i]) {
case 'authorization_code': case 'authorization_code':
data = await oauth2.model.code.create( data = await oauth2.model.code.create(
oauth2.model.user.getId(user), oauth2.model.user.getId(user),
oauth2.model.client.getId(client), oauth2.model.client.getId(client),
scope, scope,
oauth2.model.code.ttl, oauth2.model.code.ttl
); );
resObj = Object.assign({ code: data }, resObj); resObj = { code: data, ...resObj };
break; break;
case 'implicit': case 'implicit':
@ -176,18 +204,36 @@ export const authorization = wrap(async (req, res) => {
oauth2.model.accessToken.ttl oauth2.model.accessToken.ttl
); );
resObj = Object.assign({ resObj = {
token_type: 'bearer', token_type: 'bearer',
access_token: data, access_token: data,
expires_in: oauth2.model.accessToken.ttl expires_in: oauth2.model.accessToken.ttl,
}, resObj); ...resObj,
};
break; break;
case 'id_token':
if (!oauth2.model.jwt || !scope.includes('openid')) {
break;
}
data = await oauth2.model.jwt.issueIdToken(
user,
scope,
resObj.access_token as string | undefined
);
resObj = {
id_token: data,
...resObj,
};
case 'none': case 'none':
resObj = {}; resObj = {};
break; break;
default: default:
throw new UnsupportedResponseType('Unknown response_type parameter passed'); throw new UnsupportedResponseType(
'Unknown response_type parameter passed'
);
} }
} }

View File

@ -11,7 +11,11 @@ export const introspection = wrap(async function (req, res) {
if (req.body.client_id && req.body.client_secret) { if (req.body.client_id && req.body.client_secret) {
clientId = req.body.client_id as string; clientId = req.body.client_id as string;
clientSecret = req.body.client_secret as string; clientSecret = req.body.client_secret as string;
console.debug('Client credentials parsed from body parameters ', clientId, clientSecret); req.oauth2.logger.debug(
'Client credentials parsed from body parameters ',
clientId,
clientSecret
);
} else { } else {
if (!req.headers || !req.headers.authorization) { if (!req.headers || !req.headers.authorization) {
throw new InvalidRequest('No authorization header passed'); throw new InvalidRequest('No authorization header passed');
@ -23,7 +27,9 @@ export const introspection = wrap(async function (req, res) {
} }
if (pieces[0] !== 'Basic') { if (pieces[0] !== 'Basic') {
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`); throw new InvalidRequest(
`Unsupported authorization method: ${pieces[0]}`
);
} }
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2); pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
@ -33,7 +39,11 @@ export const introspection = wrap(async function (req, res) {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; clientSecret = pieces[1];
console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret); req.oauth2.logger.debug(
'Client credentials parsed from basic auth header: ',
clientId,
clientSecret
);
} }
if (!req.body.token) { if (!req.body.token) {
@ -49,7 +59,7 @@ export const introspection = wrap(async function (req, res) {
const resObj = { const resObj = {
token_type: 'bearer', token_type: 'bearer',
token: token.token, token: token.token,
expires_in: Math.floor(ttl / 1000) expires_in: Math.floor(ttl / 1000),
}; };
dataResponse(req, res, resObj); dataResponse(req, res, resObj);

View File

@ -1,13 +1,16 @@
import * as tokens from './tokens' import * as tokens from './tokens';
import { import {
InvalidRequest, InvalidRequest,
InvalidClient, InvalidClient,
UnauthorizedClient, UnauthorizedClient,
UnsupportedGrantType, UnsupportedGrantType,
OAuth2Error OAuth2Error,
} from '../model/error' } from '../model/error';
import { data as dataResponse, error as errorResponse } from '../utils/response' import {
import wrap from '../utils/wrap' data as dataResponse,
error as errorResponse,
} from '../utils/response';
import wrap from '../utils/wrap';
import { OAuth2TokenResponse } from '../model/model'; import { OAuth2TokenResponse } from '../model/model';
export const token = wrap(async (req, res) => { export const token = wrap(async (req, res) => {
@ -20,7 +23,11 @@ export const token = wrap(async (req, res) => {
if (req.body.client_id && req.body.client_secret) { if (req.body.client_id && req.body.client_secret) {
clientId = req.body.client_id as string; clientId = req.body.client_id as string;
clientSecret = req.body.client_secret as string; clientSecret = req.body.client_secret as string;
console.debug('Client credentials parsed from body parameters', clientId, clientSecret); req.oauth2.logger.debug(
'Client credentials parsed from body parameters',
clientId,
clientSecret
);
} else { } else {
if (!req.headers || !req.headers.authorization) { if (!req.headers || !req.headers.authorization) {
throw new InvalidRequest('No authorization header passed'); throw new InvalidRequest('No authorization header passed');
@ -32,7 +39,9 @@ export const token = wrap(async (req, res) => {
} }
if (pieces[0] !== 'Basic') { if (pieces[0] !== 'Basic') {
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`); throw new InvalidRequest(
`Unsupported authorization method: ${pieces[0]}`
);
} }
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2); pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
@ -42,15 +51,21 @@ export const token = wrap(async (req, res) => {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; clientSecret = pieces[1];
console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); req.oauth2.logger.debug(
'Client credentials parsed from basic auth header:',
clientId,
clientSecret
);
} }
if (!req.body.grant_type) { if (!req.body.grant_type) {
throw new InvalidRequest('Request body does not contain grant_type parameter'); throw new InvalidRequest(
'Request body does not contain grant_type parameter'
);
} }
grantType = req.body.grant_type as string; grantType = req.body.grant_type as string;
console.debug('Parameter grant_type is', grantType); req.oauth2.logger.debug('Parameter grant_type is', grantType);
const client = await oauth2.model.client.fetchById(clientId); const client = await oauth2.model.client.fetchById(clientId);
@ -63,36 +78,58 @@ export const token = wrap(async (req, res) => {
throw new UnauthorizedClient('Invalid client secret'); throw new UnauthorizedClient('Invalid client secret');
} }
if (!oauth2.model.client.checkGrantType(client, grantType) && grantType !== 'refresh_token') { if (
!oauth2.model.client.checkGrantType(client, grantType) &&
grantType !== 'refresh_token'
) {
throw new UnauthorizedClient('Invalid grant type for the client'); throw new UnauthorizedClient('Invalid grant type for the client');
} else { } else {
console.debug('Grant type check passed'); req.oauth2.logger.debug('Grant type check passed');
} }
let tokenResponse: OAuth2TokenResponse; let tokenResponse: OAuth2TokenResponse;
try { try {
switch (grantType) { switch (grantType) {
case 'authorization_code': case 'authorization_code':
tokenResponse = await tokens.authorizationCode(oauth2, client, req.body.code); tokenResponse = await tokens.authorizationCode(
oauth2,
client,
req.body.code
);
break; break;
case 'password': case 'password':
tokenResponse = await tokens.password(oauth2, client, req.body.username, req.body.password, req.body.scope); tokenResponse = await tokens.password(
oauth2,
client,
req.body.username,
req.body.password,
req.body.scope
);
break; break;
case 'client_credentials': case 'client_credentials':
tokenResponse = await tokens.clientCredentials(oauth2, client, req.body.scope); tokenResponse = await tokens.clientCredentials(
oauth2,
client,
req.body.scope
);
break; break;
case 'refresh_token': case 'refresh_token':
tokenResponse = await tokens.refreshToken(oauth2, client, req.body.refresh_token); tokenResponse = await tokens.refreshToken(
oauth2,
client,
req.body.refresh_token
);
break; break;
default: default:
throw new UnsupportedGrantType('Grant type does not match any supported type'); throw new UnsupportedGrantType(
'Grant type does not match any supported type'
);
} }
if (tokenResponse) { if (tokenResponse) {
dataResponse(req, res, tokenResponse); dataResponse(req, res, tokenResponse);
} }
} catch (e) { } catch (e) {
errorResponse(req, res, e as OAuth2Error); errorResponse(req, res, e as OAuth2Error);
} }
}) });

View File

@ -1,5 +1,10 @@
import { InvalidRequest, ServerError, InvalidGrant } from '../../model/error'; import { InvalidRequest, ServerError, InvalidGrant } from '../../model/error';
import { OAuth2, OAuth2Client, OAuth2Code, OAuth2TokenResponse } from '../../model/model'; import {
OAuth2,
OAuth2Client,
OAuth2Code,
OAuth2TokenResponse,
} from '../../model/model';
/** /**
* Issue an access token by authorization code * Issue an access token by authorization code
@ -14,24 +19,28 @@ export async function authorizationCode(
providedCode: string providedCode: string
): Promise<OAuth2TokenResponse> { ): Promise<OAuth2TokenResponse> {
const respObj: OAuth2TokenResponse = { const respObj: OAuth2TokenResponse = {
token_type: 'bearer' token_type: 'bearer',
}; };
let code: OAuth2Code | null = null; let code: OAuth2Code | null = null;
if (!providedCode) { if (!providedCode) {
throw new InvalidRequest('code is mandatory for authorization_code grant type'); throw new InvalidRequest(
'code is mandatory for authorization_code grant type'
);
} }
try { try {
code = await oauth2.model.code.fetchByCode(providedCode); code = await oauth2.model.code.fetchByCode(providedCode);
} catch (err) { } catch (err) {
console.error(err); oauth2.logger.error(err);
throw new ServerError('Failed to call code.fetchByCode function'); throw new ServerError('Failed to call code.fetchByCode function');
} }
if (code) { if (code) {
if (oauth2.model.code.getClientId(code) !== oauth2.model.client.getId(client)) { if (
oauth2.model.code.getClientId(code) !== oauth2.model.client.getId(client)
) {
throw new InvalidGrant('Code was issued by another client'); throw new InvalidGrant('Code was issued by another client');
} }
@ -42,56 +51,81 @@ export async function authorizationCode(
throw new InvalidGrant('Code not found'); throw new InvalidGrant('Code not found');
} }
console.debug('Code fetched', code); oauth2.logger.debug('Code fetched', code);
const scope = oauth2.model.code.getScope(code);
const cleanScope = oauth2.model.client.transformScope(scope);
const userId = oauth2.model.code.getUserId(code);
const clientId = oauth2.model.code.getClientId(code);
if (oauth2.model.refreshToken.invalidateOld) {
try { try {
await oauth2.model.refreshToken.removeByUserIdClientId( await oauth2.model.refreshToken.removeByUserIdClientId(userId, clientId);
oauth2.model.code.getUserId(code),
oauth2.model.code.getClientId(code)
);
} catch (err) { } catch (err) {
console.error(err) oauth2.logger.error(err);
throw new ServerError('Failed to call refreshToken.removeByUserIdClientId function'); throw new ServerError(
'Failed to call refreshToken.removeByUserIdClientId function'
);
} }
console.debug('Refresh token removed'); oauth2.logger.debug('Refresh token removed');
}
if (!oauth2.model.client.checkGrantType(client, 'refresh_token')) { if (!oauth2.model.client.checkGrantType(client, 'refresh_token')) {
console.debug('Client does not allow grant type refresh_token, skip creation'); oauth2.logger.debug(
'Client does not allow grant type refresh_token, skip creation'
);
} else { } else {
try { try {
respObj.refresh_token = await oauth2.model.refreshToken.create( respObj.refresh_token = await oauth2.model.refreshToken.create(
oauth2.model.code.getUserId(code), userId,
oauth2.model.code.getClientId(code), clientId,
oauth2.model.code.getScope(code) scope
); );
} catch (err) { } catch (err) {
console.error(err); oauth2.logger.error(err);
throw new ServerError('Failed to call refreshToken.create function'); throw new ServerError('Failed to call refreshToken.create function');
} }
} }
try { try {
respObj.access_token = await oauth2.model.accessToken.create( respObj.access_token = await oauth2.model.accessToken.create(
oauth2.model.code.getUserId(code), userId,
oauth2.model.code.getClientId(code), clientId,
oauth2.model.code.getScope(code), scope,
oauth2.model.accessToken.ttl oauth2.model.accessToken.ttl
); );
} catch (err) { } catch (err) {
console.error(err); oauth2.logger.error(err);
throw new ServerError('Failed to call accessToken.create function'); throw new ServerError('Failed to call accessToken.create function');
} }
respObj.expires_in = oauth2.model.accessToken.ttl; respObj.expires_in = oauth2.model.accessToken.ttl;
console.debug('Access token saved:', respObj.access_token); oauth2.logger.debug('Access token saved:', respObj.access_token);
try { try {
await oauth2.model.code.removeByCode(providedCode); await oauth2.model.code.removeByCode(providedCode);
} catch (err) { } catch (err) {
console.error(err); oauth2.logger.error(err);
throw new ServerError('Failed to call code.removeByCode function'); throw new ServerError('Failed to call code.removeByCode function');
} }
if (cleanScope.includes('openid') && oauth2.model.jwt) {
const user = await oauth2.model.user.fetchById(
oauth2.model.code.getUserId(code)
);
try {
respObj.id_token = await oauth2.model.jwt.issueIdToken(
user,
cleanScope,
respObj.access_token
);
} catch (err) {
oauth2.logger.error(err);
throw new ServerError('Failed to issue an ID token');
}
}
return respObj; return respObj;
} }

View File

@ -1,4 +1,4 @@
import { ServerError, InvalidScope } from '../../model/error' import { ServerError, InvalidScope } from '../../model/error';
import { OAuth2, OAuth2Client, OAuth2TokenResponse } from '../../model/model'; import { OAuth2, OAuth2Client, OAuth2TokenResponse } from '../../model/model';
/** /**
@ -16,7 +16,7 @@ export async function clientCredentials(
let scope: string[] = []; let scope: string[] = [];
const resObj: OAuth2TokenResponse = { const resObj: OAuth2TokenResponse = {
token_type: 'bearer' token_type: 'bearer',
}; };
scope = oauth2.model.client.transformScope(wantScope); scope = oauth2.model.client.transformScope(wantScope);
@ -24,7 +24,7 @@ export async function clientCredentials(
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} }
console.debug('Scope check passed ', scope); oauth2.logger.debug('Scope check passed', scope);
try { try {
resObj.access_token = await oauth2.model.accessToken.create( resObj.access_token = await oauth2.model.accessToken.create(

View File

@ -1,4 +1,4 @@
export * from './authorizationCode' export * from './authorizationCode';
export * from './clientCredentials' export * from './clientCredentials';
export * from './password' export * from './password';
export * from './refreshToken' export * from './refreshToken';

View File

@ -1,5 +1,15 @@
import { ServerError, InvalidRequest, InvalidScope, InvalidClient } from '../../model/error' import {
import { OAuth2, OAuth2Client, OAuth2User, OAuth2TokenResponse } from '../../model/model'; ServerError,
InvalidRequest,
InvalidScope,
InvalidClient,
} from '../../model/error';
import {
OAuth2,
OAuth2Client,
OAuth2User,
OAuth2TokenResponse,
} from '../../model/model';
/** /**
* Implicit access token response * Implicit access token response
@ -20,8 +30,8 @@ export async function password(
let user: OAuth2User | null = null; let user: OAuth2User | null = null;
const resObj: OAuth2TokenResponse = { const resObj: OAuth2TokenResponse = {
token_type: 'bearer' token_type: 'bearer',
} };
if (!username) { if (!username) {
throw new InvalidRequest('Username is mandatory for password grant type'); throw new InvalidRequest('Username is mandatory for password grant type');
@ -35,7 +45,7 @@ export async function password(
if (!oauth2.model.client.checkScope(client, scope)) { if (!oauth2.model.client.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} else { } else {
console.debug('Scope check passed: ', scope); oauth2.logger.debug('Scope check passed: ', scope);
} }
try { try {
@ -59,13 +69,17 @@ export async function password(
oauth2.model.client.getId(client) oauth2.model.client.getId(client)
); );
} catch (err) { } catch (err) {
throw new ServerError('Failed to call refreshToken.removeByUserIdClientId function'); throw new ServerError(
'Failed to call refreshToken.removeByUserIdClientId function'
);
} }
console.debug('Refresh token removed'); oauth2.logger.debug('Refresh token removed');
if (!oauth2.model.client.checkGrantType(client, 'refresh_token')) { if (!oauth2.model.client.checkGrantType(client, 'refresh_token')) {
console.debug('Client does not allow grant type refresh_token, skip creation'); oauth2.logger.debug(
'Client does not allow grant type refresh_token, skip creation'
);
} else { } else {
try { try {
resObj.refresh_token = await oauth2.model.refreshToken.create( resObj.refresh_token = await oauth2.model.refreshToken.create(
@ -90,7 +104,7 @@ export async function password(
} }
resObj.expires_in = oauth2.model.accessToken.ttl; resObj.expires_in = oauth2.model.accessToken.ttl;
console.debug('Access token saved ', resObj.access_token); oauth2.logger.debug('Access token saved ', resObj.access_token);
return resObj; return resObj;
} }

View File

@ -1,24 +1,29 @@
import { InvalidRequest, ServerError, InvalidGrant, InvalidClient } from '../../model/error'; import {
InvalidRequest,
ServerError,
InvalidGrant,
InvalidClient,
} from '../../model/error';
import { import {
OAuth2, OAuth2,
OAuth2AccessToken, OAuth2AccessToken,
OAuth2Client, OAuth2Client,
OAuth2RefreshToken, OAuth2RefreshToken,
OAuth2User, OAuth2User,
OAuth2TokenResponse OAuth2TokenResponse,
} from '../../model/model'; } from '../../model/model';
/** /**
* Get a new access token from a refresh token. Scope change may not be requested. * Get a new access token from a refresh token. Scope change may not be requested.
* @param oauth2 - OAuth2 instance * @param oauth2 - OAuth2 instance
* @param client - OAuth2 client * @param client - OAuth2 client
* @param pRefreshToken - Refresh token * @param providedToken - Refresh token
* @returns Access token * @returns Access token
*/ */
export async function refreshToken( export async function refreshToken(
oauth2: OAuth2, oauth2: OAuth2,
client: OAuth2Client, client: OAuth2Client,
pRefreshToken: string, providedToken: string
): Promise<OAuth2TokenResponse> { ): Promise<OAuth2TokenResponse> {
let user: OAuth2User | null = null; let user: OAuth2User | null = null;
let ttl: number | null = null; let ttl: number | null = null;
@ -26,15 +31,17 @@ export async function refreshToken(
let accessToken: OAuth2AccessToken | null = null; let accessToken: OAuth2AccessToken | null = null;
const resObj: OAuth2TokenResponse = { const resObj: OAuth2TokenResponse = {
token_type: 'bearer' token_type: 'bearer',
}; };
if (!pRefreshToken) { if (!providedToken) {
throw new InvalidRequest('refresh_token is mandatory for refresh_token grant type'); throw new InvalidRequest(
'refresh_token is mandatory for refresh_token grant type'
);
} }
try { try {
refreshToken = await oauth2.model.refreshToken.fetchByToken(pRefreshToken); refreshToken = await oauth2.model.refreshToken.fetchByToken(providedToken);
} catch (err) { } catch (err) {
throw new ServerError('Failed to call refreshToken.fetchByToken function'); throw new ServerError('Failed to call refreshToken.fetchByToken function');
} }
@ -43,8 +50,12 @@ export async function refreshToken(
throw new InvalidGrant('Refresh token not found'); throw new InvalidGrant('Refresh token not found');
} }
if (oauth2.model.refreshToken.getClientId(refreshToken) !== oauth2.model.client.getId(client)) { if (
console.warn('Client %s tried to fetch a refresh token which belongs to client %s!', oauth2.model.refreshToken.getClientId(refreshToken) !==
oauth2.model.client.getId(client)
) {
oauth2.logger.warn(
'Client %s tried to fetch a refresh token which belongs to client %s!',
oauth2.model.client.getId(client), oauth2.model.client.getId(client),
oauth2.model.refreshToken.getClientId(refreshToken) oauth2.model.refreshToken.getClientId(refreshToken)
); );
@ -52,7 +63,9 @@ export async function refreshToken(
} }
try { try {
user = await oauth2.model.user.fetchById(oauth2.model.refreshToken.getUserId(refreshToken)); user = await oauth2.model.user.fetchById(
oauth2.model.refreshToken.getUserId(refreshToken)
);
} catch (err) { } catch (err) {
throw new InvalidClient('User not found'); throw new InvalidClient('User not found');
} }
@ -62,10 +75,14 @@ export async function refreshToken(
} }
try { try {
accessToken = await oauth2.model.accessToken.fetchByUserIdClientId(oauth2.model.user.getId(user), accessToken = await oauth2.model.accessToken.fetchByUserIdClientId(
oauth2.model.client.getId(client)); oauth2.model.user.getId(user),
oauth2.model.client.getId(client)
);
} catch (err) { } catch (err) {
throw new ServerError('Failed to call accessToken.fetchByUserIdClientId function'); throw new ServerError(
'Failed to call accessToken.fetchByUserIdClientId function'
);
} }
if (accessToken) { if (accessToken) {

View File

@ -3,7 +3,7 @@ import { AccessDenied } from './model/error';
import wrap from './utils/wrap'; import wrap from './utils/wrap';
export const middleware = wrap(async function (req: Request, res, next) { export const middleware = wrap(async function (req: Request, res, next) {
console.debug('Parsing bearer token'); req.oauth2.logger.debug('Parsing bearer token');
let token = null; let token = null;
// Look for token in header // Look for token in header
@ -21,13 +21,16 @@ export const middleware = wrap(async function (req: Request, res, next) {
} }
token = pieces[1]; token = pieces[1];
console.debug('Bearer token parsed from authorization header:', token); req.oauth2.logger.debug(
'Bearer token parsed from authorization header:',
token
);
} else if (req.query?.access_token) { } else if (req.query?.access_token) {
token = req.query.access_token; token = req.query.access_token;
console.debug('Bearer token parsed from query params:', token); req.oauth2.logger.debug('Bearer token parsed from query params:', token);
} else if (req.body?.access_token) { } else if (req.body?.access_token) {
token = req.body.access_token; token = req.body.access_token;
console.debug('Bearer token parsed from body params:', token); req.oauth2.logger.debug('Bearer token parsed from body params:', token);
} else { } else {
throw new AccessDenied('Bearer token not found'); throw new AccessDenied('Bearer token not found');
} }
@ -40,7 +43,7 @@ export const middleware = wrap(async function (req: Request, res, next) {
throw new AccessDenied('Token is expired'); throw new AccessDenied('Token is expired');
} else { } else {
res.locals.accessToken = object; res.locals.accessToken = object;
console.debug('AccessToken fetched', object); req.oauth2.logger.debug('AccessToken fetched', object);
next(); next();
} }
}); });

View File

@ -2,8 +2,12 @@ export class OAuth2Error extends Error {
public name = 'OAuth2AbstractError'; public name = 'OAuth2AbstractError';
public logLevel = 'error'; public logLevel = 'error';
constructor (public code: string, public message: string, public status: number) { constructor(
super() public code: string,
public message: string,
public status: number
) {
super();
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
} }
} }
@ -12,7 +16,7 @@ export class AccessDenied extends OAuth2Error {
public name = 'OAuth2AccessDenied'; public name = 'OAuth2AccessDenied';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('access_denied', msg, 403); super('access_denied', msg, 403);
} }
} }
@ -21,7 +25,7 @@ export class InvalidClient extends OAuth2Error {
public name = 'OAuth2InvalidClient'; public name = 'OAuth2InvalidClient';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('invalid_client', msg, 401); super('invalid_client', msg, 401);
} }
} }
@ -30,7 +34,7 @@ export class InvalidGrant extends OAuth2Error {
public name = 'OAuth2InvalidGrant'; public name = 'OAuth2InvalidGrant';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('invalid_grant', msg, 400); super('invalid_grant', msg, 400);
} }
} }
@ -39,7 +43,7 @@ export class InvalidRequest extends OAuth2Error {
public name = 'OAuth2InvalidRequest'; public name = 'OAuth2InvalidRequest';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('invalid_request', msg, 400); super('invalid_request', msg, 400);
} }
} }
@ -48,7 +52,7 @@ export class InvalidScope extends OAuth2Error {
public name = 'OAuth2InvalidScope'; public name = 'OAuth2InvalidScope';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('invalid_scope', msg, 400); super('invalid_scope', msg, 400);
} }
} }
@ -57,7 +61,7 @@ export class ServerError extends OAuth2Error {
public name = 'OAuth2ServerError'; public name = 'OAuth2ServerError';
public logLevel = 'error'; public logLevel = 'error';
constructor (msg: string) { constructor(msg: string) {
super('server_error', msg, 500); super('server_error', msg, 500);
} }
} }
@ -66,7 +70,7 @@ export class UnauthorizedClient extends OAuth2Error {
public name = 'OAuth2UnauthorizedClient'; public name = 'OAuth2UnauthorizedClient';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('unauthorized_client', msg, 400); super('unauthorized_client', msg, 400);
} }
} }
@ -75,7 +79,7 @@ export class UnsupportedGrantType extends OAuth2Error {
public name = 'OAuth2UnsupportedGrantType'; public name = 'OAuth2UnsupportedGrantType';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('unsupported_grant_type', msg, 400); super('unsupported_grant_type', msg, 400);
} }
} }
@ -84,7 +88,7 @@ export class UnsupportedResponseType extends OAuth2Error {
public name = 'OAuth2UnsupportedResponseType'; public name = 'OAuth2UnsupportedResponseType';
public logLevel = 'info'; public logLevel = 'info';
constructor (msg: string) { constructor(msg: string) {
super('unsupported_response_type', msg, 400); super('unsupported_response_type', msg, 400);
} }
} }

View File

@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { OAuth2Logger } from '../utils/logger';
/** /**
* OAuth2 client object * OAuth2 client object
@ -46,7 +47,6 @@ export interface OAuth2RefreshToken {
* OAuth2 implicit user model * OAuth2 implicit user model
*/ */
export interface OAuth2User { export interface OAuth2User {
id: string | number;
username: string; username: string;
password: string; password: string;
} }
@ -55,6 +55,7 @@ export interface OAuth2User {
* OAuth2 token response * OAuth2 token response
*/ */
export interface OAuth2TokenResponse { export interface OAuth2TokenResponse {
id_token?: string;
access_token?: string; access_token?: string;
refresh_token?: string; refresh_token?: string;
expires_in?: number; expires_in?: number;
@ -89,7 +90,9 @@ export interface OAuth2AccessTokenAdapter {
/** /**
* Fetch an access token by the token string from the database * Fetch an access token by the token string from the database
*/ */
fetchByToken: (token: OAuth2AccessToken | string) => Promise<OAuth2AccessToken>; fetchByToken: (
token: OAuth2AccessToken | string
) => Promise<OAuth2AccessToken>;
/** /**
* Check the time-to-live value * Check the time-to-live value
@ -209,6 +212,11 @@ export interface OAuth2CodeAdapter {
* OAuth2 refresh token adapter model * OAuth2 refresh token adapter model
*/ */
export interface OAuth2RefreshTokenAdapter { export interface OAuth2RefreshTokenAdapter {
/**
* Invalidate all previous refresh tokens for user/client when new one is issued.
*/
invalidateOld: boolean;
/** /**
* Create a new refresh token * Create a new refresh token
*/ */
@ -221,7 +229,9 @@ export interface OAuth2RefreshTokenAdapter {
/** /**
* Fetch a token from the database * Fetch a token from the database
*/ */
fetchByToken: (token: OAuth2RefreshToken | string) => Promise<OAuth2RefreshToken>; fetchByToken: (
token: OAuth2RefreshToken | string
) => Promise<OAuth2RefreshToken>;
/** /**
* Remove refresh token by user ID and client ID * Remove refresh token by user ID and client ID
@ -300,6 +310,25 @@ export interface OAuth2UserAdapter {
) => Promise<boolean>; ) => Promise<boolean>;
} }
/**
* Adapter for managing the `openid` scope.
*/
export interface JWTAdapter {
/**
* Issue a new ID token for user.
*/
issueIdToken: (
user: OAuth2User,
scope: string[],
accessToken?: string
) => Promise<string>;
/**
* Validate an ID token
*/
validateIdToken: (idToken: string) => Promise<boolean>;
}
/** /**
* Render the OAuth2 decision page * Render the OAuth2 decision page
*/ */
@ -321,6 +350,7 @@ export interface OAuth2AdapterModel {
user: OAuth2UserAdapter; user: OAuth2UserAdapter;
client: OAuth2ClientAdapter; client: OAuth2ClientAdapter;
code: OAuth2CodeAdapter; code: OAuth2CodeAdapter;
jwt?: JWTAdapter;
} }
/** /**
@ -328,5 +358,6 @@ export interface OAuth2AdapterModel {
*/ */
export interface OAuth2 { export interface OAuth2 {
model: OAuth2AdapterModel; model: OAuth2AdapterModel;
logger: OAuth2Logger;
decision: RenderOAuth2Decision; decision: RenderOAuth2Decision;
} }

View File

@ -1,22 +1,28 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import * as controller from './controller'; import * as controller from './controller';
import { middleware } from './middleware'; import { middleware } from './middleware';
import { RenderOAuth2Decision, OAuth2, OAuth2AdapterModel } from './model/model'; import {
RenderOAuth2Decision,
OAuth2,
OAuth2AdapterModel,
} from './model/model';
import { OAuth2Logger } from './utils/logger';
export class OAuth2Provider implements OAuth2 { export class OAuth2Provider implements OAuth2 {
public bearer = middleware; public bearer = middleware;
public controller = controller; public controller = controller;
public logger = new OAuth2Logger('info');
constructor ( constructor(
public model: OAuth2AdapterModel, public model: OAuth2AdapterModel,
public decision: RenderOAuth2Decision, public decision: RenderOAuth2Decision
) {} ) {}
express(): RequestHandler { express(): RequestHandler {
return (req, _res, next) => { return (req, _res, next) => {
console.debug('OAuth2 Injected into request'); req.oauth2.logger.debug('OAuth2 Injected into request');
req.oauth2 = this; req.oauth2 = this;
next(); next();
} };
} }
} }

43
src/utils/logger.ts Normal file
View File

@ -0,0 +1,43 @@
export type LoggerLevel = 'none' | 'info' | 'warn' | 'error' | 'debug';
type LoggerFunction = (...args: any[]) => void;
const LOG_LEVELS = ['none', 'info', 'warn', 'error', 'debug'];
interface LoggerType {
info: LoggerFunction;
warn: LoggerFunction;
error: LoggerFunction;
debug: LoggerFunction;
}
export class OAuth2Logger {
constructor(
public logLevel: LoggerLevel,
public logger: LoggerType | undefined = console
) {}
public setLogLevel(logLevel: LoggerLevel): void {
this.logLevel = logLevel;
}
public setLogger(logger?: LoggerType): void {
this.logger = logger;
}
public log(level: LoggerLevel, ...args: any[]): void {
if (!this.logger || this.logLevel === 'none') {
return;
}
if (LOG_LEVELS.indexOf(this.logLevel) < LOG_LEVELS.indexOf(level)) {
return;
}
this.logger[level as 'info' | 'warn' | 'error' | 'debug'](...args);
}
public info = this.log.bind(this, 'info');
public warn = this.log.bind(this, 'warn');
public error = this.log.bind(this, 'error');
public debug = this.log.bind(this, 'debug');
}

View File

@ -13,40 +13,49 @@ function dataRes(req: Request, res: Response, code: number, data: any): void {
res.header('Cache-Control', 'no-store'); res.header('Cache-Control', 'no-store');
res.header('Pragma', 'no-cache'); res.header('Pragma', 'no-cache');
res.status(code).send(data); res.status(code).send(data);
console.debug('Response:', data); req.oauth2.logger.debug('Response:', data);
} }
function redirect(req: Request, res: Response, redirectUri: string): void { function redirect(req: Request, res: Response, redirectUri: string): void {
res.header('Location', redirectUri); res.header('Location', redirectUri);
res.status(302).end(); res.status(302).end();
console.debug('Redirecting to', redirectUri); req.oauth2.logger.debug('Redirecting to', redirectUri);
} }
export function error(req: Request, res: Response, err: OAuth2Error, redirectUri?: string): void { export function error(
req: Request,
res: Response,
err: OAuth2Error,
redirectUri?: string
): void {
// Transform unknown error // Transform unknown error
if (!(err instanceof OAuth2Error)) { if (!(err instanceof OAuth2Error)) {
console.error((err as Error).stack); req.oauth2.logger.error((err as Error).stack);
err = new ServerError('Uncaught exception'); err = new ServerError('Uncaught exception');
} else { } else {
console.error('Exception caught', err.stack); req.oauth2.logger.error('Exception caught', err.stack);
} }
if (redirectUri) { if (redirectUri) {
const obj: ErrorResponseData = { const obj: ErrorResponseData = {
error: err.code, error: err.code,
error_description: err.message error_description: err.message,
}; };
if (req.query.state) { if (req.query.state) {
obj.state = req.query.state as string; obj.state = req.query.state as string;
} }
redirectUri += '?' + (new URLSearchParams(obj as Record<string, string>).toString()); redirectUri +=
'?' + new URLSearchParams(obj as Record<string, string>).toString();
redirect(req, res, redirectUri); redirect(req, res, redirectUri);
return; return;
} }
dataRes(req, res, err.status, { error: err.code, error_description: err.message }); dataRes(req, res, err.status, {
error: err.code,
error_description: err.message,
});
} }
export function data( export function data(
@ -57,15 +66,15 @@ export function data(
fragment: boolean = false fragment: boolean = false
): void { ): void {
if (redirectUri) { if (redirectUri) {
redirectUri += fragment redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&';
? '#'
: (redirectUri.indexOf('?') === -1 ? '?' : '&');
if (req.query.state) { if (req.query.state) {
obj.state = req.query.state as string; obj.state = req.query.state as string;
} }
redirectUri += new URLSearchParams(obj as Record<string, string>).toString(); redirectUri += new URLSearchParams(
obj as Record<string, string>
).toString();
redirect(req, res, redirectUri); redirect(req, res, redirectUri);
return; return;
} }

View File

@ -1,6 +1,8 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { error } from './response'; import { error } from './response';
export default (fn: RequestHandler, redir?: boolean): RequestHandler => (req, res, next) => export default (fn: RequestHandler, redir?: boolean): RequestHandler =>
(fn(req, res, next) as unknown as Promise<void>).catch(e => (req, res, next) =>
error(req, res, e, redir ? (req.query.redirect_uri as string) : undefined)); (fn(req, res, next) as unknown as Promise<void>).catch((e) =>
error(req, res, e, redir ? (req.query.redirect_uri as string) : undefined)
);