Skip to content

Commit

Permalink
Add Google OAuth (#289)
Browse files Browse the repository at this point in the history
Also update CONTRIBUTING.md to reflect adjusted Vite env var
  • Loading branch information
chennisden authored Aug 13, 2024
1 parent 328105b commit 19fcd71
Show file tree
Hide file tree
Showing 12 changed files with 201 additions and 25 deletions.
3 changes: 1 addition & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,7 @@ export GITHUB_CLIENT_ID=''
export GITHUB_CLIENT_SECRET=''
# For Google OAuth
export GOOGLE_CLIENT_ID=''
export GOOGLE_CLIENT_SECRET=''
export VITE_GOOGLE_CLIENT_ID=''
```

### Github OAuth Configuration
Expand Down
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@formspree/react": "^2.5.1",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-slot": "^1.1.0",
"@react-oauth/google": "^0.12.1",
"@react-three/drei": "^9.109.2",
"@react-three/fiber": "^8.16.8",
"@uidotdev/usehooks": "^2.4.1",
Expand Down
77 changes: 64 additions & 13 deletions frontend/src/components/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,75 @@
import { useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
import { FcGoogle } from "react-icons/fc";

import { GoogleOAuthProvider, useGoogleLogin } from "@react-oauth/google";
import { GOOGLE_CLIENT_ID } from "constants/env";
import { useAlertQueue } from "hooks/useAlertQueue";
import { useAuthentication } from "hooks/useAuth";

import { Button } from "components/ui/Button/Button";

interface AuthProvider {
handleGoogleSubmit?: () => void;
handleGoogleSubmit?: (
event: React.MouseEvent<HTMLButtonElement>,
) => Promise<void>;
handleGithubSubmit?: (
event: React.MouseEvent<HTMLButtonElement>,
) => Promise<void>;
}

const AuthProvider = ({
handleGoogleSubmit,
handleGithubSubmit,
}: AuthProvider) => {
const GoogleAuthComponentInner = () => {
const [credential, setCredential] = useState<string | null>(null);
const auth = useAuthentication();
const { addErrorAlert } = useAlertQueue();

useEffect(() => {
(async () => {
if (credential !== null) {
const { data, error } = await auth.client.POST("/users/google/login", {
body: {
token: credential,
},
});
if (error) {
addErrorAlert(error);
} else {
auth.login(data.api_key);
}
}
})();
}, [credential]);

const login = useGoogleLogin({
onSuccess: (tokenResponse) => {
const returnedCredential = tokenResponse.access_token;
if (returnedCredential === undefined) {
addErrorAlert("Failed to login using Google OAuth.");
} else {
setCredential(returnedCredential);
}
},
onError: () => {
addErrorAlert("Failed to login using Google OAuth.");
},
onNonOAuthError: () => {
addErrorAlert("Failed to login using Google OAuth.");
},
});

return (
<Button
variant={"outline"}
size={"lg"}
className="w-full hover:bg-gray-100 dark:hover:bg-gray-600"
onClick={() => login()}
>
<FcGoogle className="w-5 h-5" />
</Button>
);
};

const AuthProvider = ({ handleGithubSubmit }: AuthProvider) => {
return (
<div className="flex flex-col w-full">
<div className="flex justify-center items-center mb-4">
Expand All @@ -23,14 +79,9 @@ const AuthProvider = ({
</div>
<div className="flex items-center w-full gap-x-2">
{/* Google */}
<Button
variant={"outline"}
size={"lg"}
className="w-full hover:bg-gray-100 dark:hover:bg-gray-600"
onClick={handleGoogleSubmit}
>
<FcGoogle className="w-5 h-5" />
</Button>
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<GoogleAuthComponentInner />
</GoogleOAuthProvider>

{/* Github */}
<Button
Expand Down
1 change: 1 addition & 0 deletions frontend/src/constants/env.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const BACKEND_URL =
import.meta.env.VITE_APP_BACKEND_URL || "http://127.0.0.1:8080";
export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || "";
66 changes: 63 additions & 3 deletions frontend/src/gen/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/users/google/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Google Login Endpoint */
post: operations["google_login_endpoint_users_google_login_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/listings/search": {
parameters: {
query?: never;
Expand Down Expand Up @@ -460,6 +477,11 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AuthResponse */
AuthResponse: {
/** Api Key */
api_key: string;
};
/** Body_upload_artifacts_upload_post */
Body_upload_artifacts_upload_post: {
/**
Expand Down Expand Up @@ -530,6 +552,11 @@ export interface components {
/** Api Key */
api_key: string;
};
/** GoogleLogin */
GoogleLogin: {
/** Token */
token: string;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
Expand Down Expand Up @@ -672,8 +699,8 @@ export interface components {
/** Permissions */
permissions: "is_admin"[] | null;
};
/** UserRegister */
UserRegister: {
/** UserSignup */
UserSignup: {
/** Signup Token Id */
signup_token_id: string;
/** Email */
Expand Down Expand Up @@ -788,7 +815,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["UserRegister"];
"application/json": components["schemas"]["UserSignup"];
};
};
responses: {
Expand Down Expand Up @@ -960,6 +987,39 @@ export interface operations {
};
};
};
google_login_endpoint_users_google_login_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["GoogleLogin"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuthResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_listings_listings_search_get: {
parameters: {
query: {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
interface ImportMetaEnv {
readonly VITE_APP_BACKEND_URL: string;
readonly VITE_GOOGLE_CLIENT_ID: string;
}

interface ImportMeta {
Expand Down
6 changes: 3 additions & 3 deletions store/app/crud/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ async def get_user_from_github_token(self, token: str, email: str) -> User:
async def delete_github_token(self, github_id: str) -> None:
await self._delete_item(await self._get_oauth_key(github_auth_key(github_id), throw_if_missing=True))

async def get_user_from_google_token(self, token: str, email: str) -> User | None:
auth_key = google_auth_key(token)
async def get_user_from_google_token(self, email: str) -> User:
auth_key = google_auth_key(email)
user = await self._get_user_from_auth_key(auth_key)
if user is not None:
return user
Expand Down Expand Up @@ -139,7 +139,7 @@ async def test_adhoc() -> None:

await crud.get_user_from_github_token(token="gh_token_example", email="[email protected]")

await crud.get_user_from_google_token(token="google_token_example", email="[email protected]")
await crud.get_user_from_google_token(email="[email protected]")


if __name__ == "__main__":
Expand Down
50 changes: 50 additions & 0 deletions store/app/routers/auth/google.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Defines the API endpoint for creating, deleting and updating user information."""

import logging
from typing import Annotated

import aiohttp
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic.main import BaseModel

from store.app.db import Crud

logger = logging.getLogger(__name__)

google_auth_router = APIRouter()


class GoogleLogin(BaseModel):
token: str


async def get_google_user_email(token: str) -> str:
async with aiohttp.ClientSession() as session:
response = await session.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
params={"access_token": token},
)
if response.status != 200:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Google token")
return (await response.json())["email"]


class AuthResponse(BaseModel):
api_key: str


@google_auth_router.post("/login")
async def google_login_endpoint(
data: GoogleLogin,
crud: Annotated[Crud, Depends(Crud.get)],
) -> AuthResponse:
email = await get_google_user_email(data.token)
user = await crud.get_user_from_google_token(email)

api_key = await crud.add_api_key(
user_id=user.id,
source="oauth",
permissions="full", # OAuth tokens have full permissions.
)

return AuthResponse(api_key=api_key.id)
6 changes: 4 additions & 2 deletions store/app/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from store.app.errors import NotAuthenticatedError
from store.app.model import User, UserPermission
from store.app.routers.auth.github import github_auth_router
from store.app.routers.auth.google import google_auth_router
from store.app.utils.email import send_delete_email
from store.app.utils.password import verify_password

Expand Down Expand Up @@ -130,7 +131,7 @@ def validate_email(email: str) -> str:
return email


class UserRegister(BaseModel):
class UserSignup(BaseModel):
signup_token_id: str
email: str
password: str
Expand Down Expand Up @@ -184,7 +185,7 @@ class PublicUserInfoResponse(BaseModel):

@users_router.post("/signup", response_model=SinglePublicUserInfoResponseItem)
async def register_user(
data: UserRegister, email_signup_crud: EmailSignUpCrud = Depends(), user_crud: UserCrud = Depends()
data: UserSignup, email_signup_crud: EmailSignUpCrud = Depends(), user_crud: UserCrud = Depends()
) -> SinglePublicUserInfoResponseItem: # Added return type annotation
async with email_signup_crud, user_crud:
signup_token = await email_signup_crud.get_email_signup_token(data.signup_token_id)
Expand Down Expand Up @@ -258,3 +259,4 @@ async def get_user_info_by_id_endpoint(


users_router.include_router(github_auth_router, prefix="/github")
users_router.include_router(google_auth_router, prefix="/google")
2 changes: 1 addition & 1 deletion store/app/utils/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def send_signup_email(email: str, token: str) -> None:
"""
)

await send_email(subject="Register", body=body, to=email)
await send_email(subject="Signup", body=body, to=email)


async def send_reset_password_email(email: str, token: str) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_listings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
async def test_listings(app_client: AsyncClient, tmpdir: Path) -> None:
await create_tables()

# Register.
# Signup.
response = await app_client.post("/users/github/code", json={"code": "test_code"})
assert response.status_code == status.HTTP_200_OK, response.json()
token = response.json()["api_key"]
Expand Down

0 comments on commit 19fcd71

Please sign in to comment.