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

Support Groups with multiple credentials and logical operators #530

Merged
merged 8 commits into from
Jul 2, 2024
80 changes: 66 additions & 14 deletions apps/api/src/app/credentials/credentials.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}))

Expand Down Expand Up @@ -155,7 +156,7 @@ describe("CredentialsService", () => {
providerName: "github"
})

await credentialsService.addMember(_stateId, "code")
await credentialsService.addMember([_stateId], ["code"])

const fun = credentialsService.setOAuthState({
groupId,
Expand All @@ -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`)
})
Expand All @@ -184,8 +185,8 @@ describe("CredentialsService", () => {
})

const clientRedirectUri = await credentialsService.addMember(
_stateId,
"code"
[_stateId],
["code"]
)

expect(clientRedirectUri).toBeUndefined()
Expand Down Expand Up @@ -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()
Expand All @@ -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`
Expand All @@ -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`
Expand Down Expand Up @@ -323,9 +324,9 @@ describe("CredentialsService", () => {
})

const clientRedirectUri = await credentialsService.addMember(
_stateId,
[_stateId],
undefined,
"0x"
["0x"]
)

expect(clientRedirectUri).toBeUndefined()
Expand All @@ -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()
Expand Down
176 changes: 166 additions & 10 deletions apps/api/src/app/credentials/credentials.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
validateCredentials,
validateManyCredentials,
getProvider,
BlockchainProvider,
Web2Provider,
Expand Down Expand Up @@ -82,23 +83,178 @@ export class CredentialsService {
* @returns Redirect URI
*/
async addMember(
oAuthState: string,
oAuthCode?: string,
address?: string
oAuthState: string[],
oAuthCode?: string[],
address?: string[]
): Promise<string> {
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
) {
// The group has multiple credentials

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()

// Find the OAuthState for this credential provider
const credentialOAuthState = oAuthState.find(
(s) =>
this.oAuthState.get(s).providerName ===
credentialProvider
)

if (credentialOAuthState === undefined) {
return
}

// Get the oAuthState data 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
Expand All @@ -125,7 +281,7 @@ export class CredentialsService {
).getJsonRpcProvider(web3providerRpcURL)

context = {
address,
address: address[0],
jsonRpcProvider
}

Expand All @@ -143,8 +299,8 @@ export class CredentialsService {
const accessToken = await (provider as Web2Provider).getAccessToken(
clientId,
clientSecret,
oAuthCode,
oAuthState,
oAuthCode[0],
oAuthState[0],
redirectUri
)

Expand Down Expand Up @@ -190,7 +346,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
}
Expand Down
17 changes: 10 additions & 7 deletions apps/api/src/app/credentials/dto/add-member.dto.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
Loading
Loading