Created
January 20, 2025 07:49
Full example of implementing Google Authentication in PatchKit Launcher SDK React Theme.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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