diff --git a/app/config/platformMap.ts b/app/config/platformMap.ts index 33e685d544..988472f96e 100644 --- a/app/config/platformMap.ts +++ b/app/config/platformMap.ts @@ -15,6 +15,7 @@ const { ZkSync, Discord, Linkedin, + LinkedinV2, GtcStaking, Google, Brightid, @@ -109,6 +110,14 @@ defaultPlatformMap.set("Linkedin", { platFormGroupSpec: Linkedin.ProviderConfig, }); +defaultPlatformMap.set("LinkedinV2", { + platform: new LinkedinV2.LinkedinV2Platform({ + clientId: process.env.NEXT_PUBLIC_PASSPORT_LINKEDIN_CLIENT_ID_V2, + redirectUri: process.env.NEXT_PUBLIC_PASSPORT_LINKEDIN_CALLBACK, + }), + platFormGroupSpec: LinkedinV2.ProviderConfig, +}); + defaultPlatformMap.set("GtcStaking", { platform: new GtcStaking.GTCStakingPlatform(), platFormGroupSpec: GtcStaking.ProviderConfig, diff --git a/app/hooks/usePlatforms.ts b/app/hooks/usePlatforms.ts index affc4a8d0c..500c5be45a 100644 --- a/app/hooks/usePlatforms.ts +++ b/app/hooks/usePlatforms.ts @@ -46,7 +46,7 @@ const BASE_PLATFORM_CATAGORIES: PLATFORM_CATEGORY[] = [ { name: "Social & Professional Platforms", description: "Link your profiles from established social media and professional networking sites for verification.", - platforms: ["Github", "Linkedin", "Google", "Discord"], + platforms: ["Github", "Linkedin", "LinkedinV2", "Google", "Discord"], }, { name: "Biometric Verification", diff --git a/platforms/src/LinkedinV2/App-Bindings.ts b/platforms/src/LinkedinV2/App-Bindings.ts new file mode 100644 index 0000000000..ba1036a250 --- /dev/null +++ b/platforms/src/LinkedinV2/App-Bindings.ts @@ -0,0 +1,30 @@ +import { PlatformOptions } from "../types"; +import { Platform } from "../utils/platform"; + +export class LinkedinV2Platform extends Platform { + platformId = "LinkedinV2"; + path = "linkedin"; + + constructor(options: PlatformOptions = {}) { + console.log("Hello DEBUG LinkedinPlatform"); + super(); + console.log("Hello DEBUG LinkedinPlatform options.clientId", options.clientId); + this.clientId = options.clientId as string; + this.redirectUri = options.redirectUri as string; + this.state = options.state as string; + this.banner = { + cta: { + label: "Learn more", + url: "https://support.passport.xyz/passport-knowledge-base/stamps/how-do-i-add-passport-stamps/guide-to-add-a-linkedin-stamp-to-passport", + }, + } + } + + async getOAuthUrl(state: string): Promise { + console.log("Hello DEBUG getOAuthUrl clientId", this.clientId); + const linkedinUrl = await Promise.resolve( + `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${this.clientId}&redirect_uri=${this.redirectUri}&state=${state}&scope=profile%email` + ); + return linkedinUrl; + } +} diff --git a/platforms/src/LinkedinV2/Providers-config.ts b/platforms/src/LinkedinV2/Providers-config.ts new file mode 100644 index 0000000000..8a577cc088 --- /dev/null +++ b/platforms/src/LinkedinV2/Providers-config.ts @@ -0,0 +1,19 @@ +import { PlatformSpec, PlatformGroupSpec, Provider } from "../types"; +import { LinkedinV2Provider } from "./Providers/linkedin"; + +export const PlatformDetails: PlatformSpec = { + icon: "./assets/linkedinStampIcon.svg", + platform: "LinkedinV2", + name: "Linkedin V2", + description: "Connect your existing Linkedin account to verify.", + connectMessage: "Connect Account to V2", +}; + +export const ProviderConfig: PlatformGroupSpec[] = [ + { + platformGroup: "Account Name V2", + providers: [{ title: "Encrypted V2", name: "LinkedinV2" }], + }, +]; + +export const providers: Provider[] = [new LinkedinV2Provider()]; diff --git a/platforms/src/LinkedinV2/Providers/linkedin.ts b/platforms/src/LinkedinV2/Providers/linkedin.ts new file mode 100644 index 0000000000..8cb0bd44c1 --- /dev/null +++ b/platforms/src/LinkedinV2/Providers/linkedin.ts @@ -0,0 +1,116 @@ +// ----- Types +import type { RequestPayload, VerifiedPayload } from "@gitcoin/passport-types"; +import { ProviderExternalVerificationError, type Provider, type ProviderOptions } from "../../types"; + +// ----- Libs +import axios from "axios"; + +// ----- Utils +import { handleProviderAxiosError } from "../../utils/handleProviderAxiosError"; + +export type LinkedinTokenResponse = { + access_token: string; +}; + +export type LinkedinFindMyUserResponse = { + id?: string; + firstName?: string; + lastName?: string; + error?: string; +}; + +// Export a Linkedin Provider to carry out OAuth and return a record object +export class LinkedinV2Provider implements Provider { + // Give the provider a type so that we can select it with a payload + type = "LinkedinV2"; + + // Options can be set here and/or via the constructor + _options = {}; + + // construct the provider instance with supplied options + constructor(options: ProviderOptions = {}) { + console.log("Hello DEBUG LinkedinProvider"); + this._options = { ...this._options, ...options }; + } + + // verify that the proof object contains valid === "true" + async verify(payload: RequestPayload): Promise { + console.log("Hello DEBUG verify"); + const errors = []; + let valid = false, + verifiedPayload: LinkedinFindMyUserResponse = {}, + record = undefined; + + try { + if (payload.proofs) { + verifiedPayload = await verifyLinkedin(payload.proofs.code); + valid = verifiedPayload && verifiedPayload.id ? true : false; + + if (valid) { + record = { + id: verifiedPayload.id, + }; + } else { + errors.push(`We were unable to verify your LinkedIn account -- LinkedIn Account Valid: ${String(valid)}.`); + } + } else { + errors.push(verifiedPayload.error); + } + return { + valid, + record, + errors, + }; + } catch (e: unknown) { + throw new ProviderExternalVerificationError(`LinkedIn Account verification error: ${JSON.stringify(e)}.`); + } + } +} + +const requestAccessToken = async (code: string): Promise => { + try { + console.log("Hello DEBUG requestAccessToken"); + const clientId = process.env.LINKEDIN_CLIENT_ID_V2; + console.log("Hello DEBUG clientId", clientId); + const clientSecret = process.env.LINKEDIN_CLIENT_SECRET_V2; + + const tokenRequest = await axios.post( + `https://www.linkedin.com/oauth/v2/accessToken?grant_type=authorization_code&code=${code}&client_id=${clientId}&client_secret=${clientSecret}&redirect_uri=${process.env.LINKEDIN_CALLBACK}`, + {}, + { + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + } + ); + + if (tokenRequest.status != 200) { + throw `Post for request returned status code ${tokenRequest.status} instead of the expected 200`; + } + + const tokenResponse = tokenRequest.data as LinkedinTokenResponse; + + return tokenResponse.access_token; + } catch (e: unknown) { + handleProviderAxiosError(e, "LinkedIn access token request"); + return String(e); + } +}; + +const verifyLinkedin = async (code: string): Promise => { + try { + console.log("Hello DEBUG verifyLinkedin"); + // retrieve user's auth bearer token to authenticate client + const accessToken = await requestAccessToken(code); + // Now that we have an access token fetch the user details + const userRequest = await axios.get("https://api.linkedin.com/v2/userinfo", { + headers: { + Authorization: `Bearer ${accessToken}`, + "Linkedin-Version": 202305, + }, + }); + + return userRequest.data as LinkedinFindMyUserResponse; + } catch (e: unknown) { + handleProviderAxiosError(e, "LinkedIn verification", [code]); + return e; + } +}; diff --git a/platforms/src/LinkedinV2/__tests__/linkedin.test.ts b/platforms/src/LinkedinV2/__tests__/linkedin.test.ts new file mode 100644 index 0000000000..8e36ea6ccd --- /dev/null +++ b/platforms/src/LinkedinV2/__tests__/linkedin.test.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/unbound-method */ +// ---- Test subject +import { LinkedinProvider } from "../../Linkedin/Providers/linkedin"; + +import { RequestPayload } from "@gitcoin/passport-types"; + +// ----- Libs +import axios from "axios"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +const validLinkedinUserResponse = { + data: { + id: "18723656", + firstName: "First", + lastName: "Last", + }, + status: 200, +}; + +const validCodeResponse = { + data: { + access_token: "762165719dhiqudgasyuqwt6235", + }, + status: 200, +}; + +const code = "ABC123_ACCESSCODE"; + +beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.post.mockImplementation(async () => { + return validCodeResponse; + }); + + mockedAxios.get.mockImplementation(async () => { + return validLinkedinUserResponse; + }); +}); + +describe("Attempt verification", function() { + it("handles valid verification attempt", async () => { + const clientId = process.env.LINKEDIN_CLIENT_ID; + const clientSecret = process.env.LINKEDIN_CLIENT_SECRET; + const linkedin = new LinkedinProvider(); + const linkedinPayload = await linkedin.verify({ + proofs: { + code, + }, + } as unknown as RequestPayload); + + // Check the request to get the token + expect(mockedAxios.post).toHaveBeenCalledWith( + `https://www.linkedin.com/oauth/v2/accessToken?grant_type=authorization_code&code=${code}&client_id=${clientId}&client_secret=${clientSecret}&redirect_uri=${process.env.LINKEDIN_CALLBACK}`, + {}, + { + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + } + ); + + // Check the request to get the user + expect(mockedAxios.get).toHaveBeenCalledWith("https://api.linkedin.com/v2/me", { + headers: { Authorization: "Bearer 762165719dhiqudgasyuqwt6235", "Linkedin-Version": 202305 }, + }); + + expect(linkedinPayload).toEqual({ + valid: true, + errors: [], + record: { + id: validLinkedinUserResponse.data.id, + }, + }); + }); + + it("should return invalid payload when unable to retrieve auth token", async () => { + mockedAxios.post.mockRejectedValueOnce("bad request"); + + const linkedin = new LinkedinProvider(); + + await expect( + async () => + await linkedin.verify({ + proofs: { + code, + }, + } as unknown as RequestPayload) + ).rejects.toThrow("LinkedIn Account verification error: "); + }); + + it("should return invalid payload when there is no id in verifyLinkedin response", async () => { + mockedAxios.get.mockImplementation(async () => { + return { + data: { + id: undefined, + firstName: "First", + lastName: "Last", + }, + status: 200, + }; + }); + + const linkedin = new LinkedinProvider(); + + const linkedinPayload = await linkedin.verify({ + proofs: { + code, + }, + } as unknown as RequestPayload); + + expect(linkedinPayload).toMatchObject({ valid: false }); + }); + + it("should return invalid payload when a bad status code is returned by linkedin user api", async () => { + mockedAxios.get.mockRejectedValueOnce(async () => { + return { + status: 500, + }; + }); + + const linkedin = new LinkedinProvider(); + + await expect( + async () => + await linkedin.verify({ + proofs: { + code, + }, + } as unknown as RequestPayload) + ).rejects.toThrow("LinkedIn Account verification error: "); + }); +}); diff --git a/platforms/src/LinkedinV2/index.ts b/platforms/src/LinkedinV2/index.ts new file mode 100644 index 0000000000..1f85de3cdf --- /dev/null +++ b/platforms/src/LinkedinV2/index.ts @@ -0,0 +1,3 @@ +export { LinkedinV2Provider } from "./Providers/linkedin"; +export { PlatformDetails, ProviderConfig, providers } from "./Providers-config"; +export { LinkedinV2Platform } from "./App-Bindings"; diff --git a/platforms/src/platforms.ts b/platforms/src/platforms.ts index be0ea42ea9..5f6d57c57d 100644 --- a/platforms/src/platforms.ts +++ b/platforms/src/platforms.ts @@ -23,6 +23,7 @@ import * as Outdid from "./Outdid"; import * as AllowList from "./AllowList"; import * as Binance from "./Binance"; import * as CustomGithub from "./CustomGithub"; +import * as LinkedinV2 from "./LinkedinV2"; import { PlatformSpec, PlatformGroupSpec, Provider } from "./types"; type PlatformConfig = { @@ -40,6 +41,7 @@ const platforms: Record = { Google, Github, Linkedin, + LinkedinV2, Ens, Brightid, ETH, diff --git a/types/src/index.d.ts b/types/src/index.d.ts index 26b5fd6f19..861039bb44 100644 --- a/types/src/index.d.ts +++ b/types/src/index.d.ts @@ -340,6 +340,7 @@ export type PLATFORM_ID = | "Github" | "Gitcoin" | "Linkedin" + | "LinkedinV2" | "Discord" | "Signer" | "Snapshot" @@ -394,6 +395,8 @@ export type PROVIDER_ID = | "GitcoinContributorStatistics#numRoundsContributedToGte#1" | "GitcoinContributorStatistics#numGr14ContributionsGte#1" | "Linkedin" + | "LinkedinV2" + | "LinkedinV2EmailVerified" | "Discord" | "Snapshot" | "SnapshotProposalsProvider"