Initial commit
This commit is contained in:
commit
0f726741f9
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
1
.npmignore
Normal file
1
.npmignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
src/
|
1178
package-lock.json
generated
Normal file
1178
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@icynet/oauth2-provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "OAuth2.0 Provider for Icy Network",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"prepublish": "npm run build",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "Evert <evert@lunasqu.ee>",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/express-session": "^1.17.4",
|
||||||
|
"@types/node": "^17.0.21",
|
||||||
|
"typescript": "^4.5.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.17.3",
|
||||||
|
"express-session": "^1.17.2"
|
||||||
|
}
|
||||||
|
}
|
196
src/controller/authorization.ts
Normal file
196
src/controller/authorization.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
InvalidRequest,
|
||||||
|
UnsupportedResponseType,
|
||||||
|
InvalidClient,
|
||||||
|
UnauthorizedClient,
|
||||||
|
InvalidScope,
|
||||||
|
AccessDenied,
|
||||||
|
} from '../model/error';
|
||||||
|
import { OAuth2User } from '../model/model';
|
||||||
|
import { data as dataResponse } from '../utils/response';
|
||||||
|
import wrap from '../utils/wrap';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization and decision endpoint.
|
||||||
|
*/
|
||||||
|
export const authorization = wrap(async (req, res) => {
|
||||||
|
let clientId: string | null = null;
|
||||||
|
let redirectUri: string | null = null;
|
||||||
|
let responseType: string | null = null;
|
||||||
|
let grantTypes: string[] = [];
|
||||||
|
let scope: string[] | null = null;
|
||||||
|
let user: OAuth2User | null = null;
|
||||||
|
|
||||||
|
const { oauth2 } = req;
|
||||||
|
|
||||||
|
if (!req.query.redirect_uri) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!req.query.client_id) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId = req.query.client_id as string;
|
||||||
|
console.debug('Parameter client_id is', clientId);
|
||||||
|
|
||||||
|
if (!req.query.response_type) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out duplicates
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('Parameter grant_type is', grantTypes.join(' '));
|
||||||
|
|
||||||
|
const client = await oauth2.model.client.fetchById(clientId);
|
||||||
|
if (!client) {
|
||||||
|
throw new InvalidClient('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: multiple redirect URI
|
||||||
|
if (!oauth2.model.client.getRedirectUri(client)) {
|
||||||
|
throw new UnsupportedResponseType('The client has not set a redirect uri');
|
||||||
|
} else if (!oauth2.model.client.checkRedirectUri(client, redirectUri)) {
|
||||||
|
throw new InvalidRequest('Wrong RedirectUri provided');
|
||||||
|
}
|
||||||
|
console.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.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');
|
||||||
|
|
||||||
|
user = await oauth2.model.user.fetchFromRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
throw new InvalidRequest('There is no currently logged in user');
|
||||||
|
} else {
|
||||||
|
if (!user.username) {
|
||||||
|
throw new AccessDenied(user.username);
|
||||||
|
}
|
||||||
|
console.debug('User fetched from request')
|
||||||
|
}
|
||||||
|
|
||||||
|
let resObj = {};
|
||||||
|
let consented = false;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
// Check if the user has already consented to this client with this scope
|
||||||
|
// TODO: reevaluate security implications
|
||||||
|
consented = await oauth2.model.user.consented(
|
||||||
|
oauth2.model.user.getId(user),
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ask for consent
|
||||||
|
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)) {
|
||||||
|
throw new InvalidRequest('Invalid session');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save consent
|
||||||
|
if (!consented) {
|
||||||
|
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');
|
||||||
|
|
||||||
|
await oauth2.model.user.consent(
|
||||||
|
oauth2.model.user.getId(user),
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const i in grantTypes) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
resObj = Object.assign({ 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
|
||||||
|
);
|
||||||
|
|
||||||
|
resObj = Object.assign({
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return non-code response types as fragment instead of query
|
||||||
|
return dataResponse(req, res, resObj, redirectUri, responseType !== 'code');
|
||||||
|
}, true);
|
3
src/controller/index.ts
Normal file
3
src/controller/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './authorization';
|
||||||
|
export * from './introspection';
|
||||||
|
export * from './token';
|
56
src/controller/introspection.ts
Normal file
56
src/controller/introspection.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { InvalidRequest } from '../model/error';
|
||||||
|
import { data as dataResponse } from '../utils/response';
|
||||||
|
import wrap from '../utils/wrap';
|
||||||
|
|
||||||
|
export const introspection = wrap(async function (req, res) {
|
||||||
|
let clientId: string | null = null;
|
||||||
|
let clientSecret: string | null = null;
|
||||||
|
|
||||||
|
const { oauth2 } = req;
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
if (!req.headers || !req.headers.authorization) {
|
||||||
|
throw new InvalidRequest('No authorization header passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let pieces = req.headers.authorization.split(' ', 2);
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new InvalidRequest('Authorization header is corrupted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces[0] !== 'Basic') {
|
||||||
|
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new InvalidRequest('Authorization header has corrupted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId = pieces[0];
|
||||||
|
clientSecret = pieces[1];
|
||||||
|
console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.body.token) {
|
||||||
|
throw new InvalidRequest('Token not provided in request body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await oauth2.model.accessToken.fetchByToken(req.body.token);
|
||||||
|
if (!token) {
|
||||||
|
throw new InvalidRequest('Token does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = oauth2.model.accessToken.getTTL(token);
|
||||||
|
const resObj = {
|
||||||
|
token_type: 'bearer',
|
||||||
|
token: token.token,
|
||||||
|
expires_in: Math.floor(ttl / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
dataResponse(req, res, resObj);
|
||||||
|
});
|
98
src/controller/token.ts
Normal file
98
src/controller/token.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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'
|
||||||
|
import { OAuth2TokenResponse } from '../model/model';
|
||||||
|
|
||||||
|
export const token = wrap(async (req, res) => {
|
||||||
|
let clientId: string | null = null;
|
||||||
|
let clientSecret: string | null = null;
|
||||||
|
let grantType: string | null = null;
|
||||||
|
|
||||||
|
const { oauth2 } = req;
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
if (!req.headers || !req.headers.authorization) {
|
||||||
|
throw new InvalidRequest('No authorization header passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let pieces = req.headers.authorization.split(' ', 2);
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new InvalidRequest('Authorization header is corrupted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces[0] !== 'Basic') {
|
||||||
|
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new InvalidRequest('Authorization header has corrupted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId = pieces[0];
|
||||||
|
clientSecret = pieces[1];
|
||||||
|
console.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');
|
||||||
|
}
|
||||||
|
|
||||||
|
grantType = req.body.grant_type as string;
|
||||||
|
console.debug('Parameter grant_type is', grantType);
|
||||||
|
|
||||||
|
const client = await oauth2.model.client.fetchById(clientId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new InvalidClient('Client not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = oauth2.model.client.checkSecret(client, clientSecret);
|
||||||
|
if (!valid) {
|
||||||
|
throw new UnauthorizedClient('Invalid client secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenResponse) {
|
||||||
|
dataResponse(req, res, tokenResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
errorResponse(req, res, e as OAuth2Error);
|
||||||
|
}
|
||||||
|
})
|
97
src/controller/tokens/authorizationCode.ts
Normal file
97
src/controller/tokens/authorizationCode.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { InvalidRequest, ServerError, InvalidGrant } from '../../model/error';
|
||||||
|
import { OAuth2, OAuth2Client, OAuth2Code, OAuth2TokenResponse } from '../../model/model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue an access token by authorization code
|
||||||
|
* @param oauth2 - OAuth2 instance
|
||||||
|
* @param client - OAuth2 client
|
||||||
|
* @param providedCode - Authorization code
|
||||||
|
* @returns Access token.
|
||||||
|
*/
|
||||||
|
export async function authorizationCode(
|
||||||
|
oauth2: OAuth2,
|
||||||
|
client: OAuth2Client,
|
||||||
|
providedCode: string
|
||||||
|
): Promise<OAuth2TokenResponse> {
|
||||||
|
const respObj: OAuth2TokenResponse = {
|
||||||
|
token_type: 'bearer'
|
||||||
|
};
|
||||||
|
|
||||||
|
let code: OAuth2Code | null = null;
|
||||||
|
|
||||||
|
if (!providedCode) {
|
||||||
|
throw new InvalidRequest('code is mandatory for authorization_code grant type');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
code = await oauth2.model.code.fetchByCode(providedCode);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
throw new ServerError('Failed to call code.fetchByCode function');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
if (oauth2.model.code.getClientId(code) !== oauth2.model.client.getId(client)) {
|
||||||
|
throw new InvalidGrant('Code was issued by another client');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oauth2.model.code.checkTTL(code)) {
|
||||||
|
throw new InvalidGrant('Code has already expired');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new InvalidGrant('Code not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
} 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)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.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),
|
||||||
|
oauth2.model.accessToken.ttl
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await oauth2.model.code.removeByCode(providedCode);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
throw new ServerError('Failed to call code.removeByCode function');
|
||||||
|
}
|
||||||
|
|
||||||
|
return respObj;
|
||||||
|
}
|
43
src/controller/tokens/clientCredentials.ts
Normal file
43
src/controller/tokens/clientCredentials.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { ServerError, InvalidScope } from '../../model/error'
|
||||||
|
import { OAuth2, OAuth2Client, OAuth2TokenResponse } from '../../model/model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue client access token
|
||||||
|
* @param oauth2 - OAuth2 instance
|
||||||
|
* @param client - Client
|
||||||
|
* @param wantScope - Requested scopes
|
||||||
|
* @returns Access token
|
||||||
|
*/
|
||||||
|
export async function clientCredentials(
|
||||||
|
oauth2: OAuth2,
|
||||||
|
client: OAuth2Client,
|
||||||
|
wantScope: string | string[]
|
||||||
|
): Promise<OAuth2TokenResponse> {
|
||||||
|
let scope: string[] = [];
|
||||||
|
|
||||||
|
const resObj: OAuth2TokenResponse = {
|
||||||
|
token_type: 'bearer'
|
||||||
|
};
|
||||||
|
|
||||||
|
scope = oauth2.model.client.transformScope(wantScope);
|
||||||
|
if (!oauth2.model.client.checkScope(client, scope)) {
|
||||||
|
throw new InvalidScope('Client does not allow access to this scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('Scope check passed ', scope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
resObj.access_token = await oauth2.model.accessToken.create(
|
||||||
|
null,
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
scope,
|
||||||
|
oauth2.model.accessToken.ttl
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call accessToken.create function');
|
||||||
|
}
|
||||||
|
|
||||||
|
resObj.expires_in = oauth2.model.accessToken.ttl;
|
||||||
|
|
||||||
|
return resObj;
|
||||||
|
}
|
4
src/controller/tokens/index.ts
Normal file
4
src/controller/tokens/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './authorizationCode'
|
||||||
|
export * from './clientCredentials'
|
||||||
|
export * from './password'
|
||||||
|
export * from './refreshToken'
|
96
src/controller/tokens/password.ts
Normal file
96
src/controller/tokens/password.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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 scope - Requested scopes
|
||||||
|
* @returns Access token
|
||||||
|
*/
|
||||||
|
export async function password(
|
||||||
|
oauth2: OAuth2,
|
||||||
|
client: OAuth2Client,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
scope: string | string[]
|
||||||
|
): Promise<OAuth2TokenResponse> {
|
||||||
|
let user: OAuth2User | null = null;
|
||||||
|
|
||||||
|
const resObj: OAuth2TokenResponse = {
|
||||||
|
token_type: 'bearer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
throw new InvalidRequest('Username is mandatory for password grant type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new InvalidRequest('Password is mandatory for password grant type');
|
||||||
|
}
|
||||||
|
|
||||||
|
scope = oauth2.model.client.transformScope(scope);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
user = await oauth2.model.user.fetchByUsername(username);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call user.fetchByUsername function');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new InvalidClient('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await oauth2.model.user.checkPassword(user, password);
|
||||||
|
if (!valid) {
|
||||||
|
throw new InvalidClient('Wrong password');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await oauth2.model.refreshToken.removeByUserIdClientId(
|
||||||
|
oauth2.model.user.getId(user),
|
||||||
|
oauth2.model.client.getId(client)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call refreshToken.removeByUserIdClientId function');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
resObj.refresh_token = await oauth2.model.refreshToken.create(
|
||||||
|
oauth2.model.user.getId(user),
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call refreshToken.create function');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resObj.access_token = await oauth2.model.accessToken.create(
|
||||||
|
oauth2.model.user.getId(user),
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
scope,
|
||||||
|
oauth2.model.accessToken.ttl
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call accessToken.create function');
|
||||||
|
}
|
||||||
|
|
||||||
|
resObj.expires_in = oauth2.model.accessToken.ttl;
|
||||||
|
console.debug('Access token saved ', resObj.access_token);
|
||||||
|
|
||||||
|
return resObj;
|
||||||
|
}
|
98
src/controller/tokens/refreshToken.ts
Normal file
98
src/controller/tokens/refreshToken.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { InvalidRequest, ServerError, InvalidGrant, InvalidClient } from '../../model/error';
|
||||||
|
import {
|
||||||
|
OAuth2,
|
||||||
|
OAuth2AccessToken,
|
||||||
|
OAuth2Client,
|
||||||
|
OAuth2RefreshToken,
|
||||||
|
OAuth2User,
|
||||||
|
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
|
||||||
|
* @returns Access token
|
||||||
|
*/
|
||||||
|
export async function refreshToken(
|
||||||
|
oauth2: OAuth2,
|
||||||
|
client: OAuth2Client,
|
||||||
|
pRefreshToken: string,
|
||||||
|
): Promise<OAuth2TokenResponse> {
|
||||||
|
let user: OAuth2User | null = null;
|
||||||
|
let ttl: number | null = null;
|
||||||
|
let refreshToken: OAuth2RefreshToken | null = null;
|
||||||
|
let accessToken: OAuth2AccessToken | null = null;
|
||||||
|
|
||||||
|
const resObj: OAuth2TokenResponse = {
|
||||||
|
token_type: 'bearer'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!pRefreshToken) {
|
||||||
|
throw new InvalidRequest('refresh_token is mandatory for refresh_token grant type');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
refreshToken = await oauth2.model.refreshToken.fetchByToken(pRefreshToken);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call refreshToken.fetchByToken function');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!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!',
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
oauth2.model.refreshToken.getClientId(refreshToken)
|
||||||
|
);
|
||||||
|
throw new InvalidGrant('Refresh token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
user = await oauth2.model.user.fetchById(oauth2.model.refreshToken.getUserId(refreshToken));
|
||||||
|
} catch (err) {
|
||||||
|
throw new InvalidClient('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new InvalidClient('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
ttl = oauth2.model.accessToken.getTTL(accessToken);
|
||||||
|
|
||||||
|
if (!ttl) {
|
||||||
|
accessToken = null;
|
||||||
|
} else {
|
||||||
|
resObj.access_token = oauth2.model.accessToken.getToken(accessToken);
|
||||||
|
resObj.expires_in = ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
try {
|
||||||
|
resObj.access_token = await oauth2.model.accessToken.create(
|
||||||
|
oauth2.model.user.getId(user),
|
||||||
|
oauth2.model.client.getId(client),
|
||||||
|
oauth2.model.refreshToken.getScope(refreshToken),
|
||||||
|
oauth2.model.accessToken.ttl
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServerError('Failed to call accessToken.create function');
|
||||||
|
}
|
||||||
|
|
||||||
|
resObj.expires_in = oauth2.model.accessToken.ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resObj;
|
||||||
|
}
|
7
src/index.ts
Normal file
7
src/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export * from './model/model';
|
||||||
|
|
||||||
|
export * from './model/error';
|
||||||
|
export * from './controller';
|
||||||
|
|
||||||
|
export * from './middleware';
|
||||||
|
export * from './provider';
|
46
src/middleware.ts
Normal file
46
src/middleware.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
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');
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
// Look for token in header
|
||||||
|
if (req.headers.authorization) {
|
||||||
|
const pieces = req.headers.authorization.split(' ', 2);
|
||||||
|
|
||||||
|
// Check authorization header
|
||||||
|
if (!pieces || pieces.length !== 2) {
|
||||||
|
throw new AccessDenied('Wrong authorization header');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only bearer auth is supported
|
||||||
|
if (pieces[0].toLowerCase() !== 'bearer') {
|
||||||
|
throw new AccessDenied('Unsupported authorization method in header');
|
||||||
|
}
|
||||||
|
|
||||||
|
token = pieces[1];
|
||||||
|
console.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);
|
||||||
|
} else if (req.body?.access_token) {
|
||||||
|
token = req.body.access_token;
|
||||||
|
console.debug('Bearer token parsed from body params:', token);
|
||||||
|
} else {
|
||||||
|
throw new AccessDenied('Bearer token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch access token
|
||||||
|
const object = await req.oauth2.model.accessToken.fetchByToken(token);
|
||||||
|
if (!object) {
|
||||||
|
throw new AccessDenied('Token not found or has expired');
|
||||||
|
} else if (!req.oauth2.model.accessToken.checkTTL(object)) {
|
||||||
|
throw new AccessDenied('Token is expired');
|
||||||
|
} else {
|
||||||
|
res.locals.accessToken = object;
|
||||||
|
console.debug('AccessToken fetched', object);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
90
src/model/error.ts
Normal file
90
src/model/error.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
export class OAuth2Error extends Error {
|
||||||
|
public name = 'OAuth2AbstractError';
|
||||||
|
public logLevel = 'error';
|
||||||
|
|
||||||
|
constructor (public code: string, public message: string, public status: number) {
|
||||||
|
super()
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccessDenied extends OAuth2Error {
|
||||||
|
public name = 'OAuth2AccessDenied';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('access_denied', msg, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidClient extends OAuth2Error {
|
||||||
|
public name = 'OAuth2InvalidClient';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('invalid_client', msg, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidGrant extends OAuth2Error {
|
||||||
|
public name = 'OAuth2InvalidGrant';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('invalid_grant', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidRequest extends OAuth2Error {
|
||||||
|
public name = 'OAuth2InvalidRequest';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('invalid_request', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidScope extends OAuth2Error {
|
||||||
|
public name = 'OAuth2InvalidScope';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('invalid_scope', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerError extends OAuth2Error {
|
||||||
|
public name = 'OAuth2ServerError';
|
||||||
|
public logLevel = 'error';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('server_error', msg, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedClient extends OAuth2Error {
|
||||||
|
public name = 'OAuth2UnauthorizedClient';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('unauthorized_client', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsupportedGrantType extends OAuth2Error {
|
||||||
|
public name = 'OAuth2UnsupportedGrantType';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('unsupported_grant_type', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsupportedResponseType extends OAuth2Error {
|
||||||
|
public name = 'OAuth2UnsupportedResponseType';
|
||||||
|
public logLevel = 'info';
|
||||||
|
|
||||||
|
constructor (msg: string) {
|
||||||
|
super('unsupported_response_type', msg, 400);
|
||||||
|
}
|
||||||
|
}
|
2
src/model/index.ts
Normal file
2
src/model/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './error';
|
||||||
|
export * from './model';
|
332
src/model/model.ts
Normal file
332
src/model/model.ts
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 client object
|
||||||
|
*/
|
||||||
|
export interface OAuth2Client {
|
||||||
|
id: string | number;
|
||||||
|
secret: string;
|
||||||
|
scope: string[];
|
||||||
|
grants: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 access token object
|
||||||
|
*/
|
||||||
|
export interface OAuth2AccessToken {
|
||||||
|
token: string;
|
||||||
|
user_id: string | number;
|
||||||
|
client_id: string | number;
|
||||||
|
scope: string;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 authorization code object
|
||||||
|
*/
|
||||||
|
export interface OAuth2Code {
|
||||||
|
code: string;
|
||||||
|
expires_at: number;
|
||||||
|
user_id: string | number;
|
||||||
|
client_id: string | number;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 refresh token object
|
||||||
|
*/
|
||||||
|
export interface OAuth2RefreshToken {
|
||||||
|
token: string;
|
||||||
|
user_id: string | number;
|
||||||
|
client_id: string | number;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 implicit user model
|
||||||
|
*/
|
||||||
|
export interface OAuth2User {
|
||||||
|
id: string | number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 token response
|
||||||
|
*/
|
||||||
|
export interface OAuth2TokenResponse {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 access token adapter model
|
||||||
|
*/
|
||||||
|
export interface OAuth2AccessTokenAdapter {
|
||||||
|
/**
|
||||||
|
* Static time-to-live in seconds for access token
|
||||||
|
*/
|
||||||
|
ttl: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the token string from an access token object
|
||||||
|
*/
|
||||||
|
getToken: (token: OAuth2AccessToken) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new access token
|
||||||
|
*/
|
||||||
|
create: (
|
||||||
|
userId: string | number | null,
|
||||||
|
clientId: string | number,
|
||||||
|
scope: string | string[],
|
||||||
|
ttl: number
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an access token by the token string from the database
|
||||||
|
*/
|
||||||
|
fetchByToken: (token: OAuth2AccessToken | string) => Promise<OAuth2AccessToken>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the time-to-live value
|
||||||
|
*/
|
||||||
|
checkTTL: (token: OAuth2AccessToken) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the time-to-live value from a token object
|
||||||
|
*/
|
||||||
|
getTTL: (token: OAuth2AccessToken) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an access token by user ID and client ID from the database
|
||||||
|
*/
|
||||||
|
fetchByUserIdClientId: (
|
||||||
|
userId: string | number,
|
||||||
|
clientId: string | number
|
||||||
|
) => Promise<OAuth2AccessToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 client adapter model
|
||||||
|
*/
|
||||||
|
export interface OAuth2ClientAdapter {
|
||||||
|
/**
|
||||||
|
* Get client ID from client object
|
||||||
|
*/
|
||||||
|
getId: (client: OAuth2Client) => string | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client object from the database by client ID
|
||||||
|
*/
|
||||||
|
fetchById: (id: string | number) => Promise<OAuth2Client>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the client secret
|
||||||
|
*/
|
||||||
|
checkSecret: (client: OAuth2Client, secret: string) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check grant type
|
||||||
|
*/
|
||||||
|
checkGrantType: (client: OAuth2Client, grant: string) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the redirect uri of a client
|
||||||
|
*/
|
||||||
|
getRedirectUri: (client: OAuth2Client) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the redirect uri against a client
|
||||||
|
*/
|
||||||
|
checkRedirectUri: (client: OAuth2Client, redirectUri: string) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform the scope into a string array of scopes
|
||||||
|
*/
|
||||||
|
transformScope: (scope: string | string[]) => string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check scopes against client
|
||||||
|
*/
|
||||||
|
checkScope: (client: OAuth2Client, scope: string[]) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 response code adapter model
|
||||||
|
*/
|
||||||
|
export interface OAuth2CodeAdapter {
|
||||||
|
/**
|
||||||
|
* Static time-to-live in seconds for code
|
||||||
|
*/
|
||||||
|
ttl: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new code
|
||||||
|
*/
|
||||||
|
create: (
|
||||||
|
userId: string | number,
|
||||||
|
clientId: string | number,
|
||||||
|
scope: string | string[],
|
||||||
|
ttl: number
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a code by the code string from the database
|
||||||
|
*/
|
||||||
|
fetchByCode: (code: OAuth2Code | string) => Promise<OAuth2Code>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a code
|
||||||
|
*/
|
||||||
|
removeByCode: (code: string | OAuth2Code) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user ID from a code object
|
||||||
|
*/
|
||||||
|
getUserId: (code: OAuth2Code) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client ID from a code object
|
||||||
|
*/
|
||||||
|
getClientId: (code: OAuth2Code) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope from a code object
|
||||||
|
*/
|
||||||
|
getScope: (code: OAuth2Code) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the time-to-live value
|
||||||
|
*/
|
||||||
|
checkTTL: (code: OAuth2Code) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 refresh token adapter model
|
||||||
|
*/
|
||||||
|
export interface OAuth2RefreshTokenAdapter {
|
||||||
|
/**
|
||||||
|
* Create a new refresh token
|
||||||
|
*/
|
||||||
|
create: (
|
||||||
|
userId: string | number,
|
||||||
|
clientId: string | number,
|
||||||
|
scope: string | string[]
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a token from the database
|
||||||
|
*/
|
||||||
|
fetchByToken: (token: OAuth2RefreshToken | string) => Promise<OAuth2RefreshToken>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove refresh token by user ID and client ID
|
||||||
|
*/
|
||||||
|
removeByUserIdClientId: (
|
||||||
|
userId: string | number,
|
||||||
|
clientId: string | number
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove token by the token itself
|
||||||
|
*/
|
||||||
|
removeByRefreshToken: (token: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user ID from token
|
||||||
|
*/
|
||||||
|
getUserId: (code: OAuth2RefreshToken) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client ID from token
|
||||||
|
*/
|
||||||
|
getClientId: (code: OAuth2RefreshToken) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scope from token
|
||||||
|
*/
|
||||||
|
getScope: (code: OAuth2RefreshToken) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 user adapter model
|
||||||
|
*/
|
||||||
|
export interface OAuth2UserAdapter {
|
||||||
|
/**
|
||||||
|
* Get user ID
|
||||||
|
*/
|
||||||
|
getId: (user: OAuth2User) => string | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user from the database by ID
|
||||||
|
*/
|
||||||
|
fetchById: (id: string | number) => Promise<OAuth2User>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a user from the database by username
|
||||||
|
*/
|
||||||
|
fetchByUsername: (username: string) => Promise<OAuth2User>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check user's password for implicit grant
|
||||||
|
*/
|
||||||
|
checkPassword: (user: OAuth2User, password: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the user from the express request
|
||||||
|
*/
|
||||||
|
fetchFromRequest: (req: Request) => Promise<OAuth2User>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has already consented to this client and the scopes. Return false to force a decision.
|
||||||
|
*/
|
||||||
|
consented: (
|
||||||
|
userId: string | number,
|
||||||
|
clientId: string | number,
|
||||||
|
scope: string | string[]
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a consent
|
||||||
|
*/
|
||||||
|
consent: (
|
||||||
|
userId: string | number,
|
||||||
|
clientId: string | number,
|
||||||
|
scope: string | string[]
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the OAuth2 decision page
|
||||||
|
*/
|
||||||
|
export type RenderOAuth2Decision = (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
client: OAuth2Client,
|
||||||
|
scope: string[],
|
||||||
|
user: OAuth2User,
|
||||||
|
redirectUri: string
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 adapter model
|
||||||
|
*/
|
||||||
|
export interface OAuth2AdapterModel {
|
||||||
|
accessToken: OAuth2AccessTokenAdapter;
|
||||||
|
refreshToken: OAuth2RefreshTokenAdapter;
|
||||||
|
user: OAuth2UserAdapter;
|
||||||
|
client: OAuth2ClientAdapter;
|
||||||
|
code: OAuth2CodeAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 adapter
|
||||||
|
*/
|
||||||
|
export interface OAuth2 {
|
||||||
|
model: OAuth2AdapterModel;
|
||||||
|
decision: RenderOAuth2Decision;
|
||||||
|
}
|
22
src/provider.ts
Normal file
22
src/provider.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { RequestHandler } from 'express';
|
||||||
|
import * as controller from './controller';
|
||||||
|
import { middleware } from './middleware';
|
||||||
|
import { RenderOAuth2Decision, OAuth2, OAuth2AdapterModel } from './model/model';
|
||||||
|
|
||||||
|
export class OAuth2Provider implements OAuth2 {
|
||||||
|
public bearer = middleware;
|
||||||
|
public controller = controller;
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
public model: OAuth2AdapterModel,
|
||||||
|
public decision: RenderOAuth2Decision,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
express(): RequestHandler {
|
||||||
|
return (req, _res, next) => {
|
||||||
|
console.debug('OAuth2 Injected into request');
|
||||||
|
req.oauth2 = this;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
src/types/express/index.d.ts
vendored
Normal file
15
src/types/express/index.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { OAuth2 } from '../../model/model';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
export interface Request {
|
||||||
|
oauth2: OAuth2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
csrf: string;
|
||||||
|
}
|
||||||
|
}
|
74
src/utils/response.ts
Normal file
74
src/utils/response.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { OAuth2Error, ServerError } from '../model/error';
|
||||||
|
import { OAuth2TokenResponse } from '../model/model';
|
||||||
|
|
||||||
|
interface ErrorResponseData {
|
||||||
|
[x: string]: string | undefined;
|
||||||
|
error: string;
|
||||||
|
error_description: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect(req: Request, res: Response, redirectUri: string): void {
|
||||||
|
res.header('Location', redirectUri);
|
||||||
|
res.status(302).end();
|
||||||
|
console.debug('Redirecting to', redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
err = new ServerError('Uncaught exception');
|
||||||
|
} else {
|
||||||
|
console.error('Exception caught', err.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUri) {
|
||||||
|
const obj: ErrorResponseData = {
|
||||||
|
error: err.code,
|
||||||
|
error_description: err.message
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.state) {
|
||||||
|
obj.state = req.query.state as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectUri += '?' + (new URLSearchParams(obj as Record<string, string>).toString());
|
||||||
|
redirect(req, res, redirectUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRes(req, res, err.status, { error: err.code, error_description: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function data(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
obj: OAuth2TokenResponse,
|
||||||
|
redirectUri?: string,
|
||||||
|
fragment: boolean = false
|
||||||
|
): void {
|
||||||
|
if (redirectUri) {
|
||||||
|
redirectUri += fragment
|
||||||
|
? '#'
|
||||||
|
: (redirectUri.indexOf('?') === -1 ? '?' : '&');
|
||||||
|
|
||||||
|
if (req.query.state) {
|
||||||
|
obj.state = req.query.state as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectUri += new URLSearchParams(obj as Record<string, string>).toString();
|
||||||
|
redirect(req, res, redirectUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRes(req, res, 200, obj);
|
||||||
|
}
|
6
src/utils/wrap.ts
Normal file
6
src/utils/wrap.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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<void>).catch(e =>
|
||||||
|
error(req, res, e, redir ? (req.query.redirect_uri as string) : undefined));
|
104
tsconfig.json
Normal file
104
tsconfig.json
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
// "incremental": true, /* Enable incremental compilation */
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "commonjs", /* Specify what module code is generated. */
|
||||||
|
"rootDir": "./src", /* Specify the root folder within your source files. */
|
||||||
|
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types",
|
||||||
|
"src/types"
|
||||||
|
],
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files */
|
||||||
|
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user