Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "jest"
"test": "jest",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@types/node": "^20.3.1",
Expand Down
40 changes: 31 additions & 9 deletions src/client/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
'use client'

import React, { useCallback, useEffect, useReducer } from 'react'
import { doesLocalStorageMatch, hasWindow, isEqual, saveUserToLocalStorage, USER_INFO_KEY } from './utils'
import { useRouter } from 'next/navigation.js'
import { User } from './useUser'
import React, { useCallback, useEffect, useReducer, useState } from 'react'
import { toOrgIdToOrgMemberInfo } from '../user'
import { User } from './useUser'
import {
currentTimeSecs,
doesLocalStorageMatch,
hasWindow,
isEqual,
saveUserToLocalStorage,
USER_INFO_KEY,
} from './utils'

export interface RedirectToSignupOptions {
postSignupRedirectPath?: string
Expand Down Expand Up @@ -46,9 +53,12 @@ interface InternalAuthState {
setActiveOrg: (orgId: string) => Promise<User | undefined>
}

const DEFAULT_MIN_SECONDS_BEFORE_REFRESH = 120

export type AuthProviderProps = {
authUrl: string
reloadOnAuthChange?: boolean
minSecondsBeforeRefresh?: number
children?: React.ReactNode
refreshOnFocus?: boolean
}
Expand All @@ -74,7 +84,7 @@ type AuthState = {
authChangeDetected: boolean
}

const initialAuthState = {
const initialAuthState: AuthState = {
loading: true,
userAndAccessToken: {
user: undefined,
Expand Down Expand Up @@ -129,7 +139,9 @@ function authStateReducer(_state: AuthState, action: AuthStateAction): AuthState
}

export const AuthProvider = (props: AuthProviderProps) => {
const minSecondsBeforeRefresh = props.minSecondsBeforeRefresh ?? DEFAULT_MIN_SECONDS_BEFORE_REFRESH
const [authState, dispatchInner] = useReducer(authStateReducer, initialAuthState)
const [lastRefresh, setLastRefresh] = useState<number>()
const router = useRouter()
const reloadOnAuthChange = props.reloadOnAuthChange ?? true

Expand Down Expand Up @@ -157,6 +169,7 @@ export const AuthProvider = (props: AuthProviderProps) => {
const action = await apiGetUserInfo()
if (!didCancel && !action.error) {
dispatch(action)
setLastRefresh(currentTimeSecs())
}
}

Expand Down Expand Up @@ -185,11 +198,20 @@ export const AuthProvider = (props: AuthProviderProps) => {
}
if (!action.error) {
dispatch(action)
setLastRefresh(currentTimeSecs())
} else if (action.error === 'unexpected') {
clearAndSetRetryTimer()
}
}

// If we were offline or on a different tab, when we return, refetch auth info
// Some browsers trigger focus more often than we'd like, so we'll debounce a little here as well
const refreshOnOnlineOrFocus = async function () {
if (!lastRefresh || currentTimeSecs() > lastRefresh + minSecondsBeforeRefresh) {
await refreshToken()
}
}

async function onStorageEvent(event: StorageEvent) {
if (
event.key === USER_INFO_KEY &&
Expand All @@ -203,11 +225,11 @@ export const AuthProvider = (props: AuthProviderProps) => {

if (hasWindow()) {
window.addEventListener('storage', onStorageEvent)
window.addEventListener('online', refreshToken)
window.addEventListener('online', refreshOnOnlineOrFocus)

// Default for refreshOnFocus is true
if (props.refreshOnFocus !== false) {
window.addEventListener('focus', refreshToken)
window.addEventListener('focus', refreshOnOnlineOrFocus)
}
}

Expand All @@ -219,11 +241,11 @@ export const AuthProvider = (props: AuthProviderProps) => {
}
if (hasWindow()) {
window.removeEventListener('storage', onStorageEvent)
window.removeEventListener('online', refreshToken)
window.removeEventListener('focus', refreshToken)
window.removeEventListener('online', refreshOnOnlineOrFocus)
window.removeEventListener('focus', refreshOnOnlineOrFocus)
}
}
}, [dispatch, authState.userAndAccessToken.user])
}, [dispatch, authState.userAndAccessToken.user, lastRefresh])

const logout = useCallback(async () => {
await fetch('/api/auth/logout', {
Expand Down
6 changes: 5 additions & 1 deletion src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export function hasWindow(): boolean {
return typeof window !== 'undefined'
}

export const currentTimeSecs = (): number => {
return Math.floor(Date.now() / 1000)
}

export function saveUserToLocalStorage(user: User | undefined) {
if (user) {
localStorage.setItem(USER_INFO_KEY, JSON.stringify(user))
Expand Down Expand Up @@ -79,4 +83,4 @@ export function isEqual(a: any, b: any): boolean {
// We need to make sure that the comparison is done with objects that have gone through the same transformation, so we mimic the localStorage transformation to json and back
function jsonSerialize(userFromToken: UserFromToken) {
return JSON.parse(JSON.stringify(userFromToken))
}
}
13 changes: 7 additions & 6 deletions src/server/app-router-index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
export { UnauthorizedException, ConfigurationException } from './exceptions'
export {
authMiddleware,
getAccessToken,
getCurrentPath,
getCurrentUrl,
getRouteHandlers,
getUser,
getUserAndAccessToken,
getUserOrRedirect,
getAccessToken,
authMiddleware,
getCurrentUrl,
getCurrentPath,
} from './app-router'
export type { RouteHandlerArgs, RedirectOptions } from './app-router'
export type { RedirectOptions, RouteHandlerArgs } from './app-router'
export { ConfigurationException, UnauthorizedException } from './exceptions'
27 changes: 24 additions & 3 deletions src/server/app-router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { redirect } from 'next/navigation.js'
import { cookies, headers } from 'next/headers.js'
import { redirect } from 'next/navigation.js'
import { NextRequest, NextResponse } from 'next/server.js'
import { ACTIVE_ORG_ID_COOKIE_NAME } from '../shared'
import { UserFromToken } from './index'
import {
ACCESS_TOKEN_COOKIE_NAME,
CALLBACK_PATH,
Expand All @@ -22,8 +24,6 @@ import {
validateAccessToken,
validateAccessTokenOrUndefined,
} from './shared'
import { UserFromToken } from './index'
import { ACTIVE_ORG_ID_COOKIE_NAME } from '../shared'

export type RedirectOptions =
| {
Expand Down Expand Up @@ -56,6 +56,27 @@ export async function getUser(): Promise<UserFromToken | undefined> {
return undefined
}

export type UserAndAccessToken =
| {
user: UserFromToken
accessToken: string
}
| {
user: undefined
accessToken: never
}

export async function getUserAndAccessToken(): Promise<UserAndAccessToken> {
const accessToken = getAccessToken()
if (accessToken) {
const user = await validateAccessTokenOrUndefined(accessToken)
if (user) {
return { user, accessToken }
}
}
return { user: undefined, accessToken: undefined as never }
}

export function getAccessToken(): string | undefined {
return headers().get(CUSTOM_HEADER_FOR_ACCESS_TOKEN) || cookies().get(ACCESS_TOKEN_COOKIE_NAME)?.value
}
Expand Down