Skip to content

Instantly share code, notes, and snippets.

Full example of implementing Google Authentication in PatchKit Launcher SDK React Theme.
import {
configureOauth2LoopbackDefaultServer,
focusWindow,
} from "@upsoft/patchkit-launcher-runtime-api-react-theme-client";
import { useOauth2LoopbackPendingRequestListener } from "@upsoft/patchkit-launcher-runtime-api-react-theme-extras";
import * as jose from "jose";
import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
const OAUTH2_CLIENT_ID = `YOUR_OAUTH2_CLIENT_ID`;
const OAUTH2_CLIENT_SECRET = `YOUR_OAUTH2_CLIENT_SECRET`;
const OAUTH2_AUTH_ENDPOINT_URL = `https://accounts.google.com/o/oauth2/v2/auth`;
const OAUTH2_TOKEN_ENDPOINT_URL = `https://oauth2.googleapis.com/token`;
const OAUTH2_REVOKE_ENDPOINT_URL = `https://oauth2.googleapis.com/revoke`;
const OAUTH2_REDIRECT_URI_PATH = `google-auth`;
interface UserInternalAuthStoreState {
value: UserInternalAuth | undefined;
}
const USER_INTERNAL_AUTH_STORE_STATE_LOCAL_STORAGE_KEY = "google-user-internal-auth-store-state";
const USER_INTERNAL_AUTH_STORE_DEFAULT_STATE: UserInternalAuthStoreState = {
value: undefined,
};
function fetchUserInternalAuthStoreState(): UserInternalAuthStoreState {
const userInternalAuthStoreStateAsJson = localStorage.getItem(USER_INTERNAL_AUTH_STORE_STATE_LOCAL_STORAGE_KEY);
if (userInternalAuthStoreStateAsJson === null) {
return USER_INTERNAL_AUTH_STORE_DEFAULT_STATE;
}
try {
return JSON.parse(userInternalAuthStoreStateAsJson) as UserInternalAuthStoreState;
} catch {
return USER_INTERNAL_AUTH_STORE_DEFAULT_STATE;
}
}
function setUserInternalAuthStoreState(
{
userInternalAuthStoreState,
}: {
userInternalAuthStoreState: UserInternalAuthStoreState;
},
) {
const userInternalAuthStoreStateAsJson = JSON.stringify(userInternalAuthStoreState);
localStorage.setItem(USER_INTERNAL_AUTH_STORE_STATE_LOCAL_STORAGE_KEY, userInternalAuthStoreStateAsJson);
}
function generateUserOauth2CodeVerifier() {
let userOauth2CodeVerifier = ``;
const chars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789`;
for (let i = 0; i < 48; i++) {
userOauth2CodeVerifier += chars.charAt(Math.floor(Math.random() * chars.length));
}
return userOauth2CodeVerifier;
}
function generateUserOauth2State() {
let userOauth2State = ``;
const chars = `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789`;
for (let i = 0; i < 16; i++) {
userOauth2State += chars.charAt(Math.floor(Math.random() * chars.length));
}
return userOauth2State;
}
async function getUserOauth2CodeChallenge({
userOauth2CodeVerifier,
}: {
userOauth2CodeVerifier: string;
}) {
const userOauth2CodeVerifierDigest = await crypto.subtle.digest(
`SHA-256`,
new TextEncoder().encode(userOauth2CodeVerifier),
);
return btoa(String.fromCharCode(...new Uint8Array(userOauth2CodeVerifierDigest))).replace(/=/g, ``).replace(/\+/g, `-`).replace(/\//g, `_`);
}
interface UserInternalAuth {
id: string;
displayName: string;
avatarUrl: string | undefined;
accessToken: string;
accessTokenExpireDateTime: number;
refreshToken: string;
}
async function fetchUserInternalAuth(
{
userIdToken,
userAccessToken,
userAccessTokenExpireDateTime,
userRefreshToken,
}: {
userIdToken: string;
userAccessToken: string;
userAccessTokenExpireDateTime: number;
userRefreshToken: string;
},
): Promise<UserInternalAuth> {
const userIdTokenPayload = jose.decodeJwt(
userIdToken,
);
return {
id: userIdTokenPayload.sub!,
displayName: userIdTokenPayload.email as string,
avatarUrl: userIdTokenPayload.picture as string | undefined,
accessToken: userAccessToken,
accessTokenExpireDateTime: userAccessTokenExpireDateTime,
refreshToken: userRefreshToken,
};
}
async function fetchUserInternalAuthWithOauth2Code(
{
userOauth2Code,
userOauth2CodeVerifier,
userOauth2RedirectUri,
}: {
userOauth2Code: string;
userOauth2CodeVerifier: string;
userOauth2RedirectUri: string;
},
): Promise<UserInternalAuth> {
const oauth2TokenEndpointResponse = await fetch(
OAUTH2_TOKEN_ENDPOINT_URL,
{
method: `POST`,
headers: {
[`Content-Type`]: `application/x-www-form-urlencoded`,
},
body: new URLSearchParams({
code: userOauth2Code,
client_id: OAUTH2_CLIENT_ID,
client_secret: OAUTH2_CLIENT_SECRET,
grant_type: `authorization_code`,
code_verifier: userOauth2CodeVerifier,
redirect_uri: userOauth2RedirectUri,
}),
},
);
if (!oauth2TokenEndpointResponse.ok) {
throw new Error(`Status: ${String(oauth2TokenEndpointResponse.status)}`);
}
const oauth2TokenEndpointResponseBody = await oauth2TokenEndpointResponse.json() as {
id_token: string;
access_token: string;
expires_in: number;
refresh_token: string;
};
const userIdToken = oauth2TokenEndpointResponseBody.id_token;
const userAccessToken = oauth2TokenEndpointResponseBody.access_token;
const userAccessTokenExpireDateTime = Date.now() + oauth2TokenEndpointResponseBody.expires_in * 1000;
const userRefreshToken = oauth2TokenEndpointResponseBody.refresh_token;
return await fetchUserInternalAuth({
userIdToken,
userAccessToken,
userAccessTokenExpireDateTime,
userRefreshToken,
});
}
async function fetchUserInternalAuthWithRefreshToken(
{
userRefreshToken,
}: {
userRefreshToken: string;
},
): Promise<UserInternalAuth> {
const oauth2TokenEndpointResponse = await fetch(
OAUTH2_TOKEN_ENDPOINT_URL,
{
method: `POST`,
headers: {
[`Content-Type`]: `application/x-www-form-urlencoded`,
},
body: new URLSearchParams({
refresh_token: userRefreshToken,
client_id: OAUTH2_CLIENT_ID,
client_secret: OAUTH2_CLIENT_SECRET,
grant_type: `refresh_token`,
}),
},
);
if (!oauth2TokenEndpointResponse.ok) {
throw new Error(`Status: ${String(oauth2TokenEndpointResponse.status)}`);
}
const oauth2TokenEndpointResponseBody = await oauth2TokenEndpointResponse.json() as {
id_token: string;
access_token: string;
expires_in: number;
};
const userIdToken = oauth2TokenEndpointResponseBody.id_token;
const userAccessToken = oauth2TokenEndpointResponseBody.access_token;
const userAccessTokenExpireDateTime = Date.now() + oauth2TokenEndpointResponseBody.expires_in * 1000;
return await fetchUserInternalAuth({
userIdToken,
userAccessToken,
userAccessTokenExpireDateTime,
userRefreshToken,
});
}
function getIsUserAccessTokenAboutToExpire(
{
userAccessTokenExpireDateTime,
}: {
userAccessTokenExpireDateTime: number;
},
) {
const isUserAccessTokenAboutToExpire = userAccessTokenExpireDateTime - Date.now() < 1000 * 60 * 5; /* 5 minutes */
return isUserAccessTokenAboutToExpire;
}
async function invalidateUserInternalAuth(
{
userInternalAuth,
}: {
userInternalAuth: UserInternalAuth;
},
): Promise<void> {
try {
await fetch(
OAUTH2_REVOKE_ENDPOINT_URL,
{
method: `POST`,
headers: {
[`Content-Type`]: `application/x-www-form-urlencoded`,
},
body: new URLSearchParams({
token: userInternalAuth.accessToken,
}),
},
);
} catch {
// ignore
}
}
export interface UserAuth {
id: string;
displayName: string;
avatarUrl: string | undefined;
}
export interface UserContextValue {
userAuth: UserAuth | undefined;
startSignInUserTask: () => Promise<void>;
signOutUser: () => Promise<void>;
}
interface SignInUserTaskState {
userOauth2CodeVerifier: string;
userOauth2State: string;
userOauth2RedirectUri: string;
}
export const UserContext = createContext<UserContextValue>(undefined!);
export function UserContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const [userInternalAuth, setUserInternalAuth] = useState<UserInternalAuth | undefined>(
fetchUserInternalAuthStoreState().value,
);
useEffect(
() => {
setUserInternalAuthStoreState({
userInternalAuthStoreState: {
value: userInternalAuth,
},
});
},
[
userInternalAuth,
],
);
const [
signInUserTaskState,
setSignInUserTaskState,
] = useState<SignInUserTaskState | undefined>(undefined);
const startSignInUserTask: UserContextValue["startSignInUserTask"] = useCallback(
async () => {
const userOauth2CodeVerifier = generateUserOauth2CodeVerifier();
const userOauth2State = generateUserOauth2State();
const { oauth2LoopbackDefaultServerUrl } = await configureOauth2LoopbackDefaultServer({
oauth2LoopbackDefaultServerConfig: {
redirectUrl: `https://your-website.com`,
},
});
const userOauth2RedirectUri = `${String(oauth2LoopbackDefaultServerUrl)}/${OAUTH2_REDIRECT_URI_PATH}`;
setSignInUserTaskState({
userOauth2CodeVerifier,
userOauth2State,
userOauth2RedirectUri,
});
const userOauth2CodeChallenge = await getUserOauth2CodeChallenge({
userOauth2CodeVerifier: userOauth2CodeVerifier,
});
let userOauth2SignInUrl = `${OAUTH2_AUTH_ENDPOINT_URL}?`;
userOauth2SignInUrl += `client_id=${OAUTH2_CLIENT_ID}&`;
userOauth2SignInUrl += `scope=openid%20profile%20email&`;
userOauth2SignInUrl += `redirect_uri=${encodeURIComponent(userOauth2RedirectUri)}&`;
userOauth2SignInUrl += `response_type=code&`;
userOauth2SignInUrl += `code_challenge=${userOauth2CodeChallenge}&`;
userOauth2SignInUrl += `code_challenge_method=S256&`;
userOauth2SignInUrl += `state=${userOauth2State}`;
window.open(
userOauth2SignInUrl,
`_blank`,
);
},
[
],
);
useOauth2LoopbackPendingRequestListener(
useCallback(
async (
{
oauth2LoopbackPendingRequestController,
},
) => {
if (oauth2LoopbackPendingRequestController.url.includes(`/${OAUTH2_REDIRECT_URI_PATH}`)) {
await oauth2LoopbackPendingRequestController.dismiss({});
const oauth2LoopbackPendingRequestUrlAsObject = new URL(oauth2LoopbackPendingRequestController.url);
const userOauth2Code = oauth2LoopbackPendingRequestUrlAsObject.searchParams.get(`code`);
const userOauth2State = oauth2LoopbackPendingRequestUrlAsObject.searchParams.get(`state`);
if (
userOauth2Code !== null
&& signInUserTaskState !== undefined
&& signInUserTaskState.userOauth2State === userOauth2State
) {
const userNewInternalAuth = await fetchUserInternalAuthWithOauth2Code({
userOauth2Code,
userOauth2CodeVerifier: signInUserTaskState.userOauth2CodeVerifier,
userOauth2RedirectUri: signInUserTaskState.userOauth2RedirectUri,
});
setUserInternalAuth(userNewInternalAuth);
await focusWindow({});
}
}
},
[
signInUserTaskState,
],
),
);
const [shouldCheckUserAccessToken, setShouldCheckUserAccessToken] = useState<boolean>(true);
useEffect(
() => {
if (!shouldCheckUserAccessToken) {
return;
}
setShouldCheckUserAccessToken(false);
(async () => {
try {
if (userInternalAuth === undefined) {
return;
}
if (
getIsUserAccessTokenAboutToExpire({
userAccessTokenExpireDateTime: userInternalAuth.accessTokenExpireDateTime,
})
) {
try {
const userNewInternalAuth = await fetchUserInternalAuthWithRefreshToken({
userRefreshToken: userInternalAuth.refreshToken,
});
setUserInternalAuth(userNewInternalAuth);
} catch {
setUserInternalAuth(undefined);
}
}
} finally {
await new Promise(resolve => setTimeout(resolve, 5000));
setShouldCheckUserAccessToken(true);
}
})();
},
[
shouldCheckUserAccessToken,
userInternalAuth,
],
);
const signOutUser: UserContextValue["signOutUser"] = useCallback(
async () => {
if (userInternalAuth !== undefined) {
await invalidateUserInternalAuth({
userInternalAuth,
});
}
setUserInternalAuth(undefined);
},
[
userInternalAuth,
],
);
const userContextValue = useMemo<UserContextValue>(
() => {
return {
userAuth: userInternalAuth === undefined
? undefined
: {
id: userInternalAuth.id,
displayName: userInternalAuth.displayName,
avatarUrl: userInternalAuth.avatarUrl,
},
startSignInUserTask,
signOutUser,
};
},
[
userInternalAuth,
startSignInUserTask,
signOutUser,
],
);
return (
<UserContext.Provider
value={userContextValue}
>
{children}
</UserContext.Provider>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment