Skip to content

Commit

Permalink
feat(linkedin): wip add linkedin v2
Browse files Browse the repository at this point in the history
  • Loading branch information
larisa17 committed Oct 21, 2024
1 parent c995b66 commit d987222
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 1 deletion.
9 changes: 9 additions & 0 deletions app/config/platformMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
ZkSync,
Discord,
Linkedin,
LinkedinV2,
GtcStaking,
Google,
Brightid,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/hooks/usePlatforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions platforms/src/LinkedinV2/App-Bindings.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
}
19 changes: 19 additions & 0 deletions platforms/src/LinkedinV2/Providers-config.ts
Original file line number Diff line number Diff line change
@@ -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()];
116 changes: 116 additions & 0 deletions platforms/src/LinkedinV2/Providers/linkedin.ts
Original file line number Diff line number Diff line change
@@ -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<VerifiedPayload> {
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<string> => {
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<LinkedinFindMyUserResponse> => {
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;
}
};
134 changes: 134 additions & 0 deletions platforms/src/LinkedinV2/__tests__/linkedin.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof axios>;

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: ");
});
});
3 changes: 3 additions & 0 deletions platforms/src/LinkedinV2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { LinkedinV2Provider } from "./Providers/linkedin";
export { PlatformDetails, ProviderConfig, providers } from "./Providers-config";
export { LinkedinV2Platform } from "./App-Bindings";
2 changes: 2 additions & 0 deletions platforms/src/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -40,6 +41,7 @@ const platforms: Record<string, PlatformConfig> = {
Google,
Github,
Linkedin,
LinkedinV2,
Ens,
Brightid,
ETH,
Expand Down
3 changes: 3 additions & 0 deletions types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export type PLATFORM_ID =
| "Github"
| "Gitcoin"
| "Linkedin"
| "LinkedinV2"
| "Discord"
| "Signer"
| "Snapshot"
Expand Down Expand Up @@ -394,6 +395,8 @@ export type PROVIDER_ID =
| "GitcoinContributorStatistics#numRoundsContributedToGte#1"
| "GitcoinContributorStatistics#numGr14ContributionsGte#1"
| "Linkedin"
| "LinkedinV2"
| "LinkedinV2EmailVerified"
| "Discord"
| "Snapshot"
| "SnapshotProposalsProvider"
Expand Down

0 comments on commit d987222

Please sign in to comment.