okta-auth-js
okta-auth-js copied to clipboard
Token manager auto renew not working
Describe the bug?
The access token expires without renewal despite having refresh token lifetime set to unlimited. The refresh token and access token expire and the user is redirected to login again. This happens even when refresh token rotation is disabled
What is expected to happen?
The access token should be periodically refreshed until the expiration of the refresh token
What is the actual behavior?
Access Token Lifetime: 5 minutes
Refresh Token Lifetime: unlimited
Refresh token rotation is enabled
Login
09:27:04.420
tokenManager added
event emitted for accessToken and refreshToken, both with the same expiresAt
value
09:31:33.998
tokenManager expired
event emitted for refreshToken
09:31:34.011
Request made to /oauth2/default/v1/token
to fetch new tokens using refresh_token_a
09:31:35.005
tokenManager expired
event emitted for accessToken
09:31:35.791
tokenManager added
event emitted for refreshToken
09:31:35.797
Request made to /oauth2/default/v1/token
to fetch new tokens using refresh_token_a
(Returns the same refreshToken as the request in step 4, but a different accessToken)
09:31:35.005
tokenManager expired
event emitted for accessToken
09:31:36.729
OKTA-AUTH-JS:updateAuthState: Event:added Status:canceled
09:36:05.001
tokenManager expired
event emitted for refreshToken
09:36:05.565
Okta Error Event:
09:36:05.565 OAuthError: The client specified not to prompt, but the user is not logged in.
Babel 2
w 1.chunk.js:250780
n CustomError.ts:13
Be 1.chunk.js:251791
n OAuthError.ts:14
sn handleOAuthResponse.ts:21
sn handleOAuthResponse.ts:56
promise callback*sn handleOAuthResponse.ts:54
un getToken.ts:117
promise callback*un/< getToken.ts:115
promise callback*un getToken.ts:72
cn getWithoutPrompt.ts:21
fn renewToken.ts:18
value PromiseQueue.ts:44
value PromiseQueue.ts:26
value PromiseQueue.ts:25
Yn TokenManager.ts:260
promise callback*Yn TokenManager.ts:252
Xn TokenManager.ts:321
e TokenManager.ts:398
emit index.js:36
a TokenManager.ts:43
a TokenManager.ts:96
sentryWrapped helpers.ts:87
setTimeout handler*./node_modules/ 1.chunk.js:278516
Ln TokenManager.ts:95
Hn TokenManager.ts:134
Xn/t.renewPromise[r]< TokenManager.ts:294
promise callback*Xn TokenManager.ts:321
e TokenManager.ts:398
emit index.js:36
a TokenManager.ts:43
a TokenManager.ts:96
sentryWrapped helpers.ts:87
setTimeout handler*./node_modules/ 1.chunk.js:278516
Ln TokenManager.ts:95
f TokenManager.ts:194
Jn TokenManager.ts:221
e browser.ts:417
u runtime.js:45
_invoke runtime.js:274
09:36:05.998
tokenManager expired
event emitted for accessToken
09:36:06.340
Okta Error Event (Same as above)
09:36:06.536
Redirect to login page
Reproduction Steps?
Set up an application with the following properties Access Token Lifetime: 5 minutes Refresh Token Lifetime: unlimited
Sign in and wait 10 minutes
I added some event listeners to the token manager for better visibility
oktaAuth.tokenManager.on('expired', (a, b, c) => {
console.group('Okta Expired Event');
console.log(a);
console.log(b);
console.log(c);
console.groupEnd();
});
oktaAuth.tokenManager.on('error', (a, b, c) => {
console.group('Okta Error Event');
console.log(a);
console.log(b);
console.log(c);
console.groupEnd();
});
Okta Config:
export const oktaConfig: OktaAuthOptions = {
issuer: `${process.env...}/oauth2/default`,
clientId: process.env...,
redirectUri: `${window.location.origin}/login/callback`,
scopes: ['openid', 'email', 'offline_access'],
tokenManager: {
autoRenew: true,
},
devMode: true,
};
SDK Versions
"@okta/okta-react": "5.1.2" "@okta/okta-auth-js": "4.9.0"
Execution Environment
Firefox 98.0.2 (64-bit)
Additional Information?
Posting this here since the behavior seems to be related to the token manager and our application does not have any custom token refresh behavior
Thanks for the post!
Someone from our team will review this soon.
@jameslessen Please update to the latest version of AuthJS. From the network response you posted, it is clear that the client is falling back to using an iframe/cookie method to refresh tokens (getWithoutPrompt) rather than using the refresh tokens. This was a known issue which was fixed in version 5.2.1. We recommend updating to the latest version to take advantage of all current fixes.
Same here ok 7.0.1 !
@nitrique can you provide a code snippet?
export const oktaAuth = new OktaAuth({
redirectUri: `${ window.location.origin }/login/callback`,
clientId: window.config.OKTA_CLIENT_ID,
pkce: true,
issuer: `${ issuer?.endpoint ?? authIssuers[0].endpoint }/oauth2/default`,
// Enable this to get auth event messages in console
devMode: false,
});
oktaAuth.start();
export async function initAuthentication(): Promise<UserClaims | null> {
const tokenParams: TokenParams = {
scopes: [ "groups", "openid", "profile", "email" ],
};
const accessToken: AccessToken = (await oktaAuth.tokenManager.get(
"accessToken",
)) as AccessToken;
if (!accessToken) {
await oktaAuth.token.getWithRedirect(tokenParams);
}
return accessToken?.claims;
}
initAuthentication();
Some code has been omitted. The okta service never trigger auto renew, we have to do the following:
const interval = setInterval(async () => {
// Renew 60 seconds before expiry
if (claims?.exp && getUnixTime(new Date()) > (claims.exp - 60)) {
oktaAuth.tokenManager.renew("accessToken");
}
}, 15e3);
@nitrique How are you handling the redirect back to your app?
@jaredperreault-okta can you be more specific ?
Redirect only occur on login. On token renew we don't have redirect (classic OIDC flow).
If you are talking about first redirect:
if (oktaAuth.isLoginRedirect()) {
try {
oktaAuth
.handleLoginRedirect()
.then(() => location.href = location.origin);
} catch (e) {
// log or display error details
}
} else {
location.href = location.origin;
}
@nitrique can you try
oktaAuth.tokenManager.on('expired', console.log);
I'm curious if the renew requests are failing or whether the renew process is never triggered
it write "accessToken" on console.
Can you check the network tab to see if any network requests are being made (when renew triggers)
and add the following log line
oktaAuth.tokenManager.on('error', console.log);
to see if token renewal is throwing any errors
Nothing.
I don't have the time to debug the lib sorry, I've implemented my own timer which work ;)
Not sure if this is the same issue but I'm seeing the token renewal working in most of the cases but then quite often not (let's say 60-80% success rate). Here's my config:
new OktaAuth({
issuer: '...',
clientId: '...',
redirectUri: `${window.location.origin}/signin`,
tokenManager: {
expireEarlySeconds: 59 * 60, // I'm using this to get token refresh once a minute
},
devMode: true,
})
I'm using @okta/okta-react
and have a hook like this:
const onTokenRenewal = useCallback((key: string, newToken: Token) => {
console.log(new Date().toISOString(), 'new token', key)
// here I have the code to utilize the new token but omitting it now
}, [])
const onTokenExpire = useCallback((key: string) => {
console.log(new Date().toISOString(), 'expired', key)
}, [])
const onTokenError = useCallback((err: Error) => {
console.log(new Date().toISOString(), 'error', err)
}, [])
const onTokenRemove = useCallback((key: string) => {
console.log(new Date().toISOString(), 'removed', key)
}, [])
const onTokenAdded = useCallback((key: string) => {
console.log(new Date().toISOString(), 'added', key)
}, [])
useEffect(() => {
console.log(new Date().toISOString(), 'start')
oktaAuth.start()
oktaAuth.tokenManager.on('renewed', onTokenRenewal)
oktaAuth.tokenManager.on('expired', onTokenExpire)
oktaAuth.tokenManager.on('error', onTokenError)
oktaAuth.tokenManager.on('removed', onTokenRemove)
oktaAuth.tokenManager.on('added', onTokenAdded)
return () => {
oktaAuth.tokenManager.off('renewed')
oktaAuth.tokenManager.off('expired')
oktaAuth.tokenManager.off('error')
oktaAuth.tokenManager.off('removed')
oktaAuth.tokenManager.off('added')
oktaAuth.stop()
}
}, [oktaAuth, onTokenRenewal, onTokenExpire, onTokenRemove, onTokenAdded, onTokenError])
When it works correctly, I'm seeing logs like this:
2022-11-09T11:18:45.484Z start
OKTA-AUTH-JS:updateAuthState: Event:undefined Status:canceled
OKTA-AUTH-JS:updateAuthState: Event:undefined Status:emitted
2022-11-09T11:19:21.003Z expired idToken
2022-11-09T11:19:21.007Z expired accessToken
2022-11-09T11:19:21.814Z removed idToken
KTA-AUTH-JS:updateAuthState: Event:added Status:canceled
2022-11-09T11:19:21.815Z added idToken
2022-11-09T11:19:21.815Z new token idToken
OKTA-AUTH-JS:updateAuthState: Event:removed Status:canceled
2022-11-09T11:19:21.817Z removed accessToken
OKTA-AUTH-JS:updateAuthState: Event:added Status:canceled
2022-11-09T11:19:21.817Z added accessToken
2022-11-09T11:19:21.817Z new token accessToken
OKTA-AUTH-JS:updateAuthState: Event:undefined Status:emitted
2022-11-09T11:19:21.826Z removed idToken // for some reason id and access tokens are renewed three times
A minute later there's another similar logging sequence.
When I see that the renewal process works, I refresh the browser page and wait. If it turns out that the renewal process doesn't work, the logs look like this:
2022-11-09T11:20:40.922Z start
OKTA-AUTH-JS:updateAuthState: Event:undefined Status:canceled
OKTA-AUTH-JS:updateAuthState: Event:undefined Status:emitted
2022-11-09T11:21:22.010Z expired idToken
2022-11-09T11:21:22.011Z expired accessToken
// and nothing after this
In the latter case there are no calls to the /oauth2/v1/token
API as in the first case. As you can see from above, I have an error listener but that logs nothing either.
I'm using these versions meaning I have the latest ones:
"@okta/okta-auth-js": "^7.0.1",
"@okta/okta-react": "^6.7.0",
I have also tried this version but the result is the same:
useEffect(() => {
console.log(new Date().toISOString(), 'start')
oktaAuth.start().then(() => {
oktaAuth.tokenManager.on('renewed', onTokenRenewal)
})
...
@nitrique @karhatsu did you notice your respective issues prior to upgrading to 7.0.1
?
I've added a ticket to our next sprint to investigate this
Internal Ref: OKTA-549676
I have built the app on top of 7.0.1 so I don't have experience from the prior versions.
@karhatsu It works for me with the following on React:
export const oktaAuth = new OktaAuth({
redirectUri: `${ window.location.origin }/login/callback`,
clientId: window.config.OKTA_CLIENT_ID,
pkce: true,
issuer: `${ issuer?.endpoint ?? authIssuers[0].endpoint }/oauth2/default`,
// Enable this to get auth event messages in console
devMode: false,
cookies: {
secure: true,
},
storageManager: {
token: {
storageTypes: [
"cookie",
],
},
cache: {
storageTypes: [
"cookie",
],
},
transaction: {
storageTypes: [
"cookie",
],
},
},
tokenManager: {
expireEarlySeconds: 15,
autoRemove: true,
autoRenew: true,
secure: true,
},
});
export const tokenRenewListener = (key: string) => {
oktaAuth.tokenManager.renew(key);
};
(I agree this function is a pass-through)
In auth context:
useEffect(() => {
oktaAuth.start();
// .... truncated code (handle auth state check and redirect) .....
oktaAuth.tokenManager.on("expired", tokenRenewListener);
return () => {
stopOktaAuth();
oktaAuth.tokenManager.off("expired", tokenRenewListener);
};
}, []);
HINT : oktaAuth.start() is needed to monitor token state
-> Take care of token storage, this lib stores by default on localStorage, which is a big CVE as any browser's plugin can access it (see https://auth0.com/docs/secure/security-guidance/data-security/token-storage), prefer secure cookies.
-> My advice is to DON'T use Okta React lib, which has to much dependencies like react-navigation (old version) and can differ from your implementation
-> Second advice, find an OpenIDConnect generic lib, it's better than auth providers libs which lead to vendor lock-in and most of the time has a better maintainance, if you look at this lib in exemple, it still uses "var" keyword, in 2022...
@nitrique Thank you for the hints! I've been trying it out with as default Okta settings as possible, so been using local storage for now. Not sure if it has any impact for this case.
Probably the biggest difference to your code is the usage of 59 minutes in tokenManager.expireEarlySeconds
. First of all, this way I can run way more test cases than with the values close to 0 seconds. On the other hand, it could also have an impact to the bug; maybe the possible race condition is related to renewing the tokens too often?
@karhatsu tons of articles explain what you shouldn't use local storage like this one https://dev.to/rdegges/please-stop-using-local-storage-1i04 , but it's up to you :)
The token should last the biggest time frame that you can accept if access token is stolen (I'm not talking of refresh token), for critical applications 1 minute can be good, but generally 30 minutes is a good start.
Take care of not emitting too long access token, this is the purpose of refresh token. In my client's company, I requested to set the token expiration to 5 minutes which I think is good, even if it's not a critical application. Token renewal is free :)
To answer your question, OpenIDConnect doesn't mention a limit of renewal as I know, but Okta devs can have implemented it differently
I appreciate your advice @nitrique. My point here are not the security details but providing the Okta team information how to figure out the issue in the code. I would be quite surprised if it's related to the storage type. Also the 59 minutes early expiration is not something I'd use normally (and wouldn't be even possible according to Okta docs), it's just for easier debugging the problem.
@karhatsu I am not able to reproduce your issue, but I assume it's related to leader election service. When you open 2+ tabs with your app, one one tab should be a leader and perform token auto renewal. Even if you use only 1 tab, it should be self-elected as a leader. If election fails for some reason, token renew process will not be triggered.
(We use broadcast-channel for leader election)
Could you please answer following question to help me investigate your issue further?
- What browser are you using?
- Did you notice you have the issue when 2+ tabs are opened? If yes, do you see same issue on other tabs?
- When you have the issue, can you please debug the values of:
-
oktaAuth.serviceManager.services.get('leaderElection').isLeader()
-
oktaAuth.serviceManager.services.get('autoRenew').isStarted()
-
oktaAuth.serviceManager.services.get('leaderElection').elector.isDead
-
oktaAuth.serviceManager.services.get('leaderElection').elector.hasLeader
-
- If you open broadcast-channel test page (1 tab) and perform page refresh several times, do you see any errors in console? Does console message "is leader" show up every time?
Sorry for taking so long to respond. I'm using Chrome, haven't tested with other browsers.
When the renewal doesn't work, it logs these values:
2022-12-15T08:08:16.005Z expired idToken
isLeader false
isStarted false
isDead false
hasLeader true
When it does work, then it logs like this:
2022-12-15T08:12:44.219Z expired idToken
isLeader true
isStarted true
isDead false
hasLeader true
I have never tried this before with having two tabs open. Now when I tested it and when it worked, the first tab logged the latter output and the second the former. Both were still able to update the token.
I tried your test page several times and all the time it logged is leader
.
Version 7.2.0 will include the fix for your issue.
I tried with 7.2.0 but unfortunately I'm still seeing similar behaviour.
Could you please also debug these values with auth-js 7.2.0 when token is expired:
{
syncStorageCanStart: oktaAuth.serviceManager.services.get('syncStorage').canStart(),
syncStorageStarted: oktaAuth.serviceManager.services.get('syncStorage').isStarted(),
autoRenewCanStart: oktaAuth.serviceManager.services.get('autoRenew').canStart(),
autoRenewStarted: oktaAuth.serviceManager.services.get('autoRenew').isStarted(),
isLeader: oktaAuth.serviceManager.services.get('leaderElection').elector.isLeader,
hasLeader: oktaAuth.serviceManager.services.get('leaderElection').elector.hasLeader,
type: oktaAuth.serviceManager.services.get('leaderElection').elector.broadcastChannel.method.type
}
From your logs above
isLeader false
hasLeader true
it seems like there are 2 tabs opened and current one is not a leader, but from your comment I understand that there is only 1 tab opened, so it can be a bug with leader election.
Could you please also contact [email protected] to help us gathering more information to investigarte?
I've looked at the issue in auth-js 7.2.0
and figured that starting oktaAuth service does not always start AutoRenewService
while canStart()
returns true - that has been tested in Chrome (111.0.5563.64) and Safari 16.3 (18614.4.6.1.6) with no plugins in two different projects. I've also confirmed the problem exists with one and several tabs.
Currently this seems to be a reliable workaround
oktaAuth
.start()
.then(() => {
const autoRenew = oktaAuth.serviceManager.getService('autoRenew')
if (autoRenew && !autoRenew.isStarted()) {
return autoRenew.start()
}
})
@alexfedosov When autoRenew.start()
resolves a promise, autoRenew
service might not start yet. It requires some time to elect a leader tab and then start autoRenew
service on a leader tab. But if doesn't start after a while (several seconds), then it looks like a problem same as @karhatsu has.
@alexfedosov @karhatsu
- Do you use Next.js or some other framework with SSR?
- Do you call
oktaAuth.stop()
somewhere in your app code? - Are you sure you have only 1 instance of
OktaAuth
on a page? I can reproduce an issue if calloktaAuth.stop()
in the cleanup function ofuserEffect
.
Internal ref: OKTA-597615
@denysoblohin-okta
- We don't use SSR
- oktaAuth.stop() is ofc in the cleanup of react's
useEffect
but we have this effect in the root component, so essentially it is never called - There is only single instance of oktaAuth
Basically we have this
import { useOktaAuth } from '@okta/okta-react'
export const useAccessTokenRenewal = () => {
const { oktaAuth } = useOktaAuth()
useEffect(() => {
oktaAuth.start()
return () => {
oktaAuth.stop()
}
}, [oktaAuth])
}
and then there are several places where we again use useOktaAuth()
hook to verify auth state and get user token, but this should not create extra OktaAuth
instances, right?
Right, should not create extra instances. But:
- If you use
<Security>
from '@okta/okta-react', it already callsoktaAuth.start()
, sostart
can be called twice which could be a cause of an issue. - If you use
<React.StrictMode />
in React 18, it calls your effect twice. So start + stop + start can occur.
You can debug what's going on with start and stop calls with this code example
const origStart = OktaAuth.prototype.start;
const origStop = OktaAuth.prototype.stop;
OktaAuth.prototype.start = function() {
console.log('>>> OktaAuth start');
return origStart.apply(this);
};
OktaAuth.prototype.stop = function() {
console.log('>>> OktaAuth stop');
return origStop.apply(this);
};
We certainly have it called twice due to the <Security>
component; thank you for the hint.
Regarding the start + stop + start
sequence, it looks like a fairly common command chain. Shouldn't the Okta-auth handle it?
@alexfedosov Could you please try the branch from https://github.com/okta/okta-auth-js/pull/1398 in your project and check if it solves your issue?
@denysoblohin-okta removing oktaAuth.start()
(while keeping Security component) seems to fix it, I can also verify the branch in the next few days and let you know