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

Allow users to authenticate with Google OAuth #414

Merged
merged 16 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
5 changes: 5 additions & 0 deletions .dev.vars
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
# https://developers.cloudflare.com/pages/platform/functions/bindings/#interact-with-your-environment-variables-locally
ENVIRONMENT=development
JWT_SECRET=secret
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://chatcraft.org/api/login/
GOOGLE_RESPONSE_TYPE=code
GOOGLE_SCOPE=profile email
184 changes: 172 additions & 12 deletions functions/api/login.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { buildUrl, createResourcesForEnv } from "../utils";
import { requestAccessToken, requestUserInfo, requestDevUserInfo } from "../github";
import {
requestGoogleAccessToken,
requestGoogleDevUserInfo,
requestGoogleUserInfo,
} from "../google";
import { TokenProvider } from "../token-provider";

interface Env {
ENVIRONMENT: string;
CLIENT_ID: string;
CLIENT_SECRET: string;
JWT_SECRET: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_REDIRECT_URI: string;
GOOGLE_RESPONSE_TYPE: string;
GOOGLE_SCOPE: string;
}

let provider: string | null = "";
WangGithub0 marked this conversation as resolved.
Show resolved Hide resolved

// Authenticate the user with GitHub, then create a JWT for use in ChatCraft.
// We store the token in a secure, HTTP-only cookie.
export async function handleProdLogin({
Expand Down Expand Up @@ -101,24 +112,173 @@ export async function handleDevLogin({
}
}

export async function handleGoogleProdLogin({
WangGithub0 marked this conversation as resolved.
Show resolved Hide resolved
code,
chatId,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
GOOGLE_RESPONSE_TYPE,
GOOGLE_SCOPE,
JWT_SECRET,
tokenProvider,
appUrl,
}: {
code: string | null;
chatId: string | null;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GOOGLE_REDIRECT_URI: string;
GOOGLE_RESPONSE_TYPE: string;
GOOGLE_SCOPE: string;
JWT_SECRET: string;
tokenProvider: TokenProvider;
appUrl: string;
}) {
// If we're missing the code, redirect to the Google Auth UI
if (!code) {
// const state = JSON.stringify({ provider: "google", chatId: chatId });
WangGithub0 marked this conversation as resolved.
Show resolved Hide resolved
const url = buildUrl(
"https://accounts.google.com/o/oauth2/v2/auth",
// If there's a chatId, piggy-back it on the request as state
chatId
? {
client_id: GOOGLE_CLIENT_ID,
redirect_uri: GOOGLE_REDIRECT_URI,
response_type: GOOGLE_RESPONSE_TYPE,
scope: GOOGLE_SCOPE,
state: chatId,
}
: {
client_id: GOOGLE_CLIENT_ID,
redirect_uri: GOOGLE_REDIRECT_URI,
response_type: GOOGLE_RESPONSE_TYPE,
scope: GOOGLE_SCOPE,
}
);
return Response.redirect(url, 302);
}

// Otherwise, exchange the code for an access_token, then get user info
// and use that to create JWTs for ChatCraft.
try {
const googleAccessToken = await requestGoogleAccessToken(
code,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI
);
const user = await requestGoogleUserInfo(googleAccessToken);
// User info goes in a non HTTP-Only cookie that browser can read
const idToken = await tokenProvider.createToken(user.username, user, JWT_SECRET);
// API authorization goes in an HTTP-Only cookie that only functions can read
const accessToken = await tokenProvider.createToken(user.username, { role: "api" }, JWT_SECRET);

// Return to the root or a specific chat if we have an id
const url = new URL(chatId ? `/c/${chatId}` : "/", appUrl).href;

return new Response(null, {
status: 302,
headers: new Headers([
["Location", url],
["Set-Cookie", tokenProvider.serializeToken("access_token", accessToken)],
["Set-Cookie", tokenProvider.serializeToken("id_token", idToken)],
]),
});
} catch (err) {
console.error(err);
return Response.redirect(`https://chatcraft.org/?google_login_error`, 302);
}
}

// In development, we simulate a Google login.
export async function handleGoogleDevLogin({
chatId,
JWT_SECRET,
tokenProvider,
appUrl,
}: {
chatId: string | null;
JWT_SECRET: string;
tokenProvider: TokenProvider;
appUrl: string;
}) {
try {
const user = requestGoogleDevUserInfo();
// User info goes in a non HTTP-Only cookie that browser can read
const idToken = await tokenProvider.createToken(user.username, user, JWT_SECRET);
// API authorization goes in an HTTP-Only cookie that only functions can read
const accessToken = await tokenProvider.createToken(user.username, { role: "api" }, JWT_SECRET);

// Return to the root or a specific chat if we have an id
const url = new URL(chatId ? `/c/${chatId}` : "/", appUrl).href;

return new Response(null, {
status: 302,
headers: new Headers([
["Location", url],
["Set-Cookie", tokenProvider.serializeToken("access_token", accessToken)],
["Set-Cookie", tokenProvider.serializeToken("id_token", idToken)],
]),
});
} catch (err) {
console.error(err);
return Response.redirect(`/?google_login_error`, 302);
}
}

export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
const { CLIENT_ID, CLIENT_SECRET, JWT_SECRET } = env;
const {
CLIENT_ID,
CLIENT_SECRET,
JWT_SECRET,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
GOOGLE_RESPONSE_TYPE,
GOOGLE_SCOPE,
} = env;

const reqUrl = new URL(request.url);
// Determine the login provider
provider = reqUrl.searchParams.get("provider") ? reqUrl.searchParams.get("provider") : provider;
WangGithub0 marked this conversation as resolved.
Show resolved Hide resolved
const code = reqUrl.searchParams.get("code");
// Include ?chat_id=... to redirect back to a given chat in the client. GitHub will
// return it back to us via ?state=...
const chatId = reqUrl.searchParams.get("chat_id") || reqUrl.searchParams.get("state");
const { appUrl, isDev, tokenProvider } = createResourcesForEnv(env.ENVIRONMENT, request.url);

return isDev
? handleDevLogin({ chatId, JWT_SECRET, tokenProvider, appUrl })
: handleProdLogin({
code,
chatId,
CLIENT_ID,
CLIENT_SECRET,
JWT_SECRET,
tokenProvider,
appUrl,
});
if (provider === "google") {
return isDev
? handleGoogleDevLogin({
chatId,
JWT_SECRET,
tokenProvider,
appUrl,
})
: handleGoogleProdLogin({
code,
chatId,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
GOOGLE_RESPONSE_TYPE,
GOOGLE_SCOPE,
JWT_SECRET,
tokenProvider,
appUrl,
});
} else {
return isDev
? handleDevLogin({ chatId, JWT_SECRET, tokenProvider, appUrl })
: handleProdLogin({
code,
chatId,
CLIENT_ID,
CLIENT_SECRET,
JWT_SECRET,
tokenProvider,
appUrl,
});
}
};
69 changes: 69 additions & 0 deletions functions/google.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, test, expect } from "vitest";

import { requestGoogleAccessToken, requestGoogleUserInfo } from "./google";

export const googleMocks = () => ({
"google.com": (mockAccessToken = "google_access_token...") => {
// Mock the call to GitHub's OAuth login endpoint
const fetchMock = getMiniflareFetchMock();
fetchMock.disableNetConnect();
const origin = fetchMock.get("https://accounts.google.com");
origin
.intercept({ method: "POST", path: /^\/o\/oauth2\/token.+/ })
.reply(200, { access_token: mockAccessToken });
return origin;
},
"www.googleapis.com": ({
email,
name,
picture,
}: {
email: string;
name: string;
picture: string;
}) => {
const fetchMock = getMiniflareFetchMock();
fetchMock.disableNetConnect();
const origin = fetchMock.get("https://www.googleapis.com");
origin
.intercept({ method: "GET", path: "/oauth2/v1/userinfo" })
.reply(200, { email, name, picture });
return origin;
},
all(mockAccessToken = "googleo_access_token...") {
this["google.com"](mockAccessToken);
// Use default user details
this["www.googleapis.com"]({ email: "email", name: "name", picture: "picture" });
},
});

describe("google.ts", () => {
const mockAccessToken = "gho_access_token...";

test("requestAccessToken()", async () => {
const env = getMiniflareBindings();

// Mock the call to GitHub's OAuth login endpoint
const mocks = googleMocks();
mocks["google.com"](mockAccessToken);

const accessToken = await requestGoogleAccessToken(
"code",
env.CLIENT_ID,
env.CLIENT_SECRET,
env.GOOGLE_REDIRECT_URI
);
expect(accessToken).toEqual(mockAccessToken);
});

test("requestGoogleUserInfo()", async () => {
// Mock the call to Google API /user endpoint
const mocks = googleMocks();
mocks["www.googleapis.com"]({ email: "email", name: "name", picture: "picture" });

const user: User = await requestGoogleUserInfo(mockAccessToken);
expect(user.username).toEqual("email");
expect(user.name).toEqual("name");
expect(user.avatarUrl).toEqual("picture");
});
});
70 changes: 70 additions & 0 deletions functions/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { buildUrl } from "./utils";

// https://developers.google.com/identity/protocols/oauth2/web-server#exchange-authorization-code
export async function requestGoogleAccessToken(
code: string,
CLIENT_ID: string,
CLIENT_SECRET: string,
GOOGLE_REDIRECT_URI: string
) {
const url = buildUrl("https://accounts.google.com/o/oauth2/token", {
code: code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: GOOGLE_REDIRECT_URI,
grant_type: "authorization_code",
});

const res = await fetch(url, {
method: "POST",
headers: {
"User-Agent": "chatcraft.org",
},
});

if (!res.ok) {
throw new Error(`Failed to get Google token: ${res.status} ${await res.text()}`);
}

const result = (await res.json()) as {
error?: string;
access_token: string;
};
if (result.error) {
throw new Error(`Error in Google token response: ${result.error}`);
}

return result.access_token;
}

export async function requestGoogleUserInfo(token: string): Promise<User> {
const res = await fetch("https://www.googleapis.com/oauth2/v1/userinfo", {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": "chatcraft.org",
},
});

if (!res.ok) {
throw new Error(`Failed to get Google User info: ${res.status} ${await res.text()}`);
}

const { email, name, picture } = (await res.json()) as {
email: string;
name: string;
picture: string;
};

return { username: email, name: name, avatarUrl: picture };
}

// In development environments, we automatically log the user in without involving GitHub
WangGithub0 marked this conversation as resolved.
Show resolved Hide resolved
export function requestGoogleDevUserInfo() {
return {
username: "chatcraft_dev_google",
name: "ChatCraftDevGoogle",
avatarUrl:
"https://upload.wikimedia.org/wikipedia/commons/c/c1/Google_%22G%22_logo.svg?size=402",
};
}
Loading
Loading