Radzion
15 min readSep 27, 2023

Watch on YouTube | 🐙 GitHub | 🎮 Demo

In this post, I will walk you through a full-stack implementation of OAuth 2 authentication for a NextJS static app with a NodeJS back-end. This is a model I have used for years. I have recently refactored it and will share it with you. We will be examining the increaser.org codebase. It is housed in a private repo, but don’t worry, I’ll include all the necessary snippets in this blog post. Additionally, you can find all the reusable components, hooks, and utils at the ReactKit repository. While Increaser provides Google and Facebook login, I had previously implemented LinkedIn and Twitter options. All these providers adhere to the same OAuth 2 standard. Hence, once you understand the concept explained in this post, you can easily expand your auth options.

Sign up page at Increaser

Displaying OAuth Options

Let’s begin with the front-end. Here, we have the OAuthOptions component, which is used on both the sign-in and sign-up pages to display all the available OAuth providers.

import { OAuthProvider } from "@increaser/api-interface/client/graphql"
import { VStack } from "@increaser/ui/ui/Stack"
import { OAuthOption } from "./OAuthOption"

const options: OAuthProvider[] = ["google", "facebook"]

export const OAuthOptions = () => (
<VStack gap={12}>
{options.map((provider) => (
<OAuthOption key={provider} provider={provider} />
))}
</VStack>
)

The options array includes Google and Facebook. The OAuthProvider type is derived from the types generated from the GraphQL schema. Type generation is not this post's focus, but if you're curious about how I manage it in a monorepo, check out this post.

import { analytics } from "analytics"

import { ExternalLink } from "router/Link/ExternalLink"
import { IconCentricButton } from "@increaser/ui/ui/buttons/IconCentricButton"
import { OAuthProvider } from "@increaser/api-interface/client/graphql"
import { getOAuthUrl } from "auth/utils/oauth"
import { oauthProviderNameRecord } from "auth/oauthProviderNameRecord"
import { AuthProviderIcon } from "./AuthProviderIcon"

interface OAuthOptionProps {
provider: OAuthProvider
}

export const OAuthOption = ({ provider }: OAuthOptionProps) => {
const providerName = oauthProviderNameRecord[provider]

return (
<ExternalLink
key={provider}
to={getOAuthUrl(provider)}
openInSameTab
onClick={() => {
analytics.trackEvent(`Start identification with ${providerName}`)
}}
>
<IconCentricButton
as="div"
text={`Continue with ${providerName}`}
icon={<AuthProviderIcon provider={provider} />}
/>
</ExternalLink>
)
}

The OAuthOption component is a link that triggers the OAuth flow by opening the Facebook or Google login page in the same tab. When the user clicks the link, we also send an event to our analytics service to monitor the conversion rate between different providers. Data from Increaser reveals that Google and Facebook have the same conversion rate. However, ten times more users choose Google over Facebook. Back when I had Twitter and LinkedIn, these options attracted even fewer users, leading me to remove them to avoid cluttering the UI.

Amplitude analytics

Our button is rendered solely for visual purposes as a div element, hence there’s no need to pass the onClick handler. The icon is rendered using the Match component from ReactKit. This functions as a switch statement for React components.

import { AuthProvider } from "@increaser/api-interface/client/graphql"
import { Match } from "@increaser/ui/ui/Match"
import { FacebookIcon } from "@increaser/ui/ui/icons/FacebookIcon"
import { GoogleIcon } from "@increaser/ui/ui/icons/GoogleIcon"

interface AuthProviderIconProps {
provider: AuthProvider
}

export const AuthProviderIcon = ({ provider }: AuthProviderIconProps) => (
<Match
value={provider}
google={() => <GoogleIcon />}
facebook={() => <FacebookIcon />}
/>
)

The IconCentricButton component is a wrapper around the reusable Button component, which I discussed in this post. Our goal here is to center the text while keeping the icon absolutely positioned to the left for aesthetic purposes.

import { ReactNode } from "react"
import styled from "styled-components"

import { Button, ButtonProps } from "./Button"
import { horizontalPadding } from "../../css/horizontalPadding"
import { centerContent } from "../../css/centerContent"

const Content = styled.div`
position: relative;
width: 100%;
${centerContent};
`

const IconWrapper = styled.div`
position: absolute;
left: 0;
display: flex;
`

interface Props extends Omit<ButtonProps, "children"> {
icon: ReactNode
text: ReactNode
}

const Container = styled(Button)`
${horizontalPadding(24)};
`

export const IconCentricButton = ({ icon, text, as, ...rest }: Props) => (
<Container kind="outlined" forwardedAs={as} size="xl" {...rest}>
<Content>
<IconWrapper>{icon}</IconWrapper>
{text}
</Content>
</Container>
)

Constructing OAuth Url for Google and Facebook

To construct the url for the OAuth flow, we use the getOAuthUrl function. This returns the url for the specified provider. We first extract the base url from the oauthBaseUrlRecord object, then build an object with shared query params. The client_id is extracted from the oauthClientIdRecord object, which relies on environment variables. To acquire these keys, you must set up OAuth for your app within Google and Facebook developer consoles. The getOAuthRedirectUri function is used to build the redirect_uri. This function extracts the app's base url from environment variables. In the production version of Increaser, this would be https://increaser.org, and http://localhost:3000 for local development. The function then adds /oauth/{provider} to the base url. This implies that Google will redirect the user to https://increaser.org/oauth/google after logging in on their platform. The scope represents the list of permissions we request from the user. For Google, these scope urls are separated by space, while for Facebook, it's a comma-separated list of permissions. As a response_type, we desire to receive a code. Then we have a few custom query parameters for each provider. Once again, we're using the match function from ReactKit to replace the switch statement.

import { addQueryParams } from "@increaser/utils/addQueryParams"
import { shouldBeDefined } from "@increaser/utils/shouldBeDefined"
import { AuthProvider } from "@increaser/api-interface/client/graphql"
import { Path } from "router/Path"
import { match } from "@increaser/utils/match"

const oauthBaseUrlRecord: Record<AuthProvider, string> = {
google: "https://accounts.google.com/o/oauth2/v2/auth",
facebook: "https://www.facebook.com/v4.0/dialog/oauth",
}

const oauthScopeRecord: Record<AuthProvider, string> = {
google:
"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
facebook: "public_profile,email",
}

const oauthClientIdRecord: Record<AuthProvider, string> = {
google: shouldBeDefined(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID),
facebook: shouldBeDefined(process.env.NEXT_PUBLIC_FACEBOOK_APP_ID),
}

export const getOAuthRedirectUri = (provider: AuthProvider) =>
`${process.env.NEXT_PUBLIC_BASE_URL}${Path.OAuth}/${provider}`

export const getOAuthUrl = (provider: AuthProvider) => {
const baseUrl = oauthBaseUrlRecord[provider]

const sharedQueryParams = {
client_id: oauthClientIdRecord[provider],
redirect_uri: getOAuthRedirectUri(provider),
scope: oauthScopeRecord[provider],
response_type: "code",
}

const customQueryParams = match<AuthProvider, object>(provider, {
google: () => ({
access_type: "offline",
prompt: "consent",
}),
facebook: () => ({
auth_type: "rerequest",
display: "popup",
}),
})

return addQueryParams(baseUrl, {
...sharedQueryParams,
...customQueryParams,
})
}

To integrate an object with query parameters into a url, we employ the addQueryParams function from ReactKit:

export const addQueryParams = (
baseUrl: string,
params: Record<string, string | number | boolean>
) => {
const query = Object.entries(params)
.map((pair) => pair.map(encodeURIComponent).join("="))
.join("&")

return [baseUrl, query].join(baseUrl.includes("?") ? "&" : "?")
}

Handling OAuth Redirect

After signing in on Google or Facebook login pages, the user will be redirected back to our app as specified by the redirect_uri parameter.

pages

These pages use the same component to manage the OAuth redirect. We simply pass unique provider names to these pages so that they can be pre-rendered with a title. This eliminates the slight layout shift between a pre-rendered page and a page that is rendered on the client side after extracting the provider name from the route.

import { OAuthContent } from "auth/components/OAuthContent"
import { makeAuthPage } from "layout/makeAuthPage"

export default makeAuthPage(() => <OAuthContent provider={"facebook"} />)

The OAuthContent components extract the code from the query params and send it to our API in combination with the provider name and redirect uri. The timeZone parameter is specific to Increaser. While awaiting the API response, we display a spinner within the same container we had on the sign-in and sign-up pages for visual consistency.

import { useCallback } from "react"
import { useHandleQueryParams } from "navigation/hooks/useHandleQueryParams"
import { AuthView } from "./AuthView"
import { getCurrentTimezoneOffset } from "@increaser/utils/time/getCurrentTimezoneOffset"
import { OAuthProvider } from "@increaser/api-interface/client/graphql"
import { getOAuthRedirectUri } from "auth/utils/oauth"
import { oauthProviderNameRecord } from "auth/oauthProviderNameRecord"
import { AuthConfirmationStatus } from "./AuthConfirmationStatus"
import { QueryApiError } from "api/useApi"
import { useAuthenticateWithOAuthMutation } from "auth/hooks/useAuthenticateWithOAuthMutation"

interface OAuthParams {
code: string
}

interface OAuthContentProps {
provider: OAuthProvider
}

export const OAuthContent = ({ provider }: OAuthContentProps) => {
const { mutate: authenticate, error } = useAuthenticateWithOAuthMutation()

useHandleQueryParams<OAuthParams>(
useCallback(
({ code }) => {
authenticate({
provider,
code,
redirectUri: getOAuthRedirectUri(provider),
timeZone: getCurrentTimezoneOffset(),
})
},
[authenticate, provider]
)
)

return (
<AuthView title={`Continue with ${oauthProviderNameRecord[provider]}`}>
<AuthConfirmationStatus error={error as QueryApiError | undefined} />
</AuthView>
)
}

Users can only be in one of two states on this page: either waiting for the API response or dealing with an error. Therefore, the AuthConfirmationStatus component will show a spinner or display an error message that prompts the user to try again by returning to the sign-in page.

import { CopyText } from "@increaser/ui/ui/CopyText"
import { Spinner } from "@increaser/ui/ui/Spinner"
import { VStack } from "@increaser/ui/ui/Stack"
import { Text } from "@increaser/ui/ui/Text"
import { Button } from "@increaser/ui/ui/buttons/Button"
import { InfoIcon } from "@increaser/ui/ui/icons/InfoIcon"
import { QueryApiError } from "api/useApi"
import Link from "next/link"
import { Path } from "router/Path"
import { AUTHOR_EMAIL } from "shared/externalResources"

interface AuthConfirmationStatusProps {
error?: QueryApiError
}

export const AuthConfirmationStatus = ({
error,
}: AuthConfirmationStatusProps) => {
return (
<VStack alignItems="center" gap={20}>
<Text
style={{ display: "flex" }}
color={error ? "alert" : "regular"}
size={80}
>
{error ? <InfoIcon /> : <Spinner />}
</Text>
{error ? (
<>
<Text style={{ wordBreak: "break-word" }} centered height="large">
{error.message}
</Text>
<Link style={{ width: "100%" }} href={Path.SignIn}>
<Button kind="secondary" style={{ width: "100%" }} size="l">
Go back
</Button>
</Link>
<Text centered color="supporting" size={14}>
Nothing helps? Email us at <br />
<CopyText color="regular" as="span" content={AUTHOR_EMAIL}>
{AUTHOR_EMAIL}
</CopyText>
</Text>
</>
) : null}
</VStack>
)
}

There’s only one type of message that makes sense to the user, and I’ll explain this in the back-end code section. All other errors are meant for us developers to identify what went wrong. That’s why we include a support contact in case the user is stuck.

Error view at Increaser

A crucial point to note here is that I disable React strict mode. Otherwise, when we run the app locally, it will purposefully cause the re-rendering of the component, resulting in a double request to the API. To disable React strict mode, we add reactStrictMode: false to the next.config.js file. If you have an efficient solution for keeping React strict mode enabled while preventing a second request, please let me know.

const nextConfig = {
reactStrictMode: false,
// ...
}

I use the useHandleQueryParams hook from ReactKit because the query parameter will be missing on the first render. We wait for the query params to be available before sending the request to the API.

import { useRouter } from "next/router"
import { useEffect } from "react"

type QueryParamsHandler<T> = (params: T) => void

export const useHandleQueryParams = <T>(handler: QueryParamsHandler<T>) => {
const { isReady, query } = useRouter()

useEffect(() => {
if (!isReady) return

handler(query as unknown as T)
}, [isReady, query, handler])
}

The parsed query derived from the useRouter will have a type where all the values are optional. Due to this, we need to assert the type to inform TypeScript that the query params will indeed be present. We prefer not to handle cases where the user manipulates query params. If this happens, it's better to let the app crash.

In the useAuthenticateWithOAuthMutation hook, we execute a GraphQL request to our API and update the session by invoking the updateSession function from the useAuthSession hook.

import { graphql } from "@increaser/api-interface/client"
import { useMutation } from "react-query"

import { useApi } from "api/useApi"
import { AuthSessionWithOAuthInput } from "@increaser/api-interface/client/graphql"
import { useAuthSession } from "./useAuthSession"

const authSessionWithOAuthDocument = graphql(`
query authSessionWithOAuth($input: AuthSessionWithOAuthInput!) {
authSessionWithOAuth(input: $input) {
token
expiresAt
isFirst
}
}
`)

export const useAuthenticateWithOAuthMutation = () => {
const { query } = useApi()

const [, updateSession] = useAuthSession()

return useMutation(async (input: AuthSessionWithOAuthInput) => {
const { authSessionWithOAuth } = await query(authSessionWithOAuthDocument, {
input,
})

updateSession(authSessionWithOAuth)
})
}

We store the auth session in local storage. For more information about managing local storage effectively, check out this post.

import { AuthSession } from "@increaser/api-interface/client/graphql"
import { analytics } from "analytics"
import { useCallback } from "react"
import { useQueryClient } from "react-query"
import { PersistentStateKey, usePersistentState } from "state/persistentState"

export const useAuthSession = () => {
const queryClient = useQueryClient()

const [session, setSession] = usePersistentState<AuthSession | undefined>(
PersistentStateKey.AuthSession,
undefined
)

const onChange = useCallback(
(session: AuthSession | undefined) => {
if (session) {
analytics.trackEvent("Finish identification")

if (session.isFirst) {
analytics.trackEvent("Finish Sign Up")
}
} else {
queryClient.clear()
}

setSession(session)
},
[queryClient, setSession]
)

return [session, onChange] as const
}

Since the useAuthSession hook is the only mechanism through which we update the session, we can wrap the setSession function and incorporate analytics events for a successful authentication and a call to clear the cache when the user signs out.

The session comprises a JWT token, expiration date, and a flag indicating whether it’s the user’s first sign in. Currently, I don’t use the expiration date, but if it’s suitable for your app, you can check the expiration time on app startup and auto-sign out the user if the token is about to expire. This prevents it from happening in the middle of an important user interaction.

For authenticated requests to the API, we retrieve the token from the useAuthSession hook and add it to the Authorization header in the useApi hook.

import { ApiErrorCode } from "@increaser/api/errors/ApiErrorCode"
import { shouldBeDefined } from "@increaser/utils/shouldBeDefined"
import { TypedDocumentNode } from "@graphql-typed-document-node/core"
import { print } from "graphql"
import { useAuthSession } from "auth/hooks/useAuthSession"

interface ApiErrorInfo {
message: string
extensions: {
code: string
}
}

interface ApiResponse<T> {
data: T
errors: ApiErrorInfo[]
}

export class ApiError extends Error {}

class HttpError extends Error {
public status: number

constructor(status: number, message: string) {
super(message)
this.status = status
}
}

export type QueryApiError = ApiError | HttpError

export type Variables = Record<string, unknown>

export type QueryApi = <T, V extends Variables = Variables>(
document: TypedDocumentNode<T, V>,
variables?: V
) => Promise<T>

export const useApi = () => {
const [authSession, setAuthSession] = useAuthSession()

const headers: HeadersInit = {
"Content-Type": "application/json",
}
if (authSession) {
headers.Authorization = authSession.token
}

const query: QueryApi = async <T, V>(
document: TypedDocumentNode<T, V>,
variables?: V
) => {
const apiUrl = shouldBeDefined(process.env.NEXT_PUBLIC_API_URL)

const response = await window.fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify({
query: print(document),
variables,
}),
})

if (!response.ok) {
throw new HttpError(response.status, response.statusText)
}

const { data, errors } = (await response.json()) as ApiResponse<T>

if (errors?.length) {
const { message, extensions } = errors[0]
if (extensions?.code === ApiErrorCode.Unauthenticated) {
setAuthSession(undefined)
}

throw new ApiError(message)
}

return data
}

return { query }
}

Implementing OAuth on the NodeJS Back-end

On the server side, we have a query named authSessionWithOAuth. It takes provider, code, redirectUri, and a parameter specific to Increaser, timeZone. It returns the auth session with a JWT token, the expiration time, and a sign that shows if it's the user's first sign-in, indicating that they just registered in the app. This can be used to trigger an onboarding flow on the front-end, for example.

type AuthSession {
token: String!
expiresAt: Int!
isFirst: Boolean
}

input AuthSessionWithOAuthInput {
provider: OAuthProvider!
code: String!
redirectUri: String!
timeZone: Int!
}

type Query {
authSessionWithOAuth(input: AuthSessionWithOAuthInput!): AuthSession!
}

The query’s implementation consists of two parts: the user is first authenticated with Google or Facebook, and then authorized in our app.

import { OperationContext } from "../../gql/OperationContext"
import { QueryResolvers } from "../../gql/schema"
import { authenticateWithOAuth } from "../utils/authenticateWithOAuth"
import { authorize } from "../utils/authorize"

export const authSessionWithOAuth: QueryResolvers<OperationContext>["authSessionWithOAuth"] =
async (
_,
{ input: { timeZone, provider, redirectUri, code } },
{ country }
) => {
const result = await authenticateWithOAuth({
provider,
redirectUri,
code,
})

return authorize({
timeZone,
country,
...result,
})
}

In the authenticateWithOAuth function, we first validate the code from the front-end by acquiring the access token from Google or Facebook.

import { capitalizeFirstLetter } from "@increaser/utils/capitalizeFirstLetter"
import { OAuthProvider } from "../../gql/schema"
import { AuthenticationResult } from "./AuthenticationResult"
import { getOAuthAccessToken } from "./getOAuthAccessToken"
import { getOAuthUserInfo } from "./getOAuthUserInfo"
import { AuthenticationError } from "../../errors/AuthenticationError"

interface AuthenticateWithOAuthParams {
provider: OAuthProvider
code: string
redirectUri: string
}

export const authenticateWithOAuth = async ({
provider,
code,
redirectUri,
}: AuthenticateWithOAuthParams): Promise<AuthenticationResult> => {
const accessToken = await getOAuthAccessToken({
provider,
code,
redirectUri,
})

const { email, name } = await getOAuthUserInfo({
provider,
accessToken,
})

if (!email) {
throw new AuthenticationError(
`Your ${capitalizeFirstLetter(
provider
)} account doesn't provide an email. Please try a different authentication method.`
)
}

return { email, name }
}

We employ the match function again to pair the provider with the appropriate request. Although we use different requests for the providers, we expect the same response – an access token – from both. On the server side, we access secrets by using a getSecret function. You will implement this differently based on your infrastructure. Perhaps you can safely use environment variables, or use AWS Secrets Manager in the case of AWS, which I covered in this post.

import { match } from "@increaser/utils/match"
import { OAuthProvider } from "../../gql/schema"
import { addQueryParams } from "@increaser/utils/addQueryParams"
import { assertEnvVar } from "../../shared/assertEnvVar"
import { queryOAuthProvider } from "./queryOAuthProvider"
import { getSecret } from "../../utils/getSecret"

interface GetOAuthAccessTokenParams {
provider: OAuthProvider
code: string
redirectUri: string
}

interface TokenResponse {
access_token: string
}

const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
const FACEBOOK_TOKEN_URL = "https://graph.facebook.com/v4.0/oauth/access_token"

export const getOAuthAccessToken = async ({
provider,
code,
redirectUri,
}: GetOAuthAccessTokenParams) => {
const actionName = `get ${provider} access token`
const response = await match(provider, {
google: async () =>
queryOAuthProvider<TokenResponse>(actionName, GOOGLE_TOKEN_URL, {
method: "POST",
body: JSON.stringify({
client_id: assertEnvVar("GOOGLE_CLIENT_ID"),
client_secret: await getSecret("GOOGLE_CLIENT_SECRET"),
redirect_uri: redirectUri,
grant_type: "authorization_code",
code,
}),
}),
facebook: async () =>
queryOAuthProvider<TokenResponse>(
actionName,
addQueryParams(FACEBOOK_TOKEN_URL, {
client_id: assertEnvVar("FACEBOOK_CLIENT_ID"),
client_secret: await getSecret("FACEBOOK_CLIENT_SECRET"),
redirect_uri: redirectUri,
code,
})
),
})

return response.access_token
}

We query Google and Facebook APIs using a simple fetch wrapper titled queryOAuthProvider. This will throw an AuthenticationError if the response status isn't 200. We do not focus much on formatting the error because it won't make sense to the user. We attach the action name to the error message to understand the step in the authentication flow that failed.

import fetch from "node-fetch"
import { AuthenticationError } from "../../errors/AuthenticationError"

export const queryOAuthProvider = async <T>(
action: string,
...args: Parameters<typeof fetch>
) => {
const response = await fetch(...args)

if (!response.ok) {
let message = response.statusText
try {
message = await response.text()
} catch (err) {}

throw new AuthenticationError(`${action} failed: ${message}`)
}

return (await response.json()) as T
}

Once we have the access token, we can retrieve the user info from Google or Facebook. Here, we apply the same pattern as in the previous function.

import { match } from "@increaser/utils/match"
import { OAuthProvider } from "../../gql/schema"
import { addQueryParams } from "@increaser/utils/addQueryParams"
import { queryOAuthProvider } from "./queryOAuthProvider"

interface GetOAuthUserInfoParams {
accessToken: string
provider: OAuthProvider
}

interface UserInfoResponse {
email?: string
name?: string
}

const GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
const FACEBOOK_USER_INFO_URL = "https://graph.facebook.com/me"

export const getOAuthUserInfo = async ({
accessToken,
provider,
}: GetOAuthUserInfoParams) => {
const actionName = `get ${provider} user info`

return match(provider, {
google: async () =>
queryOAuthProvider<UserInfoResponse>(actionName, GOOGLE_USER_INFO_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
}),
facebook: async () =>
queryOAuthProvider<UserInfoResponse>(
actionName,
addQueryParams(FACEBOOK_USER_INFO_URL, {
fields: ["email", "name"].join(","),
access_token: accessToken,
})
),
})
}

It should be noted that having a Facebook account doesn’t necessarily mean a user has an email, because they might have signed up for Facebook using a phone number. Although this situation is quite unlikely, I had a few instances with Increaser. While it’s possible to implement an app that does not require a user to have an email, I chose to make email a mandatory field to avoid unnecessary concerns.

import { getUserByEmail, putUser } from "@increaser/db/user"
import { AuthSession } from "../../gql/schema"
import { AuthenticationResult } from "./AuthenticationResult"
import { getAuthSession } from "./getAuthSession"
import { getUserInitialFields } from "@increaser/entities-utils/user/getUserInitialFields"

interface AuthorizeParams extends AuthenticationResult {
timeZone: number
country?: string
}

export const authorize = async ({
email,
name,
country,
timeZone,
}: AuthorizeParams): Promise<AuthSession> => {
const existingUser = await getUserByEmail(email, ["id"])
if (existingUser) {
return getAuthSession(existingUser.id)
}

const newUser = getUserInitialFields({
email,
name,
country,
timeZone,
})

await putUser(newUser)

return {
...getAuthSession(newUser.id),
isFirst: true,
}
}

Once we’ve authenticated the user with the OAuth provider, we can authorize them in our app. First, we check if a user with the given email already exists in our database. If it does, we return the auth session. Otherwise, we create a new user and return the auth session with a flag indicating it’s the user’s first sign-in.

import { convertDuration } from "@increaser/utils/time/convertDuration"
import { AuthSession } from "../../gql/schema"
import jwt from "jsonwebtoken"
import { getSecret } from "../../utils/getSecret"

const tokenLifespanInDays = 300

export const getAuthSession = async (id: string): Promise<AuthSession> => {
const expiresAt = Math.round(
convertDuration(Date.now(), "ms", "s") +
convertDuration(tokenLifespanInDays, "d", "s")
)
const secret = await getSecret("SECRET")
const token = jwt.sign({ id, exp: expiresAt }, secret)

return {
token,
expiresAt,
}
}

We use getAuthSession to construct an object with a JWT token and expiration time. We use the secret to sign the token and set the lifespan for 300 days. We rely on another function from ReactKit to convert units.