Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add M2MTokenUserInfoFetcher [DEV-3516] #468

Merged
merged 10 commits into from
Jan 12, 2024
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', new AccountController().create);
app.get('/account', new AccountController().get);
app.get('/account/idtoken', new AccountController().getIdToken);

Expand Down
115 changes: 113 additions & 2 deletions src/controllers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,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 @@ -303,4 +301,117 @@ 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 LogTo app id from request header
DaevMithran marked this conversation as resolved.
Show resolved Hide resolved
// 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
DaevMithran marked this conversation as resolved.
Show resolved Hide resolved
if (!request.body.user || !request.body.user.primaryEmail) {
DaevMithran marked this conversation as resolved.
Show resolved Hide resolved
return response.status(StatusCodes.BAD_REQUEST).json({
error: 'User id is not specified or primaryEmail is not set',
});
}
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 });
if (accounts.length === 0) {
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',
});
}
} else {
paymentAccount = accounts[0];
}

// 4. Check the token balance for Testnet account
if (paymentAccount.address && process.env.ENABLE_ACCOUNT_TOPUP === 'true') {
DaevMithran marked this conversation as resolved.
Show resolved Hide resolved
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.OK).json(customer);
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
error: `Internal Error: ${(error as Error)?.message || error}`,
});
}
}
}
18 changes: 14 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,24 @@ 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));
const customerId = request.headers['customer-id'];
if (typeof customerId === 'string') this.setCustomerId(customerId);
}
} 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
11 changes: 11 additions & 0 deletions src/middleware/auth/routine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface IAuthParams {
reset(): void;
// Getters
getUserId(): string;
getCustomerId(): string;
getScopes(): string[];
getNamespace(): Namespaces;
getIsAllowedUnauthorized(): boolean;
Expand All @@ -34,6 +35,7 @@ export interface IAuthParams {

export class AuthParams implements IAuthParams {
userId: string;
customerId: string;
scopes: string[];
namespace: Namespaces;
isAllowedUnauthorized: boolean;
Expand All @@ -44,6 +46,7 @@ export class AuthParams implements IAuthParams {
this.namespace = '' as Namespaces;
this.scopes = [];
this.userId = '' as string;
this.customerId = '' as string;
this.isAllowedUnauthorized = false;
this.rule = {} as MethodToScopeRule;
this.routeToScoupeList = [];
Expand All @@ -53,6 +56,9 @@ export class AuthParams implements IAuthParams {
public getUserId(): string {
return this.userId;
}
public getCustomerId(): string {
return this.customerId;
}
public getScopes(): string[] {
return this.scopes;
}
Expand All @@ -73,6 +79,9 @@ export class AuthParams implements IAuthParams {
public setUserId(userId: string): void {
this.userId = userId;
}
public setCustomerId(customerId: string): void {
this.customerId = customerId;
}
public setScopes(scopes: string[]): void {
this.scopes = scopes;
}
Expand Down Expand Up @@ -112,6 +121,7 @@ export class AuthReturn extends AuthParams implements IReturn {
error: '',
data: {
userId: this.getUserId(),
customerId: this.getCustomerId(),
scopes: this.getScopes() as string[],
namespace: this.getNamespace(),
isAllowedUnauthorized: this.getIsAllowedUnauthorized(),
Expand All @@ -125,6 +135,7 @@ export class AuthReturn extends AuthParams implements IReturn {
error: error,
data: {
userId: '',
customerId: '',
scopes: [],
namespace: this.getNamespace(),
isAllowedUnauthorized: this.getIsAllowedUnauthorized(),
Expand Down
Loading
Loading