Skip to content

Commit

Permalink
feat: Add M2MTokenUserInfoFetcher [DEV-3516] (#468)
Browse files Browse the repository at this point in the history
* feat: Add account create api

* feat: Support m2m token in auth

* feat: Add getAppScopes in auth

* Update account/create api

* Update create api flow & auth

* Add request validator

* Delegate tokens only to testnet account

* chore: Suggestion to simplifying the user or customer getting (#469)

* fix: Fix error handling on tracking operations [DEV-3527] (#461)

* fix: Fix error handling on tracking operations

* Silently log tracking errors

* Remove duplicate return statements

---------

Co-authored-by: Andrew Nikitin <andrew.nikitin@cheqd.io>

* chore(release): 2.15.1-develop.1 [skip ci]

## [2.15.1-develop.1](2.15.0...2.15.1-develop.1) (2024-01-11)

### Bug Fixes

* Fix error handling on tracking operations [DEV-3527] ([#461](#461)) ([53d7dfd](53d7dfd))

* Simplyfing setting up user or customer from fetcher

* Fix it for account creating

---------

Co-authored-by: DaevMithran <61043607+DaevMithran@users.noreply.github.com>
Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>

---------

Co-authored-by: Andrew Nikitin <andrew.nikitin@cheqd.io>
Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
  • Loading branch information
3 people authored Jan 12, 2024
1 parent b6b9754 commit 279ec36
Show file tree
Hide file tree
Showing 14 changed files with 376 additions and 20 deletions.
4 changes: 2 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ class App {
}

private middleware() {
const auth = new Authentication();
this.express.use(
express.json({
limit: '50mb',
Expand All @@ -68,8 +67,8 @@ class App {
},
})
);

this.express.use(cookieParser());
const auth = new Authentication();
if (process.env.ENABLE_AUTHENTICATION === 'true') {
this.express.use(
session({
Expand Down Expand Up @@ -185,6 +184,7 @@ class App {
);

// Account API
app.post('/account/create', AccountController.createValidator, new AccountController().create);
app.get('/account', new AccountController().get);
app.get('/account/idtoken', new AccountController().getIdToken);

Expand Down
132 changes: 130 additions & 2 deletions src/controllers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,24 @@ import type { CustomerEntity } from '../database/entities/customer.entity.js';
import type { UserEntity } from '../database/entities/user.entity.js';
import type { PaymentAccountEntity } from '../database/entities/payment.account.entity.js';
import { IdentityServiceStrategySetup } from '../services/identity/index.js';
import { check, validationResult } from 'express-validator';

export class AccountController {
public static createValidator = [
check('user')
.exists()
.withMessage('user is required')
.isObject()
.withMessage('user property should be valid object')
.bail(),
check('user.primaryEmail')
.exists()
.withMessage('user.primaryEmail is required')
.trim()
.isEmail()
.withMessage('primaryEmail is not a valid email id')
.bail(),
];
/**
* @openapi
*
Expand Down Expand Up @@ -152,14 +168,12 @@ export class AccountController {
}
const logToUserId = request.body.user.id;
const logToUserEmail = request.body.user.primaryEmail;

const defaultRole = await RoleService.instance.getDefaultRole();
if (!defaultRole) {
return response.status(StatusCodes.BAD_REQUEST).json({
error: 'Default role is not set on Credential Service side',
});
}

// 2. Check if such row exists in the DB
user = await UserService.instance.get(logToUserId);
if (!user) {
Expand Down Expand Up @@ -290,4 +304,118 @@ export class AccountController {
}
return response.status(StatusCodes.OK).json({});
}

/**
* @openapi
*
* /account/create:
* post:
* tags: [Account]
* summary: Create an client for an authenticated user.
* description: This endpoint creates a client in the custodian-mode for an authenticated user
* requestBody:
* content:
* application/x-www-form-urlencoded:
* schema:
* $ref: '#/components/schemas/AccountCreateRequest'
* application/json:
* schema:
* $ref: '#/components/schemas/AccountCreateRequest'
* responses:
* 200:
* description: The request was successful.
* content:
* application/json:
* idToken:
* type: string
* 400:
* $ref: '#/components/schemas/InvalidRequest'
* 401:
* $ref: '#/components/schemas/UnauthorizedError'
* 500:
* $ref: '#/components/schemas/InternalError'
*/
public async create(request: Request, response: Response) {
// For now we keep temporary 1-1 relation between user and customer
// So the flow is:
// 1. Get LogToPrimaryEmail from request body
// 2. Check if the customer exists
// 2.1. if no - Create customer
// 3. Check is paymentAccount exists for the customer
// 3.1. If no - create it
// 4. Check the token balance for Testnet account
let customer: CustomerEntity | null;
let paymentAccount: PaymentAccountEntity | null;

// 1. Get logTo UserId from request body
// validate request
const result = validationResult(request);
// handle error
if (!result.isEmpty()) {
return response.status(StatusCodes.BAD_REQUEST).json({ error: result.array().pop()?.msg });
}

const logToUserEmail = request.body.user.primaryEmail;

try {
// 2. Check if the customer exists
if (response.locals.customer) {
customer = response.locals.customer as CustomerEntity;
} else {
// 2.1 Create customer
customer = (await CustomerService.instance.create(logToUserEmail)) as CustomerEntity;
if (!customer) {
return response.status(StatusCodes.BAD_REQUEST).json({
error: 'Customer creation failed',
});
}
}

// 3. Check is paymentAccount exists for the customer
const accounts = await PaymentAccountService.instance.find({ customer });
paymentAccount = accounts.find((account) => account.namespace === CheqdNetwork.Testnet) || null;
if (paymentAccount === null) {
const key = await new IdentityServiceStrategySetup(customer.customerId).agent.createKey(
'Secp256k1',
customer
);
if (!key) {
return response.status(StatusCodes.BAD_REQUEST).json({
error: 'PaymentAccount is not found in db: Key was not created',
});
}
paymentAccount = (await PaymentAccountService.instance.create(
CheqdNetwork.Testnet,
true,
customer,
key
)) as PaymentAccountEntity;
if (!paymentAccount) {
return response.status(StatusCodes.BAD_REQUEST).json({
error: 'PaymentAccount is not found in db: Payment account was not created',
});
}
}

// 4. Check the token balance for Testnet account
if (paymentAccount.address && process.env.ENABLE_ACCOUNT_TOPUP === 'true') {
const balances = await checkBalance(paymentAccount.address, process.env.TESTNET_RPC_URL);
const balance = balances[0];
if (!balance || +balance.amount < TESTNET_MINIMUM_BALANCE * Math.pow(10, DEFAULT_DENOM_EXPONENT)) {
// 3.1 If it's less then required for DID creation - assign new portion from testnet-faucet
const resp = await FaucetHelper.delegateTokens(paymentAccount.address);
if (resp.status !== StatusCodes.OK) {
return response.status(StatusCodes.BAD_GATEWAY).json({
error: resp.error,
});
}
}
}
return response.status(StatusCodes.CREATED).json(customer);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
error: `Internal Error: ${(error as Error)?.message || error}`,
});
}
}
}
16 changes: 12 additions & 4 deletions src/middleware/auth/base-auth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { APITokenUserInfoFetcher } from './user-info-fetcher/api-token.js';
import type { IUserInfoFetcher } from './user-info-fetcher/base.js';
import { IAuthHandler, RuleRoutine, IAPIGuard } from './routine.js';
import type { IAuthResponse, MethodToScopeRule } from '../../types/authentication.js';
import { M2MTokenUserInfoFetcher } from './user-info-fetcher/m2m-token.js';
import { decodeJwt } from 'jose';

export class BaseAPIGuard extends RuleRoutine implements IAPIGuard {
userInfoFetcher: IUserInfoFetcher = {} as IUserInfoFetcher;
Expand Down Expand Up @@ -46,6 +48,7 @@ export class BaseAPIGuard extends RuleRoutine implements IAPIGuard {
}
this.setScopes(_res.data.scopes);
this.setUserId(_res.data.userId);
this.setCustomerId(_res.data.customerId);
// Checks if the list of scopes from user enough to make an action
if (!this.areValidScopes(this.getRule(), this.getScopes())) {
return this.returnError(
Expand Down Expand Up @@ -107,7 +110,7 @@ export class BaseAPIGuard extends RuleRoutine implements IAPIGuard {
export class BaseAuthHandler extends BaseAPIGuard implements IAuthHandler {
private nextHandler: IAuthHandler;
oauthProvider: IOAuthProvider;
private bearerTokenIdentifier = 'Bearer';
private static bearerTokenIdentifier = 'Bearer';
private pathSkip = ['/swagger', '/static', '/logto', '/account/bootstrap'];

constructor() {
Expand All @@ -118,17 +121,22 @@ export class BaseAuthHandler extends BaseAPIGuard implements IAuthHandler {
this.nextHandler = {} as IAuthHandler;
}

public extractBearerTokenFromHeaders({ authorization }: IncomingHttpHeaders): string | unknown {
public static extractBearerTokenFromHeaders({ authorization }: IncomingHttpHeaders): string | unknown {
if (authorization && authorization.startsWith(this.bearerTokenIdentifier)) {
return authorization.slice(this.bearerTokenIdentifier.length + 1);
}
return undefined;
}

private chooseUserFetcherStrategy(request: Request): void {
const token = this.extractBearerTokenFromHeaders(request.headers) as string;
const token = BaseAuthHandler.extractBearerTokenFromHeaders(request.headers) as string;
if (token) {
this.setUserInfoStrategy(new APITokenUserInfoFetcher(token));
const payload = decodeJwt(token);
if (payload.aud === process.env.LOGTO_APP_ID) {
this.setUserInfoStrategy(new APITokenUserInfoFetcher(token));
} else {
this.setUserInfoStrategy(new M2MTokenUserInfoFetcher(token));
}
} else {
this.setUserInfoStrategy(new SwaggerUserInfoFetcher());
}
Expand Down
58 changes: 58 additions & 0 deletions src/middleware/auth/logto-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider {
return await this.assignDefaultRoleForUser(userId, process.env.LOGTO_DEFAULT_ROLE_ID);
}

public async setDefaultRoleForApp(appId: string): Promise<ICommonErrorResponse> {
const roles = await this.getRolesForUser(appId);
if (roles.status !== StatusCodes.OK) {
return this.returnError(StatusCodes.BAD_GATEWAY, roles.error);
}
// Check that default role is set
for (const role of roles.data) {
if (role.id === process.env.LOGTO_DEFAULT_ROLE_ID) {
return this.returnOk(roles.data);
}
}

// Assign a default role to a user
return await this.assignDefaultRoleForApp(appId, process.env.LOGTO_DEFAULT_ROLE_ID);
}

private returnOk(data: any): ICommonErrorResponse {
return {
status: StatusCodes.OK,
Expand Down Expand Up @@ -114,6 +130,23 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider {
}
return this.returnOk(scopes);
}

public async getAppScopes(appId: string): Promise<ICommonErrorResponse> {
const scopes = [] as string[];
const roles = await this.getRolesForApp(appId);
if (roles.status !== StatusCodes.OK) {
return this.returnError(StatusCodes.BAD_GATEWAY, roles.error);
}
// Check that default role is set
for (const role of roles.data) {
const _s = await this.getScopesForRole(role.id);
if (_s.status === StatusCodes.OK) {
scopes.push(..._s.data);
}
}
return this.returnOk(scopes);
}

private async setDefaultScopes(): Promise<ICommonErrorResponse> {
const _r = await this.getAllResources();
if (_r.status !== StatusCodes.OK) {
Expand Down Expand Up @@ -224,6 +257,17 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider {
return this.returnError(StatusCodes.BAD_GATEWAY, `getRolesForUser ${err}`);
}
}

public async getRolesForApp(appId: string): Promise<ICommonErrorResponse> {
const uri = new URL(`/api/applications/${appId}/roles`, process.env.LOGTO_ENDPOINT);
try {
// Note: By default, the API returns first 20 roles.
// If our roles per user grows to more than 20, we need to implement pagination
return await this.getToLogto(uri, 'GET');
} catch (err) {
return this.returnError(StatusCodes.BAD_GATEWAY, `getRolesForUser ${err}`);
}
}
private async getRoleInfo(roleId: string): Promise<ICommonErrorResponse> {
const uri = new URL(`/api/roles/${roleId}`, process.env.LOGTO_ENDPOINT);
try {
Expand Down Expand Up @@ -264,6 +308,20 @@ export class LogToHelper extends OAuthProvider implements IOAuthProvider {
return this.returnError(StatusCodes.BAD_GATEWAY, `getRolesForUser ${err}`);
}
}

private async assignDefaultRoleForApp(appId: string, roleId: string): Promise<ICommonErrorResponse> {
const uri = new URL(`/api/applications/${appId}/roles`, process.env.LOGTO_ENDPOINT);
// Such role exists
try {
const body = {
roleIds: [roleId],
};
return await this.postToLogto(uri, body, { 'Content-Type': 'application/json' });
} catch (err) {
return this.returnError(StatusCodes.BAD_GATEWAY, `getRolesForUser ${err}`);
}
}

private async getRoleIdByName(roleName: string): Promise<ICommonErrorResponse> {
const uri = new URL(`/api/roles`, process.env.LOGTO_ENDPOINT);
try {
Expand Down
4 changes: 4 additions & 0 deletions src/middleware/auth/oauth/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface IOAuthProvider {
getDefaultScopes(): string[] | void;
getAllResourcesWithNames(): string[] | void;
getUserScopes(userId: string): Promise<ICommonErrorResponse>;
getAppScopes(appId: string): Promise<ICommonErrorResponse>;
getScopesForRoles(rolesList: string[]): Promise<string[] | void>;
}

Expand All @@ -28,6 +29,9 @@ export abstract class OAuthProvider implements IOAuthProvider {
getUserScopes(userId: string): Promise<ICommonErrorResponse> {
throw new Error('Method not implemented.');
}
getAppScopes(appId: string): Promise<ICommonErrorResponse> {
throw new Error('Method not implemented.');
}
getScopesForRoles(rolesList: string[]): Promise<string[] | void> {
throw new Error('Method not implemented.');
}
Expand Down
4 changes: 4 additions & 0 deletions src/middleware/auth/oauth/logto-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export class LogToProvider extends OAuthProvider implements IOAuthProvider {
return await this.logToHelper.getUserScopes(userId);
}

public async getAppScopes(appId: string): Promise<ICommonErrorResponse> {
return await this.logToHelper.getAppScopes(appId);
}

public async getScopesForRoles(rolesList: string[]): Promise<string[] | void> {
if (this.logToHelper) {
const scopes = await this.logToHelper.getScopesForRolesList(rolesList);
Expand Down
1 change: 1 addition & 0 deletions src/middleware/auth/routes/account-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class AccountAuthHandler extends BaseAuthHandler {
constructor() {
super();
this.registerRoute('/account', 'GET', 'read:account', { skipNamespace: true });
this.registerRoute('/account', 'POST', 'create:account', { skipNamespace: true });
}
public async handle(request: Request, response: Response): Promise<IAuthResponse> {
if (!request.path.includes('/account')) {
Expand Down
Loading

0 comments on commit 279ec36

Please sign in to comment.