diff --git a/src/components/LichessButton.tsx b/src/components/LichessButton.tsx index 31a1c52..645c90e 100644 --- a/src/components/LichessButton.tsx +++ b/src/components/LichessButton.tsx @@ -1,12 +1,18 @@ -import { useLichess } from "#/hooks/useLichess.ts"; +import { useLichessUser } from "#/context/LichessUserContext.tsx"; +import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts"; const LichessButton = () => { - const { lichessLogin, lichessLogout } = useLichess(); + const { lichessLogin, lichessLogout } = useLichessOAuth(); + const { user, setUser, logout } = useLichessUser(); + + const onClickHandler = () => { + return user?.isLoggedIn ? lichessLogout() : lichessLogin(); + }; return ( ); }; diff --git a/src/context/LichessUserContext.tsx b/src/context/LichessUserContext.tsx index a918183..26e8b16 100644 --- a/src/context/LichessUserContext.tsx +++ b/src/context/LichessUserContext.tsx @@ -9,13 +9,13 @@ import { interface ILichessUser { username: string; + id: string; isLoggedIn: boolean; } interface ILichessUserContextType { user: ILichessUser | null; - setUser: Dispatch>; - logout: () => void; + setUser: Dispatch> } const LichessUserContext = createContext( @@ -25,14 +25,10 @@ const LichessUserContext = createContext( export const LichessUserProvider = ({ children }: { children: ReactNode }) => { const [user, setUser] = useState(null); - const logout = () => { - setUser(null); - }; - // TODO useEffect to check and login user on mount return ( - + {children} ); diff --git a/src/hooks/useLichess.ts b/src/hooks/useLichess.ts deleted file mode 100644 index 754ca5e..0000000 --- a/src/hooks/useLichess.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useServerFn } from "@tanstack/react-start"; -import { useLichessUser } from "#/context/LichessUserContext.tsx"; -import { getToken, login } from "#/server/lichess.ts"; - -export const useLichess = () => { - const { user, setUser, logout } = useLichessUser(); - - const triggerLogin = useServerFn(login); - const trigggerGetToken = useServerFn(getToken); - - console.log("Lichess User:", user); - - const lichessTokenVerification = async ({ code }) => { - await trigggerGetToken(); - }; - - const lichessLogin = async () => { - // TODO add try/catch with toast - await triggerLogin(); - }; - - const lichessLogout = () => { - console.log("Lichess Logout"); - // TODO remove cookies - logout(); - }; - - return { - lichessLogin, - lichessLogout, - lichessTokenVerification, - }; -}; diff --git a/src/hooks/useLichessCallback.ts b/src/hooks/useLichessCallback.ts index cd29550..9fe598b 100644 --- a/src/hooks/useLichessCallback.ts +++ b/src/hooks/useLichessCallback.ts @@ -1,17 +1,26 @@ +import { useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; -import { useLichess } from "#/hooks/useLichess.ts"; +import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts"; interface IProps { code: string; } export const useLichessCallback = ({ code }: IProps) => { - const { lichessTokenVerification } = useLichess(); + const navigate = useNavigate(); + + const { lichessAccessToken, getLichessUser, setLichessUser } = + useLichessOAuth(); useEffect(() => { if (!code) return; - const response = lichessTokenVerification({ code }); - console.log("Response:", response); - }, [code, lichessTokenVerification]); + (async () => { + const tokenData = await lichessAccessToken({ code }); + const userData = await getLichessUser({ tokenData }); + await setLichessUser({ username: userData.username, id: userData.id }); + await navigate({ to: "/chessboard" }); + // TODO set logging in state then redirect + })(); + }, [code, lichessAccessToken, getLichessUser, setLichessUser, navigate]); }; diff --git a/src/hooks/useLichessOAuth.ts b/src/hooks/useLichessOAuth.ts new file mode 100644 index 0000000..7cc4d03 --- /dev/null +++ b/src/hooks/useLichessOAuth.ts @@ -0,0 +1,60 @@ +import { useServerFn } from "@tanstack/react-start"; +import { useLichessUser } from "#/context/LichessUserContext.tsx"; +import { getToken, getUser, login, setSession, logout } from "#/server/lichess.ts"; + +interface ITokenData { + access_token: string; + expires_in: number; + token_type: string; +} + +export const useLichessOAuth = () => { + const { setUser } = useLichessUser(); + + const loginFn = useServerFn(login); + const logoutFn = useServerFn(logout) + const accessTokenFn = useServerFn(getToken); + const getUserFn = useServerFn(getUser); + const setSessionFn = useServerFn(setSession); + + const lichessAccessToken = async ({ code }: { code: string }) => { + return accessTokenFn({ data: { code } }); + }; + + const setLichessUser = async ({ + username, + id, + }: { + username: string; + id: string; + }) => { + await setSessionFn({ data: { username, id } }); + setUser({ username, id, isLoggedIn: true }); + }; + + const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => { + return getUserFn({ + data: { access_token: tokenData.access_token }, + }); + }; + + const lichessLogin = async () => { + // TODO add try/catch with toast + await loginFn(); + }; + + const lichessLogout = async () => { + console.log("Lichess Logout"); + // TODO remove cookies + await logoutFn(); + setUser(null); + }; + + return { + lichessLogin, + lichessLogout, + lichessAccessToken, + getLichessUser, + setLichessUser, + }; +}; diff --git a/src/routes/callback.tsx b/src/routes/callback.tsx index 8d7b7b6..c54a903 100644 --- a/src/routes/callback.tsx +++ b/src/routes/callback.tsx @@ -4,7 +4,6 @@ import Callback from "#/pages/Callback.tsx"; const callbackSchema = z.object({ code: z.string(), - state: z.string(), }); // type CallbackProps = z.infer; diff --git a/src/server/lichess.ts b/src/server/lichess.ts index 163bab8..6f3862f 100644 --- a/src/server/lichess.ts +++ b/src/server/lichess.ts @@ -1,31 +1,118 @@ import { randomBytes } from "node:crypto"; import { redirect } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; +import { getCookie, setCookie, deleteCookie } from "@tanstack/react-start/server"; import { env } from "#/env.ts"; import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts"; -export const getToken = createServerFn({ method: "POST" }).handler(async () => { - console.log("getTokens"); -}); +const createVerifier = () => base64UrlEncode(randomBytes(32)); +const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier)); -export const verifyToken = createServerFn({ method: "POST" }).handler( +const getRedirectUri = () => + process.env.NODE_ENV === "production" + ? `https://${env.LICHESS_CLIENT_ID}/callback` + : "http://localhost:3000/callback"; + +// TODO fix and check +export const getSession = createServerFn({ method: "GET" }).handler( async () => { - console.log("verifyTokens"); + const session = getCookie("lichess-session"); + if (!session) return null; + try { + return JSON.parse(session) as { username: string }; + } catch { + return null; + } }, ); -export const login = createServerFn({ method: "POST" }).handler(async () => { - const createVerifier = () => base64UrlEncode(randomBytes(32)); - const createChallenge = (verifier: string) => - base64UrlEncode(sha256(verifier)); +export const setSession = createServerFn({ method: "POST" }) + .inputValidator((data: { username: string; id: string }) => data) + .handler(async ({ data }) => { + setCookie( + "lichess-session", + JSON.stringify({ username: data.username, id: data.id }), + { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }, + ); + setCookie("lichess-oauth-verifier", "", { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + }); + + return { username: data.username, id: data.id }; + }); + +export const getToken = createServerFn({ method: "POST" }) + .inputValidator((data: { code: string }) => data) + .handler(async ({ data }) => { + const verifier = getCookie("lichess-oauth-verifier"); + + if (!verifier) + throw new Error("OAuth session expired. Please login again."); + + const response = await fetch("https://lichess.org/api/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: env.LICHESS_CLIENT_ID || "", + code: data.code, + redirect_uri: getRedirectUri(), + code_verifier: verifier, + }), + }); + + if (!response.ok) + throw new Error(`Token exchange failed: ${await response.text()}`); + + return response.json(); + }); + +export const getUser = createServerFn({ method: "GET" }) + .inputValidator((data: { access_token: string }) => data) + .handler(async ({ data }) => { + const response = await fetch("https://lichess.org/api/account", { + headers: { + Authorization: `Bearer ${data.access_token}`, + }, + }); + + return response.json(); + }); + +export const logout = createServerFn().handler(async () => { + deleteCookie("lichess-session", { path: "/" }); + deleteCookie("lichess-oauth-verifier", { path: "/" }); +}); + +export const login = createServerFn({ method: "GET" }).handler(async () => { const verifier = createVerifier(); const challenge = createChallenge(verifier); + setCookie("lichess-oauth-verifier", verifier, { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 10, + }); + const params = new URLSearchParams({ response_type: "code", client_id: env.LICHESS_CLIENT_ID || "", - redirect_uri: `${process.env.NODE_ENV === "production" ? `https://${env.LICHESS_CLIENT_ID}/callback` : "http://localhost:3000/callback"}`, + redirect_uri: getRedirectUri(), scope: "study:read", code_challenge_method: "S256", code_challenge: challenge, @@ -33,9 +120,5 @@ export const login = createServerFn({ method: "POST" }).handler(async () => { throw redirect({ href: `https://lichess.org/oauth?${params.toString()}`, - statusCode: 302, - headers: { - "Set-Cookie": `lichess_oauth_verifier=${verifier}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`, // Expires in 10 mins - }, }); });