Skip to content

Commit

Permalink
feat(github): implementing github stamp
Browse files Browse the repository at this point in the history
  • Loading branch information
nutrina committed Jun 14, 2022
1 parent 83d6626 commit a719ea6
Show file tree
Hide file tree
Showing 11 changed files with 296 additions and 5 deletions.
2 changes: 2 additions & 0 deletions app/components/CardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from "react";

// --- Identity Providers
import { GoogleCard, EnsCard, PohCard, TwitterCard, PoapCard, FacebookCard, BrightidCard } from "./ProviderCards";
import GithubCard from "./ProviderCards/GithubCard";

export const CardList = (): JSX.Element => {
return (
Expand All @@ -15,6 +16,7 @@ export const CardList = (): JSX.Element => {
<PoapCard />
<EnsCard />
<PohCard />
<GithubCard />
</div>
</div>
);
Expand Down
132 changes: 132 additions & 0 deletions app/components/ProviderCards/GithubCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// --- Methods
import React, { useContext, useEffect, useState } from "react";
import { debounce } from "ts-debounce";
import { BroadcastChannel } from "broadcast-channel";

// --- Identity tools
import { PROVIDER_ID } from "@dpopp/types";
import { fetchVerifiableCredential } from "@dpopp/identity";

// --- Components
import { Card } from "../Card";

// --- Context
import { UserContext } from "../../context/userContext";
import { ProviderSpec } from "../../config/providers";
import { datadogLogs } from "@datadog/browser-logs";

// Each provider is recognised by its ID
const providerId: PROVIDER_ID = "Github";

export default function GithubCard(): JSX.Element {
const { address, signer, handleAddStamp, allProvidersState } = useContext(UserContext);
const [isLoading, setLoading] = useState(false);

// Fetch Github OAuth2 url from the IAM procedure
async function handleFetchGithubOAuth(): Promise<void> {
// Fetch data from external API
const res = await fetch(
`${process.env.NEXT_PUBLIC_DPOPP_PROCEDURE_URL?.replace(/\/*?$/, "")}/github/generateAuthUrl`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
callback: process.env.NEXT_PUBLIC_DPOPP_GITHUB_CALLBACK,
}),
}
);
const data = await res.json();
// open new window for authUrl
const githubUrl = data.authUrl;

openGithubOAuthUrl(githubUrl);
}

// Open Twitter authUrl in centered window
function openGithubOAuthUrl(url: string): void {
const width = 600;
const height = 800;
const left = screen.width / 2 - width / 2;
const top = screen.height / 2 - height / 2;

// Pass data to the page via props
window.open(
url,
"_blank",
"toolbar=no, location=no, directories=no, status=no, menubar=no, resizable=no, copyhistory=no, width=" +
width +
", height=" +
height +
", top=" +
top +
", left=" +
left
);
}

// Listener to watch for oauth redirect response on other windows (on the same host)
function listenForRedirect(e: { target: string; data: { code: string; state: string } }) {
// when receiving github oauth response from a spawned child run fetchVerifiableCredential
if (e.target === "github") {
// pull data from message
const queryCode = e.data.code;
const queryState = e.data.state;

datadogLogs.logger.info("Saving Stamp", { provider: "Github" });
// fetch and store credential
setLoading(true);
fetchVerifiableCredential(
process.env.NEXT_PUBLIC_DPOPP_IAM_URL || "",
{
type: providerId,
version: "0.0.0",
address: address || "",
proofs: {
code: queryCode, // provided by github as query params in the redirect
sessionKey: queryState,
},
},
signer as { signMessage: (message: string) => Promise<string> }
)
.then(async (verified: { credential: any }): Promise<void> => {
await handleAddStamp({
provider: providerId,
credential: verified.credential,
});
datadogLogs.logger.info("Successfully saved Stamp", { provider: "Github" });
})
.finally(() => {
setLoading(false);
});
}
}

// attach and destroy a BroadcastChannel to handle the message
useEffect(() => {
// open the channel
const channel = new BroadcastChannel("github_oauth_channel");
// event handler will listen for messages from the child (debounced to avoid multiple submissions)
channel.onmessage = debounce(listenForRedirect, 300);

return () => {
channel.close();
};
});

const issueCredentialWidget = (
<button data-testid="button-verify-github" className="verify-btn" onClick={handleFetchGithubOAuth}>
Connect account
</button>
);

return (
<Card
providerSpec={allProvidersState[providerId]!.providerSpec as ProviderSpec}
verifiableCredential={allProvidersState[providerId]!.stamp?.credential}
issueCredentialWidget={issueCredentialWidget}
isLoading={isLoading}
/>
);
}
5 changes: 5 additions & 0 deletions app/config/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,9 @@ export const STAMP_PROVIDERS: Readonly<Providers> = {
name: "Bright ID",
description: "Bright ID name",
},
Github: {
icon: "./assets/githubStampIcon.svg",
name: "Github",
description: "Github name",
},
};
4 changes: 4 additions & 0 deletions app/context/userContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ const startingAllProvidersState: AllProvidersState = {
providerSpec: STAMP_PROVIDERS.Brightid,
stamp: undefined,
},
Github: {
providerSpec: STAMP_PROVIDERS.Github,
stamp: undefined,
},
};

export interface UserContextState {
Expand Down
14 changes: 14 additions & 0 deletions app/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ const App: NextPage = () => {

return <div></div>;
}
// if Twitter oauth then submit message to other windows and close self
else if ((queryError || queryCode) && queryState && /^github-.*/.test(queryState)) {
// shared message channel between windows (on the same domain)
const channel = new BroadcastChannel("github_oauth_channel");
// only continue with the process if a code is returned
if (queryCode) {
channel.postMessage({ target: "github", data: { code: queryCode, state: queryState } });
}
// always close the redirected window
window.close();

return <div></div>;
}

return (
<div>
<Router>
Expand Down
10 changes: 10 additions & 0 deletions app/public/assets/githubStampIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions iam/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { PohProvider } from "./providers/poh";
import { POAPProvider } from "./providers/poap";
import { FacebookProvider } from "./providers/facebook";
import { BrightIdProvider } from "./providers/brightid";
import { GithubProvider } from "./providers/github";

// Initiate providers - new Providers should be registered in this array...
const providers = new Providers([
Expand All @@ -54,6 +55,7 @@ const providers = new Providers([
new POAPProvider(),
new FacebookProvider(),
new BrightIdProvider(),
new GithubProvider(),
]);

// create the app and run on port
Expand Down
54 changes: 54 additions & 0 deletions iam/src/procedures/githubOauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import crypto from "crypto";
import axios from "axios";

export const getSessionKey = (): string => {
return `github-${crypto.randomBytes(32).toString("hex")}`;
};

export type GithubTokenResponse = {
access_token: string;
};

export type GithubFindMyUserResponse = {
id?: string;
login?: string;
type?: string;
};

const requestAccessToken = async (code: string): Promise<string> => {
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;

// Exchange the code for an access token
const tokenRequest = await axios.post(
`https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}`,
{},
{
headers: { Accept: "application/json" },
}
);

if (tokenRequest.status != 200) {
throw `Post for request returned status code ${tokenRequest.status} instead of the expected 200`;
}

const tokenResponse = tokenRequest.data as GithubTokenResponse;

return tokenResponse.access_token;
};

export const requestFindMyUser = async (code: string): Promise<GithubFindMyUserResponse> => {
// 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.github.com/user", {
headers: { Authorization: `token ${accessToken}` },
});

if (userRequest.status != 200) {
throw `Get user request returned status code ${userRequest.status} instead of the expected 200`;
}

return userRequest.data as GithubFindMyUserResponse;
};
30 changes: 26 additions & 4 deletions iam/src/procedures/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// ---- Server
import { Request, Response, Router } from "express";

import { generateAuthURL, getSessionKey, initClient } from "./twitterOauth";
import * as twitterOAuth from "./twitterOauth";
import * as githubOAuth from "./githubOauth";
import { triggerBrightidSponsorship, verifyBrightidContextId } from "./brightid";

export const router = Router();
Expand All @@ -10,19 +11,40 @@ export type GenerateTwitterAuthUrlRequestBody = {
callback: string;
};

export type GenerateGithubAuthUrlRequestBody = {
callback: string;
};

export type GenerateBrightidBody = {
contextIdData: string;
};

router.post("/twitter/generateAuthUrl", (req: Request, res: Response): void => {
const { callback } = req.body as GenerateTwitterAuthUrlRequestBody;
if (callback) {
const state = getSessionKey();
const client = initClient(callback, state);
const state = twitterOAuth.getSessionKey();
const client = twitterOAuth.initClient(callback, state);

const data = {
state,
authUrl: twitterOAuth.generateAuthURL(client, state),
};

res.status(200).send(data);
} else {
res.status(400);
}
});

router.post("/github/generateAuthUrl", (req: Request, res: Response): void => {
const { callback } = req.body as GenerateGithubAuthUrlRequestBody;
if (callback) {
const state = githubOAuth.getSessionKey();
const clientId = process.env.GITHUB_CLIENT_ID;

const data = {
state,
authUrl: generateAuthURL(client, state),
authUrl: `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${callback}&state=${state}`,
};

res.status(200).send(data);
Expand Down
46 changes: 46 additions & 0 deletions iam/src/providers/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// ----- Types
import type { RequestPayload, VerifiedPayload } from "@dpopp/types";

// ----- Github OAuth2
import { GithubFindMyUserResponse, requestFindMyUser } from "../procedures/githubOauth";
import type { Provider, ProviderOptions } from "../types";

// Export a Github Provider to carry out OAuth and return a record object
export class GithubProvider implements Provider {
// Give the provider a type so that we can select it with a payload
type = "Github";

// Options can be set here and/or via the constructor
_options = {};

// construct the provider instance with supplied options
constructor(options: ProviderOptions = {}) {
this._options = { ...this._options, ...options };
}

// verify that the proof object contains valid === "true"
async verify(payload: RequestPayload): Promise<VerifiedPayload> {
let valid = false,
verifiedPayload: GithubFindMyUserResponse = {};

try {
verifiedPayload = await verifyGithub(payload.proofs.code);
} catch (e) {
return { valid: false };
} finally {
valid = verifiedPayload && verifiedPayload.id ? true : false;
}

return {
valid: valid,
record: {
id: verifiedPayload.id,
},
};
}
}

// Perform verification on twitter access token
async function verifyGithub(code: string): Promise<GithubFindMyUserResponse> {
return await requestFindMyUser(code);
}
2 changes: 1 addition & 1 deletion types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,4 @@ export type Passport = {
// Passport DID
export type DID = string;

export type PROVIDER_ID = "Google" | "Ens" | "Poh" | "Twitter" | "POAP" | "Facebook" | "Brightid";
export type PROVIDER_ID = "Google" | "Ens" | "Poh" | "Twitter" | "POAP" | "Facebook" | "Brightid" | "Github";

0 comments on commit a719ea6

Please sign in to comment.