From df3ce8449a3bc7fe251078399710631b39d8b7e3 Mon Sep 17 00:00:00 2001 From: Vivian Plasencia Date: Fri, 21 Jun 2024 12:45:39 +0200 Subject: [PATCH 1/6] feat: support groups with multiple credentials using logical operators Now Bandada supports groups with multiple credentials using logical operators. re #223 --- .../credentials/credentials.service.test.ts | 80 ++++- .../app/credentials/credentials.service.ts | 174 +++++++++- .../src/app/credentials/dto/add-member.dto.ts | 17 +- apps/dashboard/src/api/bandadaAPI.ts | 13 +- .../new-group-stepper/access-mode-step.tsx | 322 +++++++++++++++++- apps/dashboard/src/pages/credentials.tsx | 206 ++++++++++- apps/dashboard/src/pages/group.tsx | 125 +++++-- libs/credentials/src/evaluateExpression.ts | 120 +++++++ libs/credentials/src/index.test.ts | 73 ++++ libs/credentials/src/index.ts | 6 +- libs/credentials/src/validateCredentials.ts | 35 +- 11 files changed, 1093 insertions(+), 78 deletions(-) create mode 100644 libs/credentials/src/evaluateExpression.ts diff --git a/apps/api/src/app/credentials/credentials.service.test.ts b/apps/api/src/app/credentials/credentials.service.test.ts index 14163aac..46776498 100644 --- a/apps/api/src/app/credentials/credentials.service.test.ts +++ b/apps/api/src/app/credentials/credentials.service.test.ts @@ -43,6 +43,7 @@ jest.mock("@bandada/credentials", () => ({ getJsonRpcProvider: jest.fn() })), validateCredentials: jest.fn(() => true), + validateManyCredentials: jest.fn(() => true), providers: [{ name: "twitter" }, { name: "github" }, { name: "blockchain" }] })) @@ -155,7 +156,7 @@ describe("CredentialsService", () => { providerName: "github" }) - await credentialsService.addMember(_stateId, "code") + await credentialsService.addMember([_stateId], ["code"]) const fun = credentialsService.setOAuthState({ groupId, @@ -171,7 +172,7 @@ describe("CredentialsService", () => { describe("# addMember", () => { it("Should throw an error if the OAuth does not exist", async () => { - const fun = credentialsService.addMember("123", "code") + const fun = credentialsService.addMember(["123"], ["code"]) await expect(fun).rejects.toThrow(`OAuth state does not exist`) }) @@ -184,8 +185,8 @@ describe("CredentialsService", () => { }) const clientRedirectUri = await credentialsService.addMember( - _stateId, - "code" + [_stateId], + ["code"] ) expect(clientRedirectUri).toBeUndefined() @@ -231,15 +232,15 @@ describe("CredentialsService", () => { ) const clientRedirectUri1 = await credentialsService.addMember( - _stateId1, - "code" + [_stateId1], + ["code"] ) expect(clientRedirectUri1).toBeUndefined() const clientRedirectUri2 = await credentialsService.addMember( - _stateId2, - "code" + [_stateId2], + ["code"] ) expect(clientRedirectUri2).toBeUndefined() @@ -265,7 +266,7 @@ describe("CredentialsService", () => { providerName: "github" }) - const fun = credentialsService.addMember(_stateId, "code") + const fun = credentialsService.addMember([_stateId], ["code"]) await expect(fun).rejects.toThrow( `OAuth account does not match criteria` @@ -289,7 +290,7 @@ describe("CredentialsService", () => { providerName: "github" }) - const fun = credentialsService.addMember(_stateId, "code") + const fun = credentialsService.addMember([_stateId], ["code"]) await expect(fun).rejects.toThrow( `OAuth account has already joined the group` @@ -323,9 +324,9 @@ describe("CredentialsService", () => { }) const clientRedirectUri = await credentialsService.addMember( - _stateId, + [_stateId], undefined, - "0x" + ["0x"] ) expect(clientRedirectUri).toBeUndefined() @@ -339,9 +340,60 @@ describe("CredentialsService", () => { }) const clientRedirectUri = await credentialsService.addMember( - _stateId, + [_stateId], undefined, - "0x1" + ["0x1"] + ) + + expect(clientRedirectUri).toBeUndefined() + }) + it("Should add a member to a group with many credentials", async () => { + const { id } = await groupsService.createGroup( + { + name: "Group3", + description: "This is a description", + treeDepth: 16, + fingerprintDuration: 3600, + credentials: JSON.stringify({ + credentials: [ + { + id: "BLOCKCHAIN_TRANSACTIONS", + criteria: { + minTransactions: 12, + network: "sepolia" + } + }, + { + id: "GITHUB_FOLLOWERS", + criteria: { + minFollowers: 5 + } + } + ], + expression: ["", "and", ""] + }) + }, + "admin" + ) + + groupId = id + + const _stateId1 = await credentialsService.setOAuthState({ + groupId, + memberId: "1", + providerName: "blockchain" + }) + + const _stateId2 = await credentialsService.setOAuthState({ + groupId, + memberId: "1", + providerName: "github" + }) + + const clientRedirectUri = await credentialsService.addMember( + [_stateId1, _stateId2], + ["code"], + ["0x"] ) expect(clientRedirectUri).toBeUndefined() diff --git a/apps/api/src/app/credentials/credentials.service.ts b/apps/api/src/app/credentials/credentials.service.ts index a068d68f..d6893cfc 100644 --- a/apps/api/src/app/credentials/credentials.service.ts +++ b/apps/api/src/app/credentials/credentials.service.ts @@ -1,5 +1,6 @@ import { validateCredentials, + validateManyCredentials, getProvider, BlockchainProvider, Web2Provider, @@ -82,23 +83,176 @@ export class CredentialsService { * @returns Redirect URI */ async addMember( - oAuthState: string, - oAuthCode?: string, - address?: string + oAuthState: string[], + oAuthCode?: string[], + address?: string[] ): Promise { - if (!this.oAuthState.has(oAuthState)) { + if (!this.oAuthState.has(oAuthState[0])) { throw new BadRequestException(`OAuth state does not exist`) } - const { + let { groupId, memberId, providerName, redirectUri: clientRedirectUri - } = this.oAuthState.get(oAuthState) + } = this.oAuthState.get(oAuthState[0]) const group = await this.groupsService.getGroup(groupId) + if (group.credentials === null) return + + const credentials = JSON.parse(group.credentials) + + if ( + credentials.credentials !== undefined && + credentials.expression !== undefined + ) { + // multiple check + const contexts = [] + let accountHash: string + + for (let i = 0; i < credentials.credentials.length; i += 1) { + // get the name of the provider of the credential + const credentialProvider = credentials.credentials[i].id + .split("_")[0] + .toLowerCase() + + const credentialOAuthState = oAuthState.find( + (s) => + this.oAuthState.get(s).providerName === + credentialProvider + ) + + if (credentialOAuthState === undefined) { + return + } + + // get the oAuthState for this credential + ;({ + groupId, + memberId, + providerName, + redirectUri: clientRedirectUri + } = this.oAuthState.get(credentialOAuthState)) + const provider = getProvider(providerName) + + let context: Web2Context | BlockchainContext + + if (address && credentialProvider === "blockchain") { + const { network } = credentials.credentials[i].criteria + + const supportedNetwork = + blockchainCredentialSupportedNetworks.find( + (n) => + n.name.toLowerCase() === network.toLowerCase() + ) + + if (supportedNetwork === undefined) + throw new BadRequestException( + `The network is not supported` + ) + + const networkEnvVariableName = + supportedNetwork.id.toUpperCase() + + const web3providerRpcURL = + process.env[`${networkEnvVariableName}_RPC_URL`] + + // eslint-disable-next-line no-await-in-loop + const jsonRpcProvider = await ( + provider as BlockchainProvider + ).getJsonRpcProvider(web3providerRpcURL) + + context = { + address: address[0], + jsonRpcProvider + } + + // Check if the same account has already joined the group. + accountHash = id(address + groupId) + } else { + const clientId = + process.env[`${providerName.toUpperCase()}_CLIENT_ID`] + const clientSecret = + process.env[ + `${providerName.toUpperCase()}_CLIENT_SECRET` + ] + const redirectUri = + process.env[ + `${providerName.toUpperCase()}_REDIRECT_URI` + ] + + // Exchange the OAuth code for a valid access token. + // eslint-disable-next-line no-await-in-loop + const accessToken = await ( + provider as Web2Provider + ).getAccessToken( + clientId, + clientSecret, + oAuthCode[0], + oAuthState[0], + redirectUri + ) + + // eslint-disable-next-line no-await-in-loop + const profile = await (provider as Web2Provider).getProfile( + accessToken + ) + + context = { + profile, + accessTokens: { [providerName]: accessToken } + } + + // Check if the same account has already joined the group. + accountHash = id(profile.id + groupId) + } + + if ( + group.oAuthAccounts.find( + // eslint-disable-next-line @typescript-eslint/no-loop-func + (account) => account.accountHash === accountHash + ) + ) { + throw new BadRequestException( + "OAuth account has already joined the group" + ) + } + + contexts.push(context) + } + + // Check credentials. + if ( + !(await validateManyCredentials( + credentials.credentials, + contexts, + credentials.expression + )) + ) { + throw new UnauthorizedException( + "OAuth account does not match criteria" + ) + } + + // Save OAuth account to prevent the same account to join groups with + // different member ids. + const oAuthAccount = new OAuthAccount() + + oAuthAccount.group = group + oAuthAccount.accountHash = accountHash + + await this.oAuthAccountRepository.save(oAuthAccount) + await this.groupsService.addMember(groupId, memberId) + + for (let i = 0; i < oAuthState.length; i += 1) { + this.oAuthState.delete(oAuthState[i]) + } + + return clientRedirectUri + } + const provider = getProvider(providerName) let accountHash: string @@ -125,7 +279,7 @@ export class CredentialsService { ).getJsonRpcProvider(web3providerRpcURL) context = { - address, + address: address[0], jsonRpcProvider } @@ -143,8 +297,8 @@ export class CredentialsService { const accessToken = await (provider as Web2Provider).getAccessToken( clientId, clientSecret, - oAuthCode, - oAuthState, + oAuthCode[0], + oAuthState[0], redirectUri ) @@ -190,7 +344,7 @@ export class CredentialsService { await this.oAuthAccountRepository.save(oAuthAccount) await this.groupsService.addMember(groupId, memberId) - this.oAuthState.delete(oAuthState) + this.oAuthState.delete(oAuthState[0]) return clientRedirectUri } diff --git a/apps/api/src/app/credentials/dto/add-member.dto.ts b/apps/api/src/app/credentials/dto/add-member.dto.ts index f3a4cf8f..d517574f 100644 --- a/apps/api/src/app/credentials/dto/add-member.dto.ts +++ b/apps/api/src/app/credentials/dto/add-member.dto.ts @@ -1,14 +1,17 @@ -import { IsString, IsOptional } from "class-validator" +import { IsArray, ArrayNotEmpty, IsOptional } from "class-validator" export class AddMemberDto { - @IsString() - readonly oAuthState: string + @IsArray() + @ArrayNotEmpty() + readonly oAuthState: string[] @IsOptional() - @IsString() - readonly oAuthCode: string + @IsArray() + @ArrayNotEmpty() + readonly oAuthCode: string[] @IsOptional() - @IsString() - readonly address: string + @IsArray() + @ArrayNotEmpty() + readonly address: string[] } diff --git a/apps/dashboard/src/api/bandadaAPI.ts b/apps/dashboard/src/api/bandadaAPI.ts index dd069c64..d9f533a9 100644 --- a/apps/dashboard/src/api/bandadaAPI.ts +++ b/apps/dashboard/src/api/bandadaAPI.ts @@ -201,18 +201,21 @@ export async function setOAuthState( * @param oAuthCode The OAuth code. */ export async function addMemberByCredentials( - oAuthState: string, - oAuthCode?: string, - address?: string + oAuthState: string[], + oAuthCode?: string[], + address?: string[] ): Promise { try { return await request(`${API_URL}/credentials`, { method: "POST", - data: { + headers: { + "Content-Type": "application/json" + }, + data: JSON.stringify({ oAuthState, oAuthCode, address - } + }) }) } catch (error: any) { console.error(error) diff --git a/apps/dashboard/src/components/new-group-stepper/access-mode-step.tsx b/apps/dashboard/src/components/new-group-stepper/access-mode-step.tsx index 923c0731..55f39420 100644 --- a/apps/dashboard/src/components/new-group-stepper/access-mode-step.tsx +++ b/apps/dashboard/src/components/new-group-stepper/access-mode-step.tsx @@ -13,6 +13,7 @@ import { NumberInputField, NumberInputStepper, Select, + SimpleGrid, Tag, TagLabel, Text, @@ -23,9 +24,11 @@ import { BiPencil } from "react-icons/bi" import { GoGear } from "react-icons/go" import capitalize from "../../utils/capitalize" -const accessModes = ["manual", "credentials"] +const accessModes = ["manual", "credentials", "multiple_credentials"] -export type AccessMode = "manual" | "credentials" +export type AccessMode = "manual" | "credentials" | "multiple_credentials" + +const logicalOperators = ["and", "or", "not", "xor", "(", ")"] export type AccessModeStepProps = { group: any @@ -42,6 +45,9 @@ export default function AccessModeStep({ const [_validator, setValidator] = useState(0) const [_credentials, setCredentials] = useState() + const [_validators, setValidators] = useState([]) // selected validators for multiple credentials + const [_expression, setExpression] = useState([]) // Expression with logical operators for multiple credentials + useEffect(() => { setCredentials({ id: validators[_validator].id, @@ -52,13 +58,13 @@ export default function AccessModeStep({ useEffect(() => { if (_accessMode === "manual") { setCredentials(undefined) - } else { + } else if (_accessMode === "credentials") { setCredentials({ id: validators[_validator].id, criteria: {} }) } - }, [_accessMode, _validator]) + }, [_accessMode, _validator, _validators]) return ( <> @@ -106,7 +112,11 @@ export default function AccessModeStep({ } /> - {capitalize(accessMode)} + + {capitalize( + accessMode.replaceAll("_", " ") + )} + {_accessMode === accessMode && ( @@ -293,6 +303,297 @@ export default function AccessModeStep({ )} + {_accessMode === "multiple_credentials" && ( + <> + Choose credentials + + + + Credentials + + {validators.map((validator, i) => ( + + ))} + + + + Logical Operators + + {logicalOperators.map((lo) => ( + + ))} + + + + + + Expression + + {_expression.length > 0 + ? _expression + .map((elem) => { + const isLogicalOperator = + logicalOperators.includes(elem) + if (isLogicalOperator) { + return elem.toUpperCase() + } + return capitalize( + validators[ + Number(elem) + ].id.toLowerCase() + ) + }) + .join(" ") + : "No expression yet"} + + + + {/* Display credential citeria */} + + {_credentials && + _credentials.credentials && + _validators.map((validatorIndex, i) => + Object.entries( + validators[validatorIndex].criteriaABI + ).map((parameter: any, j) => ( + + {j === 0 && ( + + {`${i + 1}. ${capitalize( + validators[ + validatorIndex + ].id + .replaceAll("_", " ") + .toLowerCase() + )}`} + + )} + + {capitalize( + `${parameter[0]}${ + parameter[1].optional + ? "" + : "*" + }` + )} + + + {parameter[1].type === "number" && ( + { + const credentialTemp = { + ..._credentials + } + + credentialTemp.credentials[ + i + ].criteria = { + ..._credentials + .credentials[i] + .criteria, + [parameter[0]]: + Number(value) + } + + setCredentials( + credentialTemp + ) + }} + > + + + + + + + )} + {parameter[0] !== "network" && + parameter[1].type === "string" && ( + { + const credentialTemp = { + ..._credentials + } + + credentialTemp.credentials[ + i + ].criteria = { + ..._credentials + .credentials[i] + .criteria, + [parameter[0]]: + event.target + .value + } + + setCredentials( + credentialTemp + ) + }} + placeholder={ + parameter[0] === + "repository" + ? "/" + : undefined + } + /> + )} + {parameter[0] === "network" && + parameter[1].type === "string" && ( + + )} + {parameter[1].type === "boolean" && ( + { + const credentialTemp = { + ..._credentials + } + + credentialTemp.credentials[ + i + ].criteria = { + ..._credentials + .credentials[i] + .criteria, + [parameter[0]]: + event.target.checked + } + + setCredentials( + credentialTemp + ) + }} + /> + )} + + )) + )} + + + + Disclaimer: We will use a bit of your member’s data + to check if they meet the criteria and generate + their credentials to join the group. + + + + )} +