Skip to content

Commit

Permalink
fix: Simplify endpoint and Refactor (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikuroXina authored Jan 3, 2024
1 parent a2a680a commit f3449e8
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 140 deletions.
Binary file modified bun.lockb
Binary file not shown.
59 changes: 30 additions & 29 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
{
"name": "discord-oidc-worker",
"version": "0.1.0",
"private": true,
"description": "Discord OpenID Connect wrapper in Cloudflare Workers.",
"scripts": {
"start": "wrangler dev",
"deploy": "wrangler deploy --minify src/main.ts",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"check": "prettier --check \"src/**/*.{ts,tsx}\" && tsc --noEmit"
},
"license": "MIT",
"dependencies": {
"hono": "^3.11.12",
"jose": "^5.2.0"
},
"packageManager": "[email protected]",
"devDependencies": {
"@cloudflare/workers-types": "^4.20231218.0",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"prettier": "^3.1.0",
"typescript": "^5.3.2",
"wrangler": "^3.22.1"
}
"name": "discord-oidc-worker",
"version": "0.1.0",
"private": true,
"description": "Discord OpenID Connect wrapper in Cloudflare Workers.",
"scripts": {
"start": "wrangler dev",
"deploy": "wrangler deploy --minify src/main.ts",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"check": "prettier --check \"src/**/*.{ts,tsx}\" && tsc --noEmit"
},
"license": "MIT",
"dependencies": {
"@mikuroxina/mini-fn": "^5.5.1",
"hono": "^3.11.12",
"jose": "^5.2.0"
},
"packageManager": "[email protected]",
"devDependencies": {
"@cloudflare/workers-types": "^4.20231218.0",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"prettier": "^3.1.0",
"typescript": "^5.3.2",
"wrangler": "^3.22.1"
}
}
99 changes: 99 additions & 0 deletions src/adaptor/discord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Result } from "@mikuroxina/mini-fn";

import {
CLOUDFLARE_ACCESS_REDIRECT_URI,
DISCORD_API_ROOT,
DISCORD_CLIENT_ID,
} from "../consts";
import type { Token, TokenResult, User } from "../service/token";

export const generateToken =
(clientSecret: string) =>
async (code: string): Promise<TokenResult<Token>> => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: clientSecret,
redirect_uri: CLOUDFLARE_ACCESS_REDIRECT_URI,
code,
grant_type: "authorization_code",
scope: "identify email",
});

const tokenResponse = await fetch(`${DISCORD_API_ROOT}/oauth2/token`, {
method: "POST",
body: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!tokenResponse.ok) {
return Result.err("TOKEN_GEN_FAILURE");
}
const tokenResult = await tokenResponse.json<{
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}>();
return Result.ok(tokenResult);
};

export const me = async (accessToken: string): Promise<User> => {
const meResponse = await fetch(`${DISCORD_API_ROOT}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!meResponse.ok) {
meResponse.text().then(console.log);
throw new Error("failed to get user info");
}

const meResult = await meResponse.json<{
id: string;
username: string;
discriminator: string;
global_name?: string;
verified?: boolean;
email?: string;
}>();
if (!meResult.verified) {
throw new Error("email unverified");
}

const guildsResponse = await fetch(`${DISCORD_API_ROOT}/users/@me/guilds`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!guildsResponse.ok) {
guildsResponse.text().then(console.log);
throw new Error("failed to get guilds info");
}
const guilds = (await guildsResponse.json<{ id: string }[]>()).map(
({ id }) => id,
);

return {
...meResult,
joinedGuildIds: guilds,
};
};

export const rolesOf =
(botToken: string) =>
async (guildId: string, userId: string): Promise<string[]> => {
const memberResponse = await fetch(
`${DISCORD_API_ROOT}/guilds/${guildId}/members/${userId}`,
{
headers: {
Authorization: `Bot ${botToken}`,
},
},
);
const { roles } = await memberResponse.json<{
roles: string[];
}>();
return roles;
};
2 changes: 1 addition & 1 deletion src/adaptor/in-memory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { KeyEntry, KeyStore } from "../key";
import type { KeyEntry, KeyStore } from "../service/key";

export class InMemoryKeyStore implements KeyStore {
entry: KeyEntry | null = null;
Expand Down
2 changes: 1 addition & 1 deletion src/adaptor/kv.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { KeyEntry, KeyStore } from "../key";
import type { KeyEntry, KeyStore } from "../service/key";

const NAMESPACE_KEY = "keys";

Expand Down
5 changes: 5 additions & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const DISCORD_CLIENT_ID = "1191642657731121272";
export const DISCORD_API_ROOT = "https://discord.com/api/v10";

export const CLOUDFLARE_ACCESS_REDIRECT_URI =
"https://approvers.cloudflareaccess.com/cdn-cgi/access/callback";
132 changes: 24 additions & 108 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Result } from "@mikuroxina/mini-fn";
import { Hono } from "hono";
import { SignJWT } from "jose";

import { generateToken, me, rolesOf } from "./adaptor/discord";
import { KVKeyStore } from "./adaptor/kv";
import { loadOrGenerateKeyPair } from "./key";
import { loadOrGenerateKeyPair } from "./service/key";
import { token } from "./service/token";

const DISCORD_API_ROOT = "https://discord.com/api/v10";
const DISCORD_CLIENT_ID = "1191642657731121272";
const DISCORD_CHECK_GUILD_ID = "683939861539192860";

const CLOUDFLARE_ACCESS_REDIRECT_URI =
"https://approvers.cloudflareaccess.com/cdn-cgi/access/callback";
Expand All @@ -19,25 +19,18 @@ type Bindings = {

const app = new Hono<{ Bindings: Bindings }>();

app.get("/authorize/:scope_mode", async (c) => {
const SCOPE_MODES = ["guilds", "email"];
app.get("/authorize", async (c) => {
if (
c.req.query("client_id") !== DISCORD_CLIENT_ID ||
c.req.query("redirect_uri") !== CLOUDFLARE_ACCESS_REDIRECT_URI ||
!SCOPE_MODES.includes(c.req.param("scope_mode"))
c.req.query("redirect_uri") !== CLOUDFLARE_ACCESS_REDIRECT_URI
) {
return c.text("", 400);
}

const scope =
c.req.param("scope_mode") == "guilds"
? "identify email guilds"
: "identify email";
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: CLOUDFLARE_ACCESS_REDIRECT_URI,
response_type: "code",
scope,
scope: "identify email guilds",
state: c.req.query("state") ?? "",
prompt: "none",
});
Expand All @@ -47,107 +40,30 @@ app.get("/authorize/:scope_mode", async (c) => {

app.post("/token", async (c) => {
const body = await c.req.parseBody();

const code = body.code;
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: c.env.DISCORD_CLIENT_SECRET,
redirect_uri: CLOUDFLARE_ACCESS_REDIRECT_URI,
code: code.toString(),
grant_type: "authorization_code",
scope: "identify email",
});

const tokenResponse = await fetch(`${DISCORD_API_ROOT}/oauth2/token`, {
method: "POST",
body: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!tokenResponse.ok) {
return c.text("", 400);
}
const tokenResult = await tokenResponse.json<{
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}>();
const { access_token: accessToken } = tokenResult;

const meResponse = await fetch(`${DISCORD_API_ROOT}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!meResponse.ok) {
meResponse.text().then(console.error);
return c.text("", 500);
}
const meResult = await meResponse.json<{
id: string;
username: string;
discriminator: string;
global_name?: string;
verified?: boolean;
email?: string;
[key: `roles:${string}`]: string;
}>();

if (!meResult.verified) {
return c.text("", 400);
}

const servers: string[] = [];

const serverResp = await fetch(`${DISCORD_API_ROOT}/users/@me/guilds`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
const res = await token({
code: code.toString(),
generateToken: generateToken(c.env.DISCORD_CLIENT_SECRET),
me,
rolesOf: rolesOf(c.env.DISCORD_TOKEN),
getKeyPair: () =>
loadOrGenerateKeyPair(new KVKeyStore(c.env.KEY_CHAIN_KV)),
});
if (serverResp.ok) {
const serverJson = await serverResp.json<{ id: string }[]>();
servers.push(...serverJson.map(({ id }) => id));
}

const roleClaims: { [key: `roles:${string}`]: string[] } = {};

if (servers.includes(DISCORD_CHECK_GUILD_ID)) {
const memberResponse = await fetch(
`${DISCORD_API_ROOT}/guilds/${DISCORD_CHECK_GUILD_ID}/members/${meResult.id}`,
{
headers: {
Authorization: `Bot ${c.env.DISCORD_TOKEN}`,
},
},
);
const { roles } = await memberResponse.json<{
roles: string[];
}>();
roleClaims[`roles:${DISCORD_CHECK_GUILD_ID}`] = roles;
if (Result.isErr(res)) {
switch (res[1]) {
case "TOKEN_GEN_FAILURE":
return c.text("", 500);
case "NOT_VERIFIED":
return c.text("email not verified", 400);
}
}

const { privateKey } = await loadOrGenerateKeyPair(
new KVKeyStore(c.env.KEY_CHAIN_KV),
);
const idToken = await new SignJWT({
iss: "https://cloudflare.com",
aud: DISCORD_CLIENT_ID,
...meResult,
...roleClaims,
guilds: servers,
})
.setProtectedHeader({ alg: "RS256" })
.setExpirationTime("1h")
.setAudience(DISCORD_CLIENT_ID)
.sign(privateKey);

const { oAuthToken, jwt } = res[1];
return c.json({
...tokenResult,
...oAuthToken,
scope: "identify email",
id_token: idToken,
id_token: jwt,
});
});

Expand Down
4 changes: 3 additions & 1 deletion src/key.ts → src/service/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export interface KeyStore {
put(entry: KeyEntry): Promise<void>;
}

export async function loadOrGenerateKeyPair(store: KeyStore) {
export async function loadOrGenerateKeyPair(
store: KeyStore,
): Promise<CryptoKeyPair> {
const keyPairJson = await store.get();

if (keyPairJson === null) {
Expand Down
Loading

0 comments on commit f3449e8

Please sign in to comment.