From f8640e40d17d650a948ab225abc8d7e6fb6b57ca Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Mon, 7 Mar 2022 21:32:20 +0200 Subject: [PATCH] add jwt model, logger wrapper, prettier formatting --- .prettierrc | 5 + package-lock.json | 23 ++- package.json | 1 + src/controller/authorization.ts | 170 +++++++++++++-------- src/controller/introspection.ts | 18 ++- src/controller/token.ts | 93 +++++++---- src/controller/tokens/authorizationCode.ts | 88 +++++++---- src/controller/tokens/clientCredentials.ts | 6 +- src/controller/tokens/index.ts | 8 +- src/controller/tokens/password.ts | 34 +++-- src/controller/tokens/refreshToken.ts | 45 ++++-- src/middleware.ts | 13 +- src/model/error.ts | 26 ++-- src/model/model.ts | 57 +++++-- src/provider.ts | 16 +- src/utils/logger.ts | 43 ++++++ src/utils/response.ts | 33 ++-- src/utils/wrap.ts | 8 +- 18 files changed, 484 insertions(+), 203 deletions(-) create mode 100644 .prettierrc create mode 100644 src/utils/logger.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a4538ba --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 80 +} diff --git a/package-lock.json b/package-lock.json index 259b1cb..5e4889d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@icynet/oauth2", + "name": "@icynet/oauth2-provider", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@icynet/oauth2", + "name": "@icynet/oauth2-provider", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -16,6 +16,7 @@ "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/node": "^17.0.21", + "prettier": "^2.5.1", "typescript": "^4.5.5" } }, @@ -470,6 +471,18 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "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": { "version": "2.0.7", "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", "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index f4c18ca..f8601c6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/express": "^4.17.13", "@types/express-session": "^1.17.4", "@types/node": "^17.0.21", + "prettier": "^2.5.1", "typescript": "^4.5.5" }, "dependencies": { diff --git a/src/controller/authorization.ts b/src/controller/authorization.ts index cbe25bb..6168421 100644 --- a/src/controller/authorization.ts +++ b/src/controller/authorization.ts @@ -24,59 +24,75 @@ export const authorization = wrap(async (req, res) => { const { oauth2 } = req; 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; - console.debug('Parameter redirect uri is', redirectUri); + req.oauth2.logger.debug('Parameter redirect uri is', redirectUri); 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) 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; - console.debug('Parameter client_id is', clientId); + req.oauth2.logger.debug('Parameter client_id is', clientId); 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; - console.debug('Parameter response_type is', responseType); + req.oauth2.logger.debug('Parameter response_type is', responseType); // Support multiple types const responseTypes = responseType.split(' '); for (const i in responseTypes) { switch (responseTypes[i]) { - case 'code': - grantTypes.push('authorization_code'); - break; - case 'token': - grantTypes.push('implicit'); - break; - // case 'id_token': - case 'none': - grantTypes.push(responseTypes[i]); - break; - default: - throw new UnsupportedResponseType('Unknown response_type parameter passed'); + case 'code': + grantTypes.push('authorization_code'); + break; + case 'token': + grantTypes.push('implicit'); + break; + case 'id_token': + grantTypes.push('id_token'); + break; + case 'none': + grantTypes.push(responseTypes[i]); + break; + default: + throw new UnsupportedResponseType( + 'Unknown response_type parameter passed' + ); } } // 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 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); if (!client) { @@ -89,21 +105,26 @@ export const authorization = wrap(async (req, res) => { } else if (!oauth2.model.client.checkRedirectUri(client, redirectUri)) { 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 for (const grantType of grantTypes) { - if (!oauth2.model.client.checkGrantType(client, grantType) && grantType !== 'none') { - throw new UnauthorizedClient('This client does not support grant type ' + grantType); + if ( + !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); if (!oauth2.model.client.checkScope(client, 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); if (!user) { @@ -112,10 +133,10 @@ export const authorization = wrap(async (req, res) => { if (!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 = {}; let consented = false; if (req.method === 'GET') { @@ -128,24 +149,31 @@ export const authorization = wrap(async (req, res) => { ); // 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 - const { session: { csrf } } = req; - if (req.method === 'POST' && csrf && !(req.body.csrf && req.body.csrf === csrf)) { + const { + session: { csrf }, + } = req; + if ( + req.method === 'POST' && + csrf && + !(req.body.csrf && req.body.csrf === csrf) + ) { throw new InvalidRequest('Invalid session'); } // Save consent 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'); } else if (req.body.decision === '0') { 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( oauth2.model.user.getId(user), @@ -155,39 +183,57 @@ export const authorization = wrap(async (req, res) => { } for (const i in grantTypes) { - let data = null + let data = null; switch (grantTypes[i]) { - case 'authorization_code': - data = await oauth2.model.code.create( - oauth2.model.user.getId(user), - oauth2.model.client.getId(client), - scope, - oauth2.model.code.ttl, - ); + case 'authorization_code': + data = await oauth2.model.code.create( + oauth2.model.user.getId(user), + oauth2.model.client.getId(client), + scope, + oauth2.model.code.ttl + ); - resObj = Object.assign({ code: data }, resObj); + resObj = { code: data, ...resObj }; - break; - case 'implicit': - data = await oauth2.model.accessToken.create( - oauth2.model.user.getId(user), - oauth2.model.client.getId(client), - scope, - oauth2.model.accessToken.ttl - ); + break; + case 'implicit': + data = await oauth2.model.accessToken.create( + oauth2.model.user.getId(user), + oauth2.model.client.getId(client), + scope, + oauth2.model.accessToken.ttl + ); - resObj = Object.assign({ - token_type: 'bearer', - access_token: data, - expires_in: oauth2.model.accessToken.ttl - }, resObj); + resObj = { + token_type: 'bearer', + access_token: data, + expires_in: oauth2.model.accessToken.ttl, + ...resObj, + }; - break; - case 'none': - resObj = {}; - break; - default: - throw new UnsupportedResponseType('Unknown response_type parameter passed'); + 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': + resObj = {}; + break; + default: + throw new UnsupportedResponseType( + 'Unknown response_type parameter passed' + ); } } diff --git a/src/controller/introspection.ts b/src/controller/introspection.ts index c9da32a..9fdb2d8 100644 --- a/src/controller/introspection.ts +++ b/src/controller/introspection.ts @@ -11,7 +11,11 @@ export const introspection = wrap(async function (req, res) { if (req.body.client_id && req.body.client_secret) { clientId = req.body.client_id 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 { if (!req.headers || !req.headers.authorization) { throw new InvalidRequest('No authorization header passed'); @@ -23,7 +27,9 @@ export const introspection = wrap(async function (req, res) { } 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); @@ -33,7 +39,11 @@ export const introspection = wrap(async function (req, res) { clientId = pieces[0]; 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) { @@ -49,7 +59,7 @@ export const introspection = wrap(async function (req, res) { const resObj = { token_type: 'bearer', token: token.token, - expires_in: Math.floor(ttl / 1000) + expires_in: Math.floor(ttl / 1000), }; dataResponse(req, res, resObj); diff --git a/src/controller/token.ts b/src/controller/token.ts index 847e011..8336437 100644 --- a/src/controller/token.ts +++ b/src/controller/token.ts @@ -1,13 +1,16 @@ -import * as tokens from './tokens' +import * as tokens from './tokens'; import { InvalidRequest, InvalidClient, UnauthorizedClient, UnsupportedGrantType, - OAuth2Error -} from '../model/error' -import { data as dataResponse, error as errorResponse } from '../utils/response' -import wrap from '../utils/wrap' + OAuth2Error, +} from '../model/error'; +import { + data as dataResponse, + error as errorResponse, +} from '../utils/response'; +import wrap from '../utils/wrap'; import { OAuth2TokenResponse } from '../model/model'; 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) { clientId = req.body.client_id 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 { if (!req.headers || !req.headers.authorization) { throw new InvalidRequest('No authorization header passed'); @@ -32,7 +39,9 @@ export const token = wrap(async (req, res) => { } 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); @@ -42,15 +51,21 @@ export const token = wrap(async (req, res) => { clientId = pieces[0]; 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) { - 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; - console.debug('Parameter grant_type is', grantType); + req.oauth2.logger.debug('Parameter grant_type is', grantType); 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'); } - 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'); } else { - console.debug('Grant type check passed'); + req.oauth2.logger.debug('Grant type check passed'); } let tokenResponse: OAuth2TokenResponse; try { switch (grantType) { - case 'authorization_code': - tokenResponse = await tokens.authorizationCode(oauth2, client, req.body.code); - break; - case 'password': - tokenResponse = await tokens.password(oauth2, client, req.body.username, req.body.password, req.body.scope); - break; - case 'client_credentials': - tokenResponse = await tokens.clientCredentials(oauth2, client, req.body.scope); - break; - case 'refresh_token': - tokenResponse = await tokens.refreshToken(oauth2, client, req.body.refresh_token); - break; - default: - throw new UnsupportedGrantType('Grant type does not match any supported type'); + case 'authorization_code': + tokenResponse = await tokens.authorizationCode( + oauth2, + client, + req.body.code + ); + break; + case 'password': + tokenResponse = await tokens.password( + oauth2, + client, + req.body.username, + req.body.password, + req.body.scope + ); + break; + case 'client_credentials': + tokenResponse = await tokens.clientCredentials( + oauth2, + client, + req.body.scope + ); + break; + case 'refresh_token': + tokenResponse = await tokens.refreshToken( + oauth2, + client, + req.body.refresh_token + ); + break; + default: + throw new UnsupportedGrantType( + 'Grant type does not match any supported type' + ); } if (tokenResponse) { dataResponse(req, res, tokenResponse); } - } catch (e) { errorResponse(req, res, e as OAuth2Error); } -}) +}); diff --git a/src/controller/tokens/authorizationCode.ts b/src/controller/tokens/authorizationCode.ts index ba2ab71..c3b08b0 100644 --- a/src/controller/tokens/authorizationCode.ts +++ b/src/controller/tokens/authorizationCode.ts @@ -1,5 +1,10 @@ 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 @@ -14,24 +19,28 @@ export async function authorizationCode( providedCode: string ): Promise { const respObj: OAuth2TokenResponse = { - token_type: 'bearer' + token_type: 'bearer', }; let code: OAuth2Code | null = null; 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 { code = await oauth2.model.code.fetchByCode(providedCode); } catch (err) { - console.error(err); + oauth2.logger.error(err); throw new ServerError('Failed to call code.fetchByCode function'); } 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'); } @@ -42,56 +51,81 @@ export async function authorizationCode( throw new InvalidGrant('Code not found'); } - console.debug('Code fetched', code); + oauth2.logger.debug('Code fetched', code); - try { - await oauth2.model.refreshToken.removeByUserIdClientId( - oauth2.model.code.getUserId(code), - oauth2.model.code.getClientId(code) - ); - } catch (err) { - console.error(err) - throw new ServerError('Failed to call refreshToken.removeByUserIdClientId function'); + 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 { + await oauth2.model.refreshToken.removeByUserIdClientId(userId, clientId); + } catch (err) { + oauth2.logger.error(err); + throw new ServerError( + 'Failed to call refreshToken.removeByUserIdClientId function' + ); + } + + oauth2.logger.debug('Refresh token removed'); } - console.debug('Refresh token removed'); - 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 { try { respObj.refresh_token = await oauth2.model.refreshToken.create( - oauth2.model.code.getUserId(code), - oauth2.model.code.getClientId(code), - oauth2.model.code.getScope(code) + userId, + clientId, + scope ); } catch (err) { - console.error(err); + oauth2.logger.error(err); throw new ServerError('Failed to call refreshToken.create function'); } } try { respObj.access_token = await oauth2.model.accessToken.create( - oauth2.model.code.getUserId(code), - oauth2.model.code.getClientId(code), - oauth2.model.code.getScope(code), + userId, + clientId, + scope, oauth2.model.accessToken.ttl ); } catch (err) { - console.error(err); + oauth2.logger.error(err); throw new ServerError('Failed to call accessToken.create function'); } 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 { await oauth2.model.code.removeByCode(providedCode); } catch (err) { - console.error(err); + oauth2.logger.error(err); 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; } diff --git a/src/controller/tokens/clientCredentials.ts b/src/controller/tokens/clientCredentials.ts index e2a0035..8fc2e30 100644 --- a/src/controller/tokens/clientCredentials.ts +++ b/src/controller/tokens/clientCredentials.ts @@ -1,4 +1,4 @@ -import { ServerError, InvalidScope } from '../../model/error' +import { ServerError, InvalidScope } from '../../model/error'; import { OAuth2, OAuth2Client, OAuth2TokenResponse } from '../../model/model'; /** @@ -16,7 +16,7 @@ export async function clientCredentials( let scope: string[] = []; const resObj: OAuth2TokenResponse = { - token_type: 'bearer' + token_type: 'bearer', }; 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'); } - console.debug('Scope check passed ', scope); + oauth2.logger.debug('Scope check passed', scope); try { resObj.access_token = await oauth2.model.accessToken.create( diff --git a/src/controller/tokens/index.ts b/src/controller/tokens/index.ts index 24e814c..dc499b1 100644 --- a/src/controller/tokens/index.ts +++ b/src/controller/tokens/index.ts @@ -1,4 +1,4 @@ -export * from './authorizationCode' -export * from './clientCredentials' -export * from './password' -export * from './refreshToken' +export * from './authorizationCode'; +export * from './clientCredentials'; +export * from './password'; +export * from './refreshToken'; diff --git a/src/controller/tokens/password.ts b/src/controller/tokens/password.ts index 0e68449..46d6b48 100644 --- a/src/controller/tokens/password.ts +++ b/src/controller/tokens/password.ts @@ -1,12 +1,22 @@ -import { ServerError, InvalidRequest, InvalidScope, InvalidClient } from '../../model/error' -import { OAuth2, OAuth2Client, OAuth2User, OAuth2TokenResponse } from '../../model/model'; +import { + ServerError, + InvalidRequest, + InvalidScope, + InvalidClient, +} from '../../model/error'; +import { + OAuth2, + OAuth2Client, + OAuth2User, + OAuth2TokenResponse, +} from '../../model/model'; /** * Implicit access token response * @param oauth2 - OAuth2 instance * @param client - OAuth2 client * @param username - * @param password + * @param password * @param scope - Requested scopes * @returns Access token */ @@ -20,8 +30,8 @@ export async function password( let user: OAuth2User | null = null; const resObj: OAuth2TokenResponse = { - token_type: 'bearer' - } + token_type: 'bearer', + }; if (!username) { 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)) { throw new InvalidScope('Client does not allow access to this scope'); } else { - console.debug('Scope check passed: ', scope); + oauth2.logger.debug('Scope check passed: ', scope); } try { @@ -59,13 +69,17 @@ export async function password( oauth2.model.client.getId(client) ); } 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')) { - 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 { try { resObj.refresh_token = await oauth2.model.refreshToken.create( @@ -90,7 +104,7 @@ export async function password( } 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; } diff --git a/src/controller/tokens/refreshToken.ts b/src/controller/tokens/refreshToken.ts index 90d94a8..91d5d18 100644 --- a/src/controller/tokens/refreshToken.ts +++ b/src/controller/tokens/refreshToken.ts @@ -1,24 +1,29 @@ -import { InvalidRequest, ServerError, InvalidGrant, InvalidClient } from '../../model/error'; +import { + InvalidRequest, + ServerError, + InvalidGrant, + InvalidClient, +} from '../../model/error'; import { OAuth2, OAuth2AccessToken, OAuth2Client, OAuth2RefreshToken, OAuth2User, - OAuth2TokenResponse + OAuth2TokenResponse, } from '../../model/model'; /** * Get a new access token from a refresh token. Scope change may not be requested. * @param oauth2 - OAuth2 instance * @param client - OAuth2 client - * @param pRefreshToken - Refresh token + * @param providedToken - Refresh token * @returns Access token */ export async function refreshToken( oauth2: OAuth2, client: OAuth2Client, - pRefreshToken: string, + providedToken: string ): Promise { let user: OAuth2User | null = null; let ttl: number | null = null; @@ -26,15 +31,17 @@ export async function refreshToken( let accessToken: OAuth2AccessToken | null = null; const resObj: OAuth2TokenResponse = { - token_type: 'bearer' + token_type: 'bearer', }; - if (!pRefreshToken) { - throw new InvalidRequest('refresh_token is mandatory for refresh_token grant type'); + if (!providedToken) { + throw new InvalidRequest( + 'refresh_token is mandatory for refresh_token grant type' + ); } try { - refreshToken = await oauth2.model.refreshToken.fetchByToken(pRefreshToken); + refreshToken = await oauth2.model.refreshToken.fetchByToken(providedToken); } catch (err) { throw new ServerError('Failed to call refreshToken.fetchByToken function'); } @@ -43,8 +50,12 @@ export async function refreshToken( throw new InvalidGrant('Refresh token not found'); } - if (oauth2.model.refreshToken.getClientId(refreshToken) !== oauth2.model.client.getId(client)) { - console.warn('Client %s tried to fetch a refresh token which belongs to client %s!', + if ( + 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.refreshToken.getClientId(refreshToken) ); @@ -52,7 +63,9 @@ export async function refreshToken( } 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) { throw new InvalidClient('User not found'); } @@ -62,10 +75,14 @@ export async function refreshToken( } try { - accessToken = await oauth2.model.accessToken.fetchByUserIdClientId(oauth2.model.user.getId(user), - oauth2.model.client.getId(client)); + accessToken = await oauth2.model.accessToken.fetchByUserIdClientId( + oauth2.model.user.getId(user), + oauth2.model.client.getId(client) + ); } catch (err) { - throw new ServerError('Failed to call accessToken.fetchByUserIdClientId function'); + throw new ServerError( + 'Failed to call accessToken.fetchByUserIdClientId function' + ); } if (accessToken) { diff --git a/src/middleware.ts b/src/middleware.ts index 6c86e87..f2cd9a0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { AccessDenied } from './model/error'; import wrap from './utils/wrap'; 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; // Look for token in header @@ -21,13 +21,16 @@ export const middleware = wrap(async function (req: Request, res, next) { } 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) { 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) { 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 { 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'); } else { res.locals.accessToken = object; - console.debug('AccessToken fetched', object); + req.oauth2.logger.debug('AccessToken fetched', object); next(); } }); diff --git a/src/model/error.ts b/src/model/error.ts index 0e2cb0e..6ba17c3 100644 --- a/src/model/error.ts +++ b/src/model/error.ts @@ -2,8 +2,12 @@ export class OAuth2Error extends Error { public name = 'OAuth2AbstractError'; public logLevel = 'error'; - constructor (public code: string, public message: string, public status: number) { - super() + constructor( + public code: string, + public message: string, + public status: number + ) { + super(); Error.captureStackTrace(this, this.constructor); } } @@ -12,7 +16,7 @@ export class AccessDenied extends OAuth2Error { public name = 'OAuth2AccessDenied'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('access_denied', msg, 403); } } @@ -21,7 +25,7 @@ export class InvalidClient extends OAuth2Error { public name = 'OAuth2InvalidClient'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('invalid_client', msg, 401); } } @@ -30,7 +34,7 @@ export class InvalidGrant extends OAuth2Error { public name = 'OAuth2InvalidGrant'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('invalid_grant', msg, 400); } } @@ -39,7 +43,7 @@ export class InvalidRequest extends OAuth2Error { public name = 'OAuth2InvalidRequest'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('invalid_request', msg, 400); } } @@ -48,7 +52,7 @@ export class InvalidScope extends OAuth2Error { public name = 'OAuth2InvalidScope'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('invalid_scope', msg, 400); } } @@ -57,7 +61,7 @@ export class ServerError extends OAuth2Error { public name = 'OAuth2ServerError'; public logLevel = 'error'; - constructor (msg: string) { + constructor(msg: string) { super('server_error', msg, 500); } } @@ -66,7 +70,7 @@ export class UnauthorizedClient extends OAuth2Error { public name = 'OAuth2UnauthorizedClient'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('unauthorized_client', msg, 400); } } @@ -75,7 +79,7 @@ export class UnsupportedGrantType extends OAuth2Error { public name = 'OAuth2UnsupportedGrantType'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('unsupported_grant_type', msg, 400); } } @@ -84,7 +88,7 @@ export class UnsupportedResponseType extends OAuth2Error { public name = 'OAuth2UnsupportedResponseType'; public logLevel = 'info'; - constructor (msg: string) { + constructor(msg: string) { super('unsupported_response_type', msg, 400); } } diff --git a/src/model/model.ts b/src/model/model.ts index 6c9a391..ddbe952 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { OAuth2Logger } from '../utils/logger'; /** * OAuth2 client object @@ -11,7 +12,7 @@ export interface OAuth2Client { } /** - * OAuth2 access token object + * OAuth2 access token object */ export interface OAuth2AccessToken { token: string; @@ -33,7 +34,7 @@ export interface OAuth2Code { } /** - * OAuth2 refresh token object + * OAuth2 refresh token object */ export interface OAuth2RefreshToken { token: string; @@ -46,7 +47,6 @@ export interface OAuth2RefreshToken { * OAuth2 implicit user model */ export interface OAuth2User { - id: string | number; username: string; password: string; } @@ -55,6 +55,7 @@ export interface OAuth2User { * OAuth2 token response */ export interface OAuth2TokenResponse { + id_token?: string; access_token?: string; refresh_token?: string; expires_in?: number; @@ -89,7 +90,9 @@ export interface OAuth2AccessTokenAdapter { /** * Fetch an access token by the token string from the database */ - fetchByToken: (token: OAuth2AccessToken | string) => Promise; + fetchByToken: ( + token: OAuth2AccessToken | string + ) => Promise; /** * Check the time-to-live value @@ -163,7 +166,7 @@ export interface OAuth2CodeAdapter { * Static time-to-live in seconds for code */ ttl: number; - + /** * Create a new code */ @@ -209,6 +212,11 @@ export interface OAuth2CodeAdapter { * OAuth2 refresh token adapter model */ export interface OAuth2RefreshTokenAdapter { + /** + * Invalidate all previous refresh tokens for user/client when new one is issued. + */ + invalidateOld: boolean; + /** * Create a new refresh token */ @@ -217,12 +225,14 @@ export interface OAuth2RefreshTokenAdapter { clientId: string | number, scope: string | string[] ) => Promise; - + /** * Fetch a token from the database */ - fetchByToken: (token: OAuth2RefreshToken | string) => Promise; - + fetchByToken: ( + token: OAuth2RefreshToken | string + ) => Promise; + /** * Remove refresh token by user ID and client ID */ @@ -230,22 +240,22 @@ export interface OAuth2RefreshTokenAdapter { userId: string | number, clientId: string | number ) => Promise; - + /** * Remove token by the token itself */ removeByRefreshToken: (token: string) => Promise; - + /** * Get user ID from token */ getUserId: (code: OAuth2RefreshToken) => string; - + /** * Get client ID from token */ getClientId: (code: OAuth2RefreshToken) => string; - + /** * Get scope from token */ @@ -276,7 +286,7 @@ export interface OAuth2UserAdapter { */ checkPassword: (user: OAuth2User, password: string) => Promise; - /** + /** * Fetch the user from the express request */ fetchFromRequest: (req: Request) => Promise; @@ -300,6 +310,25 @@ export interface OAuth2UserAdapter { ) => Promise; } +/** + * Adapter for managing the `openid` scope. + */ +export interface JWTAdapter { + /** + * Issue a new ID token for user. + */ + issueIdToken: ( + user: OAuth2User, + scope: string[], + accessToken?: string + ) => Promise; + + /** + * Validate an ID token + */ + validateIdToken: (idToken: string) => Promise; +} + /** * Render the OAuth2 decision page */ @@ -321,6 +350,7 @@ export interface OAuth2AdapterModel { user: OAuth2UserAdapter; client: OAuth2ClientAdapter; code: OAuth2CodeAdapter; + jwt?: JWTAdapter; } /** @@ -328,5 +358,6 @@ export interface OAuth2AdapterModel { */ export interface OAuth2 { model: OAuth2AdapterModel; + logger: OAuth2Logger; decision: RenderOAuth2Decision; } diff --git a/src/provider.ts b/src/provider.ts index 6796dc2..64d3078 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,22 +1,28 @@ import { RequestHandler } from 'express'; import * as controller from './controller'; 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 { public bearer = middleware; public controller = controller; + public logger = new OAuth2Logger('info'); - constructor ( + constructor( public model: OAuth2AdapterModel, - public decision: RenderOAuth2Decision, + public decision: RenderOAuth2Decision ) {} express(): RequestHandler { return (req, _res, next) => { - console.debug('OAuth2 Injected into request'); + req.oauth2.logger.debug('OAuth2 Injected into request'); req.oauth2 = this; next(); - } + }; } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..cfd5fe7 --- /dev/null +++ b/src/utils/logger.ts @@ -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'); +} diff --git a/src/utils/response.ts b/src/utils/response.ts index 831b493..2ba5889 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -13,40 +13,49 @@ function dataRes(req: Request, res: Response, code: number, data: any): void { res.header('Cache-Control', 'no-store'); res.header('Pragma', 'no-cache'); res.status(code).send(data); - console.debug('Response:', data); + req.oauth2.logger.debug('Response:', data); } function redirect(req: Request, res: Response, redirectUri: string): void { res.header('Location', redirectUri); 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 if (!(err instanceof OAuth2Error)) { - console.error((err as Error).stack); + req.oauth2.logger.error((err as Error).stack); err = new ServerError('Uncaught exception'); } else { - console.error('Exception caught', err.stack); + req.oauth2.logger.error('Exception caught', err.stack); } if (redirectUri) { const obj: ErrorResponseData = { error: err.code, - error_description: err.message + error_description: err.message, }; if (req.query.state) { obj.state = req.query.state as string; } - redirectUri += '?' + (new URLSearchParams(obj as Record).toString()); + redirectUri += + '?' + new URLSearchParams(obj as Record).toString(); redirect(req, res, redirectUri); 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( @@ -57,15 +66,15 @@ export function data( fragment: boolean = false ): void { if (redirectUri) { - redirectUri += fragment - ? '#' - : (redirectUri.indexOf('?') === -1 ? '?' : '&'); + redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&'; if (req.query.state) { obj.state = req.query.state as string; } - redirectUri += new URLSearchParams(obj as Record).toString(); + redirectUri += new URLSearchParams( + obj as Record + ).toString(); redirect(req, res, redirectUri); return; } diff --git a/src/utils/wrap.ts b/src/utils/wrap.ts index 3326f31..b2a1d59 100644 --- a/src/utils/wrap.ts +++ b/src/utils/wrap.ts @@ -1,6 +1,8 @@ import { RequestHandler } from 'express'; import { error } from './response'; -export default (fn: RequestHandler, redir?: boolean): RequestHandler => (req, res, next) => - (fn(req, res, next) as unknown as Promise).catch(e => - error(req, res, e, redir ? (req.query.redirect_uri as string) : undefined)); +export default (fn: RequestHandler, redir?: boolean): RequestHandler => + (req, res, next) => + (fn(req, res, next) as unknown as Promise).catch((e) => + error(req, res, e, redir ? (req.query.redirect_uri as string) : undefined) + );