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 RadzionKit 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.
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.
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 RadzionKit. 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>
)
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 RadzionKit 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 RadzionKit:
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("?") ? "&" : "?")
}
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.
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.
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 RadzionKit 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 }
}
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 RadzionKit to convert units.