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(linkedin): wip add linkedin v2 #3001

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading