Open cekpowell opened 6 months ago
I'm seeing a similar issue. In my case, I'm trying to listen for the tokenRefresh
event, and I've set up my Hub listener inside a useEffect
with an empty dependency list. Despite my tokens having a very short expiration (5 minutes), the tokenRefresh
event never fires. I think we might have a similar underlying cause.
@amoffat could be the case. I would imagine this issue is effecting all events rather than just the signInWithRedirect
that we are wanting.
hello @cekpowell . Did you try placing Hub
inside a useEffect
in the layout file of your next.js App ?
Hey @israx - does it specifically have to be the root layout for the app? Mine is a server component and I was under the impression making it a client component would be bad practice. Is this the setup you use?
Anyway, I tested it with a layout
file for the signIn
page and still no difference - the Hub is still not catching any events.
Hub was design to be used in SPA applications, meaning that Hub events will be dispatched in the same window object where it was initiated. So if Hub is initialized at the top of the element tree, then Amplify APIs called in any children components will be able to trigger the dispatch of events.
If you are calling an API on the server, and initializing Hub on the client, that would not work because there are 2 different instances of Hub.
I'm using Hub client side in an SPA. Here's what I noticed: the auth
event with the tokenRefreshed
payload is only received by Hub when I call fetchAuthSession
. Apparently this triggers Amplify to realize that the token is expired, which in turn fires the Hub event. But I was under the impression that the token refreshes would happen out-of-band, and that I could respond to those asynchronously through Hub, without triggering Amplify via fetchAuthSession
Thanks @israx. I have tried placing the Hub in my AuthProvider
which sits at the top of my app, and im my root layout.tsx
file and im still finding that if the Hub is placed inside a useEffect
, no events are being fired.
Should the Hub work in this case? I need to listen to auth events so I can update state, and I can only update state inside a useEffect
.
@amoffat the library will check and emit the tokenRefresh
event only when the access token is expired and the fetchAuthSession
API is called. In order to support your use case the library needs to setup a listener and call fetchAuthSession
when tokens are expired. Do you mind opening a feature request with us to keep track of this feature ?
@cekpowell could you share code snippets and show how you are setting up Hub and what APIs you are using to trigger the dispatch of events ?
Sure thing:
I have an AuthProvider
component which provides auth state/update methods. it also has a Hub defined in a useEffect which should be able to listen to auth events. Here is a slimmed down version of it:
'use client'
import { decodeCognitoIdToken } from '@/utils/jwt'
import { AppSessionStorage, AppSessionStorageKeys } from '@/utils/sessionStorage'
import { Amplify } from 'aws-amplify'
import {
signInWithRedirect as amplifySignInViaSocial,
AuthError,
fetchAuthSession,
JWT,
} from 'aws-amplify/auth'
import {Hub} from 'aws-amplify/utils'
import { createContext, ReactNode, useCallback, useContext, useState } from 'react'
/**
* * ------------------------------------------------------------------------
* * MARK: Amplify Initialization
* * ------------------------------------------------------------------------
*/
Amplify.configure(AMPLIFY_CONFIG, { ssr: true })
/**
* * ------------------------------------------------------------------------
* * MARK: Types
* * ------------------------------------------------------------------------
*/
/** Supported providers for social sign-in in the app. */
export type AppSocialSignInProviders = 'Google' | 'Facebook' | 'Apple'
export interface SignInSocialParams {
/** Social provider to sign the user into the app with. */
provider: AppSocialSignInProviders
}
export enum SignInViaSocialErrors {
/** Unknown error occured (i.e., error we do not account for). Can also occur if theres no internet connection. */
unknown = 'unknown',
}
/**
* * ------------------------------------------------------------------------
* * MARK: Provider
* * ------------------------------------------------------------------------
*/
export interface AppUserDetails {
/** User's ID */
userId: string
/** User's email */
email: string
/** User's name */
name: string
/** URL to user's profile picture */
picture?: string
/** Access Token (JWT) */
accessToken: JWT
/** idToken (JWT) */
idToken: JWT
}
export interface AuthContextType {
/**
* Data
*/
/** Is there a user currently signed in? */
isSignedIn: boolean
/** `AppUserDetails` for currently signed in user. Undefined if no user is signed in. */
userDetails?: AppUserDetails
/**
* Operations
*/
/** Refreshes the user's auth state. */
refreshAuthState: () => Promise<void>
/** Sign the user in/up to the app via a social provider. */
signInViaSocial: (params: SignInSocialParams) => Promise<void>
}
export const AuthContext = createContext<AuthContextType>({
// data
isSignedIn: false,
userDetails: undefined,
// operations
refreshAuthState: async () => {},
signInViaSocial: async () => {},
})
interface AuthProviderProps {
/** Clear the cache of the `AuthProvider` on intialisation? */
clearCache?: boolean
/** Provider children */
children?: ReactNode
}
/**
* Provides and manages the authentication state of the app.
*/
export const AuthProvider = ({ children }: AuthProviderProps) => {
// getting auth state via `useAuthProvider` hook.
const auth = useAuthProvider()
// provider
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
}
/**
* Hook that provides and manages the authentication state. We define this hook
* to save defining all of the auth logic inside the `AuthProvider` component.
*/
const useAuthProvider = () => {
/**
* Data
*/
// is the user signed in?
const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
// details for currently signed in user
const [userDetails, setUserDetails] = useState<AppUserDetails | undefined>(undefined)
useEffect(() => {
const unsubscribe = Hub.listen('auth', async ({payload}) => {
console.log(payload)
})
return unsubscribe
}, [])
/**
* Operations
*/
/**
* Refresh Auth Context
*/
/**
* Refreshes the auth context based on the current auth session.
*
* Should be called whenever the users auth state changes and we need to update
* our context - e.g., sign in, sign out, etc.
*/
const refreshAuthState = useCallback(async () => {
// fetching user tokens
const authSession = await fetchAuthSession()
const accessToken = authSession.tokens?.accessToken
const idToken = authSession.tokens?.idToken
const userAttributes = idToken && decodeCognitoIdToken(idToken.toString())
// if user is signed in -> lets update state with user info
if (accessToken && idToken && userAttributes) {
setIsSignedIn(true)
setUserDetails({
userId: userAttributes.preferred_username,
email: userAttributes.email,
name: userAttributes.name,
picture: userAttributes.picture,
accessToken: accessToken,
idToken: idToken,
})
}
// if user is not signed in -> lets clear the state
if (!accessToken || !idToken || !userAttributes) {
setIsSignedIn(false)
setUserDetails(undefined)
}
}, [])
/**
* Sign Up
*/
/**
* Sign in via Social
*/
/**
* Sign the user in/up to the app via a social provider.
*
* **NOTE**: This method will re-direct the user to the social provider's login
* page, and they will then be send back to the app. You must handle this re-direct
* back to the app in order to complete the sign-in/sign-up.
*/
const signInViaSocial = async ({ provider }: SignInSocialParams) => {
try {
// setting the provider into storage (so we can access it after the re-direct)
AppSessionStorage.setItem(AppSessionStorageKeys.ACTIVE_SOCIAL_PROVIDER, provider)
// signing in via social
await amplifySignInViaSocial({
provider: provider,
customState: 'my-custom-state',
})
} catch (e) {
// cleaning up session storage
AppSessionStorage.removeItem(AppSessionStorageKeys.ACTIVE_SOCIAL_PROVIDER)
// throwing custom error
throw new Error(SignInViaSocialErrors.unknown)
}
}
/**
* Returning auth state.
*/
return {
// data
isSignedIn,
userDetails,
// operations
refreshAuthState,
signInViaSocial,
}
}
/**
* * ------------------------------------------------------------------------
* * MARK: Hooks
* * ------------------------------------------------------------------------
*/
/**
* Returns the Auth context for the app.
*/
export const useAuth = () => {
return useContext(AuthContext)
}
This AuthProvider
is then wrapped around my RootLayout
component like so:
const RootLayout = async ({
children,
}: Readonly<{
children: React.ReactNode
}>) => {
// fetching app locale using next-intl
const locale = await getLocale()
return (
<html lang={locale} suppressHydrationWarning>
{/* remove default margin */}
<body style={{ margin: 0 }}>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
)
}
export default RootLayout
Finally, in my Sign In screen, I am calling the signInViaSocial
method from the auth provider for one of Google
, Facebook
and Apple
based on what button the user presses. This functionality works fine - I can log in, be redirected back to the app, its just that no event is fired from the Hub.
@amoffat the library will check and emit the
tokenRefresh
event only when the access token is expired and thefetchAuthSession
API is called. In order to support your use case the library needs to setup a listener and callfetchAuthSession
when tokens are expired. Do you mind opening a feature request with us to keep track of this feature ?
Gotcha, so my Hub issue is unrelated to this issue then. I'll open the feature request.
@cwomack @israx - just a thought here. It seems the underlying issue is that the app is unmounted following signInWithRedirect
, and thus the Hub
is removed. Is it possible to perform the signInWithRedirect
in a new tab to prevent this?
Yes you can signIn in a different tab as long as Hub.listen
was called before signInWithRedirect
API and the listener is still active. Probably you can stop the listener after a successful event and not on a component unmount
@cekpowell, did the comments from @israx help unblock or clear the issue up here? Let us know if there is anything else that we can help with.
Hi @cwomack - unfortunately not - this issue is still blocking me, and I have resulted to using a combination of a useEffect
and local storage data.
Are you able to confirm if this is in-fact a bug with the SDK? It seems like a very crucial part of functionality that isn't working properly.
Hi, I hope my comments are useful as this is new to me. However, following this tutorial https://www.youtube.com/watch?v=wcSMnICY-_8 I encounter the same problem! Using the code from the github library specified in the video i.e. https://github.com/ErikCH/CodeFirstTypeSafety I can get it working, but effectively I'm regressing to the previous version specified in the video. And believing its a version problem, here's the diff of my two package.json's
< "name": "NOT WORKING,
---
> "name": "WORKING",
12,15c12,15
< "@aws-amplify/adapter-nextjs": "^1.2.7",
< "@aws-amplify/ui-react": "^6.1.13",
< "aws-amplify": "^6.4.0",
< "next": "14.2.5",
---
> "@aws-amplify/adapter-nextjs": "^1.0.8",
> "@aws-amplify/ui-react": "^6.0.7",
> "aws-amplify": "^6.4.3",
> "next": "14.0.4",
21c21
< "@aws-amplify/backend-cli": "^1.2.0",
---
> "@aws-amplify/backend-cli": "^1.2.1",
25,26c25,27
< "aws-cdk": "^2.149.0",
< "aws-cdk-lib": "^2.149.0",
---
> "autoprefixer": "^10.0.1",
> "aws-cdk": "^2.150.0",
> "aws-cdk-lib": "^2.150.0",
30c31
< "eslint-config-next": "14.2.5",
---
> "eslint-config-next": "14.0.4",
32c33
< "tailwindcss": "^3.4.1",
---
> "tailwindcss": "^3.3.0",
34c35
< "typescript": "^5.5.3"
---
> "typescript": "^5.5.4"
Hope this helps! C.
P.S. This turned out to be a caching problem; clearing the "token" cache in the browser, resolved the problem and to be fair the video explains this ...
The same issue is happening to me with the same setup. Has this been verified as a real bug, or are we doing something wrong with the setup? I was blocked for a few days with this and had to opt for manually handling it. It would be nice to have an official guide, though.
I've spent a couple of days chasing this down. I see a race condition where the oAuth flow is run before Hub.listen
is called, so we miss the events that have been dispatched.
I feel this is worse in SSR apps like Next.js, and more prevalent in production than dev.
Typical example code
// ConfigureAmplify.tsx
import { useCognitoHub } from './auth';
export ConfigureAmplify () => {
useCognitoHub();
return null;
};
// layout.tsx
const RootLayout = async ({ children }: Readonly<{ children: React.ReactNode;}>) => (
<html lang="en">
<body>
<ConfigureAmplifyClientSide />
<Providers>{children}</Providers>
</body>
</html>
);
};
// auth.ts
export const useCognitoHub = (setIsAuthLoading: (value: boolean) => void) => {
useEffect(() => {
const removeListener = Hub.listen('auth', async ({ payload }) => {
const { event } = payload;
switch (event) {
// DO IMPORTANT TUFF
}
}, []);
return removeListener;
});
};
I'm working around this issue by delaying the oAuth processing till the listener is read.
First this patch
// NOTE: We need to delay oAuth on the web till we are ready
diff --git a/dist/esm/singleton/Amplify.d.ts b/dist/esm/singleton/Amplify.d.ts
index 24136eff86d96e951991844ec24537c342f13a4a..b96746d046f66b42b8fc77bdc20d71728908af5e 100644
--- a/dist/esm/singleton/Amplify.d.ts
+++ b/dist/esm/singleton/Amplify.d.ts
@@ -34,7 +34,7 @@ export declare class AmplifyClass {
getConfig(): Readonly<ResourcesConfig>;
/** @internal */
[ADD_OAUTH_LISTENER](listener: (authConfig: AuthConfig['Cognito']) => void): void;
- private notifyOAuthListener;
+ notifyOAuthListener(): void;
}
/**
* The `Amplify` utility is used to configure the library.
diff --git a/dist/esm/singleton/Amplify.mjs b/dist/esm/singleton/Amplify.mjs
index 2a19a00a10bb68a0b3d4898b34b58c42f2910928..9db400e5e235c77bc27f0ce3f94b9b5ad93ccced 100644
--- a/dist/esm/singleton/Amplify.mjs
+++ b/dist/esm/singleton/Amplify.mjs
@@ -53,7 +53,7 @@ class AmplifyClass {
event: 'configure',
data: this.resourcesConfig,
}, 'Configure', AMPLIFY_SYMBOL);
- this.notifyOAuthListener();
+ // this.notifyOAuthListener();
}
/**
* Provides access to the current back-end resource configuration for the Library.
Then I modify
// auth.ts
export const useCognitoHub = (setIsAuthLoading: (value: boolean) => void) => {
useEffect(() => {
const removeListener = Hub.listen('auth', async ({ payload }) => {
const { event } = payload;
switch (event) {
// DO IMPORTANT TUFF
}
});
// NOTE: We have a small hack to remove the listner race condition
// https://github.com/aws-amplify/amplify-js/issues/13436
Amplify.notifyOAuthListener();
return removeListener;
};
},. []);
};
I think long term we need to be able to pass an option to Amplify.configure e.g. `Amplify.configure(outputs, { ssr: true, delayOAuth: true });
Hi @johnf you are correct about the potential race condition, and thank you for providing this work around.
Since Hub.listen()
is called within useEffect()
would move Amplify.configure()
call into the same useEffect()
after the call of Hub.listen()
resolve the race condition? Since the Amplify.configure()
is execute on mount of the root layout, I believe the configuration can be applied through out the client life cycle.
In addition, I am interested in what's your opinion on the following:
Today Amplify JS fires an OAuth listener as early as possible to complete an OAuth flow. What if we change the approach, instead of a eager listener, we provide a callable API, say completeInflightOAuthFlow()
, you can call it as needed, so you will have control on the timing.
@HuiSF I'll give moving Amplify.configure()
a try, I think in theory it would work fine for my use case since I don't do anything with amplify before then.
Today Amplify JS fires an OAuth listener as early as possible to complete an OAuth flow. What if we change the approach, instead of a eager listener, we provide a callable API, say
completeInflightOAuthFlow()
, you can call it as needed, so you will have control on the timing.
I think this would work well. The current default behaviour is probably fine for most folks (e.g. there are no issues in react native due to the nature of the platform), but being able to disable it and use the callable API would be perfect.
Before opening, please confirm:
JavaScript Framework
Next.js
Amplify APIs
Authentication
Amplify Version
v6
Amplify Categories
auth
Backend
Other
Environment information
Describe the bug
TLDR
If i place a
Hub.listen
into auseEffect
inside a component, theHub
never fires any events, and so I cannot handle the events and update my state accordingly. If i take theHub
out of theuseEffect
, it correctly fires, but I cannot update component state from outside of theuseEffect
as the component has not yet mounted.Full Description & Context
I have a NextJS app that uses the Amplify SDK to handle Authentication. I am not using the Amplify CLI, but connecting to an existing AWS backend I have defined.
I have implemented sign in via social providers (Google, Apple and Facebook), and I am successfully re-directed to the provider, able to sign in, and be re-directed back to the app. Now, I would like to listen for the redirect auth event in my app so that I can update my system auth state (managed in a context) with the user's details, or handle errors if there are any, and then re-direct the user to the next screen.
As per the docs, I am trying to do this with a
Hub.listen
inside auseEffect
in the page component. However, no event is ever fired when I am re-directed back to the app. If I take theHub.listen
out of auseEffect
, or out of the component all together, then the event is fired, but I am no longer able to update my system context (as this must be done inside a component after mounting).I am guessing the event is not firing inside
useEffect
because the app is unmounted when the redirect takes place, but then this raises the question of how I am meant to listen to re-direct auth events using the hub?I have found this existing issue which has been closed, but recent comments suggest others are still facing this problem.
Expected behavior
The
Hub
listener should fire an event forsignInWithRedirect
.Reproduction steps
Hub
listener which just logs the incoming payload, as done in the docs here. Also, place aHub
listener outside of theuseEffect
.signInWithRedirect
, and when redirected back to the app after sign in, the hub in theuseEffect
will not fire any events, but the one inside theuseEffect
will.Code Snippet
Log output
aws-exports.js
No response
Manual configuration
No response
Additional configuration
No response
Mobile Device
No response
Mobile Operating System
No response
Mobile Browser
No response
Mobile Browser Version
No response
Additional information and screenshots
No response