Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/migrations/2024-09-06-create-api-token-table.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ApiToken from "@src/app/models/auth/ApiToken";
import BaseMigration from "@src/core/domains/migrations/base/BaseMigration";
import { DataTypes } from "sequelize";
import ApiToken from "@src/app/models/auth/ApiToken";

export class CreateApiTokenMigration extends BaseMigration {

Expand All @@ -17,6 +17,7 @@ export class CreateApiTokenMigration extends BaseMigration {
await this.schema.createTable(this.table, {
userId: DataTypes.STRING,
token: DataTypes.STRING,
scopes: DataTypes.JSON,
revokedAt: DataTypes.DATE
})
}
Expand Down
21 changes: 21 additions & 0 deletions src/app/models/auth/ApiToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ class ApiToken extends Model<IApiTokenData> implements IApiTokenModel {
public fields: string[] = [
'userId',
'token',
'scopes',
'revokedAt'
]

public json: string[] = [
'scopes'
]

/**
* Disable createdAt and updatedAt timestamps
*/
Expand All @@ -39,6 +44,22 @@ class ApiToken extends Model<IApiTokenData> implements IApiTokenModel {
})
}

/**
* Checks if the given scope(s) are present in the scopes of this ApiToken
* @param scopes The scope(s) to check
* @returns True if all scopes are present, false otherwise
*/
public hasScope(scopes: string | string[]): boolean {
const currentScopes = this.getAttribute('scopes') ?? [];
scopes = typeof scopes === 'string' ? [scopes] : scopes;

for(const scope of scopes) {
if(!currentScopes.includes(scope)) return false;
}

return true;
}

}

export default ApiToken
3 changes: 2 additions & 1 deletion src/core/domains/auth/factory/apiTokenFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ class ApiTokenFactory extends Factory<IApiTokenModel, IApiTokenData> {
* @param {IUserModel} user
* @returns {IApiTokenModel}
*/
createFromUser(user: IUserModel): IApiTokenModel {
createFromUser(user: IUserModel, scopes: string[] = []): IApiTokenModel {
return new this.modelCtor({
userId: user.data?.id,
token: tokenFactory(),
scopes: scopes,
revokedAt: null,
})
}
Expand Down
5 changes: 4 additions & 1 deletion src/core/domains/auth/interfaces/IApitokenModel.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
/* eslint-disable no-unused-vars */
import { IModel } from "@src/core/interfaces/IModel";
import IModelData from "@src/core/interfaces/IModelData";

export interface IApiTokenData extends IModelData {
userId: string;
token: string
token: string;
scopes: string[];
revokedAt: Date | null;
}

export default interface IApiTokenModel extends IModel<IApiTokenData> {
user(): Promise<any>;
hasScope(scopes: string | string[]): boolean;
}
6 changes: 3 additions & 3 deletions src/core/domains/auth/interfaces/IAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface IAuthService extends IService {
* @returns {Promise<string>} The JWT token
* @memberof IAuthService
*/
createJwtFromUser: (user: IUserModel) => Promise<string>;
createJwtFromUser: (user: IUserModel, scopes?: string[]) => Promise<string>;

/**
* Creates a new ApiToken model from the User
Expand All @@ -65,7 +65,7 @@ export interface IAuthService extends IService {
* @returns {Promise<IApiTokenModel>} The new ApiToken model
* @memberof IAuthService
*/
createApiTokenFromUser: (user: IUserModel) => Promise<IApiTokenModel>;
createApiTokenFromUser: (user: IUserModel, scopes?: string[]) => Promise<IApiTokenModel>;

/**
* Revokes a token.
Expand All @@ -84,7 +84,7 @@ export interface IAuthService extends IService {
* @returns {Promise<string>} The JWT token
* @memberof IAuthService
*/
attemptCredentials: (email: string, password: string) => Promise<string>;
attemptCredentials: (email: string, password: string, scopes?: string[]) => Promise<string>;

/**
* Generates a JWT.
Expand Down
12 changes: 6 additions & 6 deletions src/core/domains/auth/services/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export default class AuthService extends Service<IAuthConfig> implements IAuthSe
* @param user
* @returns
*/
public async createApiTokenFromUser(user: IUserModel): Promise<IApiTokenModel> {
const apiToken = new ApiTokenFactory().createFromUser(user)
public async createApiTokenFromUser(user: IUserModel, scopes: string[] = []): Promise<IApiTokenModel> {
const apiToken = new ApiTokenFactory().createFromUser(user, scopes)
await apiToken.save();
return apiToken
}
Expand All @@ -69,8 +69,8 @@ export default class AuthService extends Service<IAuthConfig> implements IAuthSe
* @param user
* @returns
*/
async createJwtFromUser(user: IUserModel): Promise<string> {
const apiToken = await this.createApiTokenFromUser(user);
async createJwtFromUser(user: IUserModel, scopes: string[] = []): Promise<string> {
const apiToken = await this.createApiTokenFromUser(user, scopes);
return this.jwt(apiToken)
}

Expand Down Expand Up @@ -139,7 +139,7 @@ export default class AuthService extends Service<IAuthConfig> implements IAuthSe
* @param password
* @returns
*/
async attemptCredentials(email: string, password: string): Promise<string> {
async attemptCredentials(email: string, password: string, scopes: string[] = []): Promise<string> {
const user = await this.userRepository.findOneByEmail(email) as IUserModel;

if (!user?.data?.id) {
Expand All @@ -150,7 +150,7 @@ export default class AuthService extends Service<IAuthConfig> implements IAuthSe
throw new UnauthorizedError()
}

return this.createJwtFromUser(user)
return this.createJwtFromUser(user, scopes)
}

/**
Expand Down
31 changes: 30 additions & 1 deletion src/core/domains/express/middleware/authorizeMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import ForbiddenResourceError from '@src/core/domains/auth/exceptions/ForbiddenResourceError';
import UnauthorizedError from '@src/core/domains/auth/exceptions/UnauthorizedError';
import AuthRequest from '@src/core/domains/auth/services/AuthRequest';
import responseError from '@src/core/domains/express/requests/responseError';
import { BaseRequest } from '@src/core/domains/express/types/BaseRequest.t';
import { NextFunction, Response } from 'express';

/**
* Validates that the scopes in the api token match the required scopes for the request.
* If the scopes do not match, it will throw a ForbiddenResourceError.
* If no api token is found, it will throw a UnauthorizedError.
* @param scopes The scopes required for the request
* @param req The request object
* @param res The response object
*/
const validateScopes = async (scopes: string[], req: BaseRequest, res: Response) => {
if(scopes.length === 0) {
return;
}

const apiToken = req.apiToken;

if(!apiToken) {
responseError(req, res, new UnauthorizedError(), 401);
return;
}

if(!apiToken.hasScope(scopes)) {
responseError(req, res, new ForbiddenResourceError('Required scopes missing from authorization'), 403);
}
}

/**
* Authorize middleware
*
Expand All @@ -16,7 +42,7 @@ import { NextFunction, Response } from 'express';
* @param {NextFunction} next - The next function
* @returns {Promise<void>}
*/
export const authorizeMiddleware = () => async (req: BaseRequest, res: Response, next: NextFunction): Promise<void> => {
export const authorizeMiddleware = (scopes: string[] = []) => async (req: BaseRequest, res: Response, next: NextFunction): Promise<void> => {
try {

// Authorize the request
Expand All @@ -25,6 +51,9 @@ export const authorizeMiddleware = () => async (req: BaseRequest, res: Response,
// and sets the user in the App
await AuthRequest.attemptAuthorizeRequest(req);

// Validate the scopes if the authorization was successful
validateScopes(scopes, req, res);

next();
}
catch (error) {
Expand Down