Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
GitHub APIとfreshで遊ぼう



虎の穴ラボ 藤原 佳顕

1
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
アジェンダ

● 概要
● GitHub APIについて
● 構成
● freshについて
● 実装と作ったもの
2
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
自己紹介

3
● 名前

○ 藤原佳顕

● 仕事

○ FantiaとかCreatiaとか社内アプリとか

● 好み

○ Clojure、Rust

● 趣味

○ 格闘ゲーム

■ Melty Blood、Guilty Gear

○ STG(ダライアスとか)も好き

○ 祝エルデンリング発売

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
概要

4
● 諸事情でGitHub APIを叩いてコメントを抜いていくる必要がありました

● DenoでGitHub API叩いてコメント抜いてくることにしました

● せっかくなのでWebアプリにしてみました

○ https://github-comment.deno.dev/
○ https://github.com/zonuko/deno-github-comment
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
GitHub APIについて

5
● 何種類かある

○ v3: https://docs.github.com/ja/rest


■ REST API

■ 認証方法に種類がある

● ユーザー個別のトークン利用

● OAuth

● 今回はどちらも試す

○ v4: https://docs.github.com/ja/graphql


■ GraphQL API

■ 直感的に使うのは面倒そうだったので一旦見送り


Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
構成

6
● SDKっぽい物があるが今回は使わない(octokit)


○ https://github.com/octokit/octokit.js/


○ 単にREST API叩くだけなので問題はなさそう 

● 使うもの

○ Deno 1.19

○ dotenv

○ fresh(web用)

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(freshについて)

7
● freshについて

○ Denoの中の人Luca Casonato氏が作っている 

○ preactベースのWebフレームワーク 

○ deno deployで動作する 

○ ビルドがない

○ deno.land、lint.deno.landのページなどがこちらに置き換えられている 

■ →サンプルが豊富 

○ Next.js、Nuxt.js同様にディレクトリ構造ベースのルーティング 

○ まだまだ発展途上感もあり 

○ ドキュメント化されていない情報が多々あるので間違っているかもしれません 

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(cli+ユーザートークン)




8
import { config } from "./deps.ts";
const token = config().GITHUB_TOKEN;
const user = config().GITHUB_USER;
function buildConfig(): RequestInit {
return {
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
};
}
async function getRepos(): Promise<any> {
const res = await fetch(
`https://api.github.com/users/${user}/repos`,
buildConfig(),
);
return await res.json();
}
async function getPulls(repo: string): Promise<any> {
const res = await fetch(
`https://api.github.com/repos/${user}/${repo}/pulls?state=all`,
buildConfig(),
);
return await res.json();
}
async function getComments(repo: string, id: number): Promise<any> {
const res = await fetch(
`https://api.github.com/repos/${user}/${repo}/pulls/${id}/comments`,
buildConfig(),
);
}
const repos = await getRepos() as any[];
repos.forEach(async (repo) => {
console.log(await getPulls(repo.name));
});
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(ユーザートークン+cliツール)

9
● 単にfetch使っているだけなのでここまで余興

● 実際には次からのWebアプリ化が本題

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

10
● 実装方針

○ トークンはOAuthトークンを使う

■ まずはGitHub Loginを作る

○ ストレージの類は利用しない(RDB、Redisなど)


■ したがってセッション系の情報は暗号化してcookieに保存


● Rails知っている人はRailsの基本動作を想像してもらえると


○ 作るページはリポジトリ一覧、リポジトリのコメント一覧(、プルリク一覧)


Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

11
● page/index.tsx→ログインフォームがあるだけ

/** @jsx h */
import { Layout } from "../components/Layout.tsx";
import { h, PageConfig, PageProps, useData } from "../deps.ts";
export default function Home(props: PageProps) {
const errorQuery = useData("errorQuery", () => {
const q = props.url.searchParams.get("error");
if (q === "missing_code") {
return "Not found GitHub OAuth code";
}
if (q === "invalid_state") {
return "Invalid OAuth state";
}
if (q === "failed_to_get_token") {
return "Failed to get the access token";
}
});
return (
<Layout title="Login - GitHub Comments">
<nav class="navbar navbar-light bg-light">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">Login</span>
</div>
</nav>
{errorQuery &&
(
<div class="alert alert-danger" role="alert">
{errorQuery}
</div>
)}
<div class="position-relative text-center">
<a
type="button"
class="btn btn-outline-dark"
href="/login/github/auth"
>
<i class="bi bi-github"></i>
{" Login with GitHub"}
</a>
</div>
</Layout>
);
}
export const config: PageConfig = { runtimeJS: true };
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

12
● useData

○ サーバー側でレンダリング前にデータを取得できる


○ エラーでリダイレクトされてきた場合はSSRの時点で表示したい


● PageConfig

○ いわゆるオプションの設定

○ runtimeJS: クライアント側でもJS実行するかどうか


○ 他routeOverrideやcspなどオプションがある


Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

13
page/login/github/auth.ts

import { buildGithubUrl, tokenEncrypt } from "../../../logics/github.ts";
import { HandlerContext } from "../../../server_deps.ts";
export async function handler(_ctx: HandlerContext): Promise<Response> {
const oauthUrl = buildGithubUrl();
const githubRedirect = new Response(null, {
status: 302,
headers: {
location: oauthUrl,
"set-cookie": `state=${await tokenEncrypt(
new URL(oauthUrl).searchParams.get("state") || ""
)};HttpOnly;SameSite=Lax;Path=/;`,
},
});
return githubRedirect;
}
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

14
● handler関数

○ handler関数をexportすることでAPIを生やすことが出来ます


○ 今回は単純で、後の検証のためにstateを暗号化してcookieに書き込み


○ その後GitHubのログイン画面にリダイレクト


○ サーバーサイドの処理なのでcookieやリクエストヘッダーなどある程度は触れる


○ 拡張子をtsx(jsx)にすることで通常のレンダリングも同時に行うことができる


■ 試した限りでは現状handlerとjsx両方がある場合はhandler→ssrといった順番で実行さ
れる模様

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

15
page/login/github/callback.ts

export async function handler({req}: HandlerContext): Promise<Response> {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) {
return Response.redirect(`${url.origin}/?error=missing_code`);
}
const cookieState = await tokenDecrypt(cookie.getCookies(req.headers)["state"]);
if (cookieState !== state) {
return Response.redirect(`${url.origin}/?error=invalid_state`);
}
const apiRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: githubClientId(),
client_secret: githubClientSecret(),
code: code,
}),
});
if (apiRes.status !== 200) {
return Response.redirect(`${url.origin}/?error=failed_to_get_token`);
}
const resJson = await apiRes.json();
if (resJson["error"]) {
return Response.redirect(`${url.origin}/?error=failed_to_get_token`);
}
const successRes = new Response(null, {
status: 302,
headers: {
location: `${url.origin}/mypage/github`,
"set-cookie": `oauth_token=${await tokenEncrypt(
resJson["access_token"],
)};HttpOnly;SameSite=Lax;Path=/;`,
},
});
deleteCookie(successRes.headers, "state", {path: "/"});
return successRes;
}
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

16
● GitHubでのログイン後に帰ってくるパス


○ state及びcodeがつけられて帰ってくるので各々チェック 

■ state: cookieの値を復号化して一致するか(CSRF対策)


■ code: アクセストークンを取得するのに必要


● アクセストークン取得

○ 本来であればRDBやサーバー側セッションに持つべきだが今回はcookieに


○ 秘匿情報なのでサーバー側でしか復号化出来ないように暗号化して入れる


■ 今回はAES-GCMを採用

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

17
● 暗号化について

● 詳細はMDNで https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto


● まずはsecret.jsonを生成(アプリ起動前)


○ jwkという形式でjsonを作れるのでそのままファイルに保存


○ Git等に入れないように注意!


■ いわゆる秘密鍵なのでサーバー個別に持つべき


async function genKey() {
return await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
);
}
async function exportKey() {
const key = await crypto.subtle.exportKey(
"jwk",
await genKey(),
);
await Deno.writeTextFile("./secret.json", JSON.stringify(key));
console.log("generate secret.json. add to .gitignore");
}
await exportKey();
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

18
● 暗号化処理

async function loadSecret() {
const text = JSON.parse(await Deno.readTextFile("secret.json"));
return await crypto.subtle.importKey(
"jwk",
text,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
}
export async function tokenEncrypt(val: string): Promise<string> {
const ivBytes = iv();
const c = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: ivBytes },
await loadSecret(),
new TextEncoder().encode(val),
);
● iv

○ 暗号化のために必要なランダムな値


○ 秘密である必要はないが、一意である必要がある


○ GCMでは12byteが推奨なので注意


● loadSecret

○ 先程のsecret.jsonを読み込む


○ 本来ならば起動時に一度のみ読み込みたい


■ もしくはキャッシュ

● tokenEncrypt

○ 上記を合わせて引数の文字列を暗号化する


○ 後にcookieに書き込みたいのでちゃんと文字として値を返
す

return `${btoa(String.fromCharCode(...new Uint8Array(ivBytes)))}--${
btoa(String.fromCharCode(...new Uint8Array(c)))
}`;
}
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

19
● 復号処理

export async function tokenDecrypt(val: string): Promise<string> {
const [ivVal, token] = val.split("--");
const encryptedBytes = atob(token);
const ivBytes = atob(ivVal);
const encryptedData = Uint8Array.from(
encryptedBytes.split(""),
(char) => char.charCodeAt(0),
);
const ivData = Uint8Array.from(
ivBytes.split(""),
(char) => char.charCodeAt(0),
);
● 基本的には暗号化の逆を行う

○ iv及びsecretを使って復号化する

● 最終的にはトークンがほしいのでこちらも文字列に

● 基本的にはサーバー側でしか実行されない

○ Denoに生えているAPIも使える(はず)

const decryptedArrayBuffer = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivData },
await loadSecret(),
encryptedData,
);
return new TextDecoder().decode(new
Uint8Array(decryptedArrayBuffer));
}
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

20
トークンを使って情報取得(pages/api/github/repos.ts)


export async function handler(ctx: HandlerContext): Promise<Response> {
const cookieValue = getCookies(ctx.req.headers)["oauth_token"];
const url = new URL(ctx.req.url);
const page = url.searchParams.get("page") || "1";
const perPage = url.searchParams.get("per_page") || "30";
const res = await fetch(
`https://api.github.com/user/repos?page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `token ${await tokenDecrypt(cookieValue)}`,
},
},
);
const resJson = await res.json();
const link = res.headers.get("link") || "";
const pagenation = buildPagenation(link);
return new Response(JSON.stringify({
link: pagenation,
repos: resJson,
}));
}
● tokenの復号化などの処理がはいるので直接GitHub API
を叩くのではなく、サーバーを経由する

● 先程と同様にhandlerのみでルーティングする

● apiディレクトリに置くことでよりAPIらしく使える

○ fresh initしたときにも生成されるのでそちらも参
照

Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
実装と作ったもの(Webアプリ化)

21
API使う側(mypage/github.tsx)


普通のReactアプリなので特筆することはなし


→cookie等の都合でssr時のAPIコールがうまく行かない


const PER_PAGE = 30;
export default function Github(props: PageProps) {
const [repos, setRepos] = useState({
repos: [],
});
const initUrl = useData("repoInitUrl", () => props.url);
const pageParams = useData(
"repoInitPageNo",
() => {
const p = parseInt(props.url.searchParams.get("page") || "1");
if (p <= 0) return 1;
return p;
},
);
const [url, setUrl] = useState(initUrl);
const [page, setPage] = useState(pageParams);
const [link, setLink] = useState<{ link: Pagenation }>({
link: { hasNext: false, hasPrev: false },
});
useEffect(() => {
const call = async () => {
const res = await fetch(
`/api/github/repos?page=${page}&per_page=${PER_PAGE}`,
);
const json = await res.json();
if (json.repos) {
setRepos({ repos: json.repos });
}
if (json.link) {
setLink({ link: json.link });
}
};
call();
const newUrl = new URL(url);
newUrl.searchParams.set("page", page.toString());
window.history.pushState(null, "", newUrl);
setUrl(newUrl);
}, [page]);
const onPrevPage = () => {
if (link.link.hasPrev && link.link.prev) {
setPage(link.link.prev);
}
};
const onNextPage = () => {
if (link.link.hasNext && link.link.next) {
setPage(link.link.next);
}
};
Copyright  (C) 2021 Toranoana Inc. All Rights Reserved.
まとめ

● denoでGitHubにログイン〜APIコールを行ってみました


● 付随して暗号化なども実施しました


● freshを使ってWebアプリにしました


● freshは全く枯れてないのでご利用にはご注意を


● フィルタなどの実装は今後

○ handlerとjsx組み合わせればできる(気がする)


22

GitHub APIとfreshで遊ぼう

  • 1.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. GitHub APIとfreshで遊ぼう
 
 虎の穴ラボ 藤原 佳顕
 1
  • 2.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. アジェンダ
 ● 概要 ● GitHub APIについて ● 構成 ● freshについて ● 実装と作ったもの 2
  • 3.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 自己紹介
 3 ● 名前
 ○ 藤原佳顕
 ● 仕事
 ○ FantiaとかCreatiaとか社内アプリとか
 ● 好み
 ○ Clojure、Rust
 ● 趣味
 ○ 格闘ゲーム
 ■ Melty Blood、Guilty Gear
 ○ STG(ダライアスとか)も好き
 ○ 祝エルデンリング発売

  • 4.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 概要
 4 ● 諸事情でGitHub APIを叩いてコメントを抜いていくる必要がありました
 ● DenoでGitHub API叩いてコメント抜いてくることにしました
 ● せっかくなのでWebアプリにしてみました
 ○ https://github-comment.deno.dev/ ○ https://github.com/zonuko/deno-github-comment
  • 5.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. GitHub APIについて
 5 ● 何種類かある
 ○ v3: https://docs.github.com/ja/rest 
 ■ REST API
 ■ 認証方法に種類がある
 ● ユーザー個別のトークン利用
 ● OAuth
 ● 今回はどちらも試す
 ○ v4: https://docs.github.com/ja/graphql 
 ■ GraphQL API
 ■ 直感的に使うのは面倒そうだったので一旦見送り 

  • 6.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 構成
 6 ● SDKっぽい物があるが今回は使わない(octokit) 
 ○ https://github.com/octokit/octokit.js/ 
 ○ 単にREST API叩くだけなので問題はなさそう 
 ● 使うもの
 ○ Deno 1.19
 ○ dotenv
 ○ fresh(web用)

  • 7.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(freshについて)
 7 ● freshについて
 ○ Denoの中の人Luca Casonato氏が作っている 
 ○ preactベースのWebフレームワーク 
 ○ deno deployで動作する 
 ○ ビルドがない
 ○ deno.land、lint.deno.landのページなどがこちらに置き換えられている 
 ■ →サンプルが豊富 
 ○ Next.js、Nuxt.js同様にディレクトリ構造ベースのルーティング 
 ○ まだまだ発展途上感もあり 
 ○ ドキュメント化されていない情報が多々あるので間違っているかもしれません 

  • 8.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(cli+ユーザートークン) 
 
 8 import { config } from "./deps.ts"; const token = config().GITHUB_TOKEN; const user = config().GITHUB_USER; function buildConfig(): RequestInit { return { headers: { Authorization: `token ${token}`, Accept: "application/vnd.github.v3+json", }, }; } async function getRepos(): Promise<any> { const res = await fetch( `https://api.github.com/users/${user}/repos`, buildConfig(), ); return await res.json(); } async function getPulls(repo: string): Promise<any> { const res = await fetch( `https://api.github.com/repos/${user}/${repo}/pulls?state=all`, buildConfig(), ); return await res.json(); } async function getComments(repo: string, id: number): Promise<any> { const res = await fetch( `https://api.github.com/repos/${user}/${repo}/pulls/${id}/comments`, buildConfig(), ); } const repos = await getRepos() as any[]; repos.forEach(async (repo) => { console.log(await getPulls(repo.name)); });
  • 9.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(ユーザートークン+cliツール)
 9 ● 単にfetch使っているだけなのでここまで余興
 ● 実際には次からのWebアプリ化が本題

  • 10.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 10 ● 実装方針
 ○ トークンはOAuthトークンを使う
 ■ まずはGitHub Loginを作る
 ○ ストレージの類は利用しない(RDB、Redisなど) 
 ■ したがってセッション系の情報は暗号化してcookieに保存 
 ● Rails知っている人はRailsの基本動作を想像してもらえると 
 ○ 作るページはリポジトリ一覧、リポジトリのコメント一覧(、プルリク一覧) 

  • 11.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 11 ● page/index.tsx→ログインフォームがあるだけ
 /** @jsx h */ import { Layout } from "../components/Layout.tsx"; import { h, PageConfig, PageProps, useData } from "../deps.ts"; export default function Home(props: PageProps) { const errorQuery = useData("errorQuery", () => { const q = props.url.searchParams.get("error"); if (q === "missing_code") { return "Not found GitHub OAuth code"; } if (q === "invalid_state") { return "Invalid OAuth state"; } if (q === "failed_to_get_token") { return "Failed to get the access token"; } }); return ( <Layout title="Login - GitHub Comments"> <nav class="navbar navbar-light bg-light"> <div class="container-fluid"> <span class="navbar-brand mb-0 h1">Login</span> </div> </nav> {errorQuery && ( <div class="alert alert-danger" role="alert"> {errorQuery} </div> )} <div class="position-relative text-center"> <a type="button" class="btn btn-outline-dark" href="/login/github/auth" > <i class="bi bi-github"></i> {" Login with GitHub"} </a> </div> </Layout> ); } export const config: PageConfig = { runtimeJS: true };
  • 12.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 12 ● useData
 ○ サーバー側でレンダリング前にデータを取得できる 
 ○ エラーでリダイレクトされてきた場合はSSRの時点で表示したい 
 ● PageConfig
 ○ いわゆるオプションの設定
 ○ runtimeJS: クライアント側でもJS実行するかどうか 
 ○ 他routeOverrideやcspなどオプションがある 

  • 13.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 13 page/login/github/auth.ts
 import { buildGithubUrl, tokenEncrypt } from "../../../logics/github.ts"; import { HandlerContext } from "../../../server_deps.ts"; export async function handler(_ctx: HandlerContext): Promise<Response> { const oauthUrl = buildGithubUrl(); const githubRedirect = new Response(null, { status: 302, headers: { location: oauthUrl, "set-cookie": `state=${await tokenEncrypt( new URL(oauthUrl).searchParams.get("state") || "" )};HttpOnly;SameSite=Lax;Path=/;`, }, }); return githubRedirect; }
  • 14.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 14 ● handler関数
 ○ handler関数をexportすることでAPIを生やすことが出来ます 
 ○ 今回は単純で、後の検証のためにstateを暗号化してcookieに書き込み 
 ○ その後GitHubのログイン画面にリダイレクト 
 ○ サーバーサイドの処理なのでcookieやリクエストヘッダーなどある程度は触れる 
 ○ 拡張子をtsx(jsx)にすることで通常のレンダリングも同時に行うことができる 
 ■ 試した限りでは現状handlerとjsx両方がある場合はhandler→ssrといった順番で実行さ れる模様

  • 15.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 15 page/login/github/callback.ts
 export async function handler({req}: HandlerContext): Promise<Response> { const url = new URL(req.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code) { return Response.redirect(`${url.origin}/?error=missing_code`); } const cookieState = await tokenDecrypt(cookie.getCookies(req.headers)["state"]); if (cookieState !== state) { return Response.redirect(`${url.origin}/?error=invalid_state`); } const apiRes = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: githubClientId(), client_secret: githubClientSecret(), code: code, }), }); if (apiRes.status !== 200) { return Response.redirect(`${url.origin}/?error=failed_to_get_token`); } const resJson = await apiRes.json(); if (resJson["error"]) { return Response.redirect(`${url.origin}/?error=failed_to_get_token`); } const successRes = new Response(null, { status: 302, headers: { location: `${url.origin}/mypage/github`, "set-cookie": `oauth_token=${await tokenEncrypt( resJson["access_token"], )};HttpOnly;SameSite=Lax;Path=/;`, }, }); deleteCookie(successRes.headers, "state", {path: "/"}); return successRes; }
  • 16.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 16 ● GitHubでのログイン後に帰ってくるパス 
 ○ state及びcodeがつけられて帰ってくるので各々チェック 
 ■ state: cookieの値を復号化して一致するか(CSRF対策) 
 ■ code: アクセストークンを取得するのに必要 
 ● アクセストークン取得
 ○ 本来であればRDBやサーバー側セッションに持つべきだが今回はcookieに 
 ○ 秘匿情報なのでサーバー側でしか復号化出来ないように暗号化して入れる 
 ■ 今回はAES-GCMを採用

  • 17.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 17 ● 暗号化について
 ● 詳細はMDNで https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto 
 ● まずはsecret.jsonを生成(アプリ起動前) 
 ○ jwkという形式でjsonを作れるのでそのままファイルに保存 
 ○ Git等に入れないように注意! 
 ■ いわゆる秘密鍵なのでサーバー個別に持つべき 
 async function genKey() { return await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); } async function exportKey() { const key = await crypto.subtle.exportKey( "jwk", await genKey(), ); await Deno.writeTextFile("./secret.json", JSON.stringify(key)); console.log("generate secret.json. add to .gitignore"); } await exportKey();
  • 18.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 18 ● 暗号化処理
 async function loadSecret() { const text = JSON.parse(await Deno.readTextFile("secret.json")); return await crypto.subtle.importKey( "jwk", text, { name: "AES-GCM" }, false, ["encrypt", "decrypt"], ); } export async function tokenEncrypt(val: string): Promise<string> { const ivBytes = iv(); const c = await crypto.subtle.encrypt( { name: "AES-GCM", iv: ivBytes }, await loadSecret(), new TextEncoder().encode(val), ); ● iv
 ○ 暗号化のために必要なランダムな値 
 ○ 秘密である必要はないが、一意である必要がある 
 ○ GCMでは12byteが推奨なので注意 
 ● loadSecret
 ○ 先程のsecret.jsonを読み込む 
 ○ 本来ならば起動時に一度のみ読み込みたい 
 ■ もしくはキャッシュ
 ● tokenEncrypt
 ○ 上記を合わせて引数の文字列を暗号化する 
 ○ 後にcookieに書き込みたいのでちゃんと文字として値を返 す
 return `${btoa(String.fromCharCode(...new Uint8Array(ivBytes)))}--${ btoa(String.fromCharCode(...new Uint8Array(c))) }`; }
  • 19.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 19 ● 復号処理
 export async function tokenDecrypt(val: string): Promise<string> { const [ivVal, token] = val.split("--"); const encryptedBytes = atob(token); const ivBytes = atob(ivVal); const encryptedData = Uint8Array.from( encryptedBytes.split(""), (char) => char.charCodeAt(0), ); const ivData = Uint8Array.from( ivBytes.split(""), (char) => char.charCodeAt(0), ); ● 基本的には暗号化の逆を行う
 ○ iv及びsecretを使って復号化する
 ● 最終的にはトークンがほしいのでこちらも文字列に
 ● 基本的にはサーバー側でしか実行されない
 ○ Denoに生えているAPIも使える(はず)
 const decryptedArrayBuffer = await crypto.subtle.decrypt( { name: "AES-GCM", iv: ivData }, await loadSecret(), encryptedData, ); return new TextDecoder().decode(new Uint8Array(decryptedArrayBuffer)); }
  • 20.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 20 トークンを使って情報取得(pages/api/github/repos.ts) 
 export async function handler(ctx: HandlerContext): Promise<Response> { const cookieValue = getCookies(ctx.req.headers)["oauth_token"]; const url = new URL(ctx.req.url); const page = url.searchParams.get("page") || "1"; const perPage = url.searchParams.get("per_page") || "30"; const res = await fetch( `https://api.github.com/user/repos?page=${page}&per_page=${perPage}`, { headers: { Authorization: `token ${await tokenDecrypt(cookieValue)}`, }, }, ); const resJson = await res.json(); const link = res.headers.get("link") || ""; const pagenation = buildPagenation(link); return new Response(JSON.stringify({ link: pagenation, repos: resJson, })); } ● tokenの復号化などの処理がはいるので直接GitHub API を叩くのではなく、サーバーを経由する
 ● 先程と同様にhandlerのみでルーティングする
 ● apiディレクトリに置くことでよりAPIらしく使える
 ○ fresh initしたときにも生成されるのでそちらも参 照

  • 21.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. 実装と作ったもの(Webアプリ化)
 21 API使う側(mypage/github.tsx) 
 普通のReactアプリなので特筆することはなし 
 →cookie等の都合でssr時のAPIコールがうまく行かない 
 const PER_PAGE = 30; export default function Github(props: PageProps) { const [repos, setRepos] = useState({ repos: [], }); const initUrl = useData("repoInitUrl", () => props.url); const pageParams = useData( "repoInitPageNo", () => { const p = parseInt(props.url.searchParams.get("page") || "1"); if (p <= 0) return 1; return p; }, ); const [url, setUrl] = useState(initUrl); const [page, setPage] = useState(pageParams); const [link, setLink] = useState<{ link: Pagenation }>({ link: { hasNext: false, hasPrev: false }, }); useEffect(() => { const call = async () => { const res = await fetch( `/api/github/repos?page=${page}&per_page=${PER_PAGE}`, ); const json = await res.json(); if (json.repos) { setRepos({ repos: json.repos }); } if (json.link) { setLink({ link: json.link }); } }; call(); const newUrl = new URL(url); newUrl.searchParams.set("page", page.toString()); window.history.pushState(null, "", newUrl); setUrl(newUrl); }, [page]); const onPrevPage = () => { if (link.link.hasPrev && link.link.prev) { setPage(link.link.prev); } }; const onNextPage = () => { if (link.link.hasNext && link.link.next) { setPage(link.link.next); } };
  • 22.
    Copyright  (C) 2021Toranoana Inc. All Rights Reserved. まとめ
 ● denoでGitHubにログイン〜APIコールを行ってみました 
 ● 付随して暗号化なども実施しました 
 ● freshを使ってWebアプリにしました 
 ● freshは全く枯れてないのでご利用にはご注意を 
 ● フィルタなどの実装は今後
 ○ handlerとjsx組み合わせればできる(気がする) 
 22