forked from Onlineberatung/onlineBeratung-frontend
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #69 from CaritasDeutschland/feature-refresh-token
feat: refresh keycloak access token until refresh token expires
- Loading branch information
Showing
9 changed files
with
337 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { RENEW_BEFORE_EXPIRY_IN_MS } from '../../src/components/auth/auth'; | ||
import { getTokenExpiryFromLocalStorage } from '../../src/components/sessionCookie/accessSessionLocalStorage'; | ||
|
||
const waitForTokenProcessing = () => { | ||
// TODO: don't arbitrarily wait for token to be processed, find some | ||
// way to cleanly determine that the token was processed instead. | ||
// possible candidates are spying on `localStorage` or `setTimeout` | ||
return cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting | ||
}; | ||
|
||
describe('Keycloak Tokens', () => { | ||
let authTokenJson; | ||
before(() => { | ||
cy.fixture('auth.token.json').then((fixture) => { | ||
authTokenJson = fixture; | ||
}); | ||
}); | ||
|
||
it('should get and store tokens and expiry time on login', () => { | ||
cy.caritasMockedLogin(); | ||
|
||
cy.get('#appRoot').then(() => { | ||
cy.getCookie('keycloak').should('exist'); | ||
cy.getCookie('refreshToken').should('exist'); | ||
|
||
const tokenExpiry = getTokenExpiryFromLocalStorage(); | ||
expect(tokenExpiry.accessTokenValidUntilTime).to.exist; | ||
expect(tokenExpiry.refreshTokenValidUntilTime).to.exist; | ||
}); | ||
}); | ||
|
||
it('should keep refreshing access token before it expires', () => { | ||
cy.clock(); | ||
cy.caritasMockedLogin(); | ||
|
||
for (let check = 0; check < 3; check++) { | ||
waitForTokenProcessing(); | ||
|
||
cy.tick( | ||
authTokenJson.expires_in * 1000 - RENEW_BEFORE_EXPIRY_IN_MS | ||
); | ||
cy.wait('@authToken').then((interception) => { | ||
expect(interception.request.body).to.include( | ||
'grant_type=refresh_token' | ||
); | ||
}); | ||
} | ||
}); | ||
|
||
it('should refresh the access token if its expired when loading the app', () => { | ||
cy.clock(); | ||
cy.caritasMockedLogin(); | ||
|
||
cy.clock().then((clock) => { | ||
clock.restore(); | ||
}); | ||
cy.clock(authTokenJson.expires_in * 1000 + 1); | ||
cy.reload(); | ||
cy.get('#appRoot'); | ||
|
||
cy.wait('@authToken').then((interception) => { | ||
expect(interception.request.body).to.include( | ||
'grant_type=refresh_token' | ||
); | ||
}); | ||
|
||
cy.tick(1000); // logout() call uses setTimeout | ||
cy.get('#appRoot').should('exist'); | ||
}); | ||
|
||
it('should logout if refresh token is already expired when loading the app', () => { | ||
cy.clock(); | ||
cy.caritasMockedLogin(); | ||
|
||
cy.clock().then((clock) => { | ||
clock.restore(); | ||
}); | ||
cy.clock(authTokenJson.refresh_expires_in * 1000 + 1); | ||
cy.reload(); | ||
cy.get('#appRoot'); | ||
waitForTokenProcessing(); | ||
|
||
cy.tick(1000); // logout() call uses setTimeout | ||
cy.get('#loginRoot').should('exist'); | ||
}); | ||
|
||
it('should logout if refresh token is expired while the app is loaded', () => { | ||
cy.clock(); | ||
cy.caritasMockedLogin(); | ||
waitForTokenProcessing(); | ||
|
||
cy.tick(authTokenJson.refresh_expires_in * 1000 + 1); | ||
waitForTokenProcessing(); | ||
|
||
cy.tick(1000); // logout() call uses setTimeout | ||
cy.get('#loginRoot').should('exist'); | ||
}); | ||
|
||
it('should not logout if refresh token is expired but access token is still valid', () => { | ||
cy.clock(); | ||
cy.caritasMockedLogin({ | ||
auth: { expires_in: 1800, refresh_expires_in: 600 } | ||
}); | ||
|
||
waitForTokenProcessing(); | ||
cy.tick(600 * 1000); | ||
waitForTokenProcessing(); | ||
|
||
cy.tick(1000); // logout() call uses setTimeout | ||
cy.get('#loginRoot').should('not.exist'); | ||
}); | ||
|
||
it('should not logout if refresh token is expired but access token is still valid when the app loads', () => { | ||
const refreshExpiresIn = 600; | ||
|
||
cy.clock(); | ||
cy.caritasMockedLogin({ | ||
auth: { expires_in: 1800, refresh_expires_in: refreshExpiresIn } | ||
}); | ||
|
||
cy.clock().then((clock) => { | ||
clock.restore(); | ||
}); | ||
cy.clock(refreshExpiresIn * 1000 + 1); | ||
cy.reload(); | ||
cy.get('#appRoot'); | ||
waitForTokenProcessing(); | ||
|
||
cy.tick(1000); // logout() call uses setTimeout | ||
cy.get('#loginRoot').should('not.exist'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { logout } from '../logout/logout'; | ||
import { LoginData } from '../registration/autoLogin'; | ||
import { setTokenInCookie } from '../sessionCookie/accessSessionCookie'; | ||
import { | ||
getTokenExpiryFromLocalStorage, | ||
setTokenExpiryInLocalStorage | ||
} from '../sessionCookie/accessSessionLocalStorage'; | ||
import { refreshKeycloakAccessToken } from '../sessionCookie/refreshKeycloakAccessToken'; | ||
|
||
export const RENEW_BEFORE_EXPIRY_IN_MS = 10 * 1000; // seconds | ||
|
||
export const setTokens = (data: LoginData) => { | ||
if (data.access_token) { | ||
setTokenInCookie('keycloak', data.access_token); | ||
setTokenExpiryInLocalStorage( | ||
'auth.access_token_valid_until', | ||
data.expires_in | ||
); | ||
} | ||
if (data.refresh_token) { | ||
setTokenInCookie('refreshToken', data.refresh_token); | ||
setTokenExpiryInLocalStorage( | ||
'auth.refresh_token_valid_until', | ||
data.refresh_expires_in | ||
); | ||
} | ||
}; | ||
|
||
const refreshTokens = (): Promise<void> => { | ||
const currentTime = new Date().getTime(); | ||
const tokenExpiry = getTokenExpiryFromLocalStorage(); | ||
|
||
if ( | ||
tokenExpiry.refreshTokenValidUntilTime <= | ||
currentTime - RENEW_BEFORE_EXPIRY_IN_MS | ||
) { | ||
logout(true); | ||
return Promise.resolve(); | ||
} | ||
|
||
return refreshKeycloakAccessToken().then((response) => { | ||
setTokens(response); | ||
}); | ||
}; | ||
|
||
const startTimers = ({ | ||
accessTokenValidInMs, | ||
refreshTokenValidInMs | ||
}: { | ||
accessTokenValidInMs: number; | ||
refreshTokenValidInMs: number; | ||
}) => { | ||
const accessTokenRefreshIntervalInMs = | ||
accessTokenValidInMs - RENEW_BEFORE_EXPIRY_IN_MS; | ||
|
||
let refreshInterval; | ||
// just a sanity check so that we don't accidentally register an endless loop | ||
if (accessTokenRefreshIntervalInMs > 0) { | ||
refreshInterval = window.setInterval(() => { | ||
refreshTokens(); | ||
}, accessTokenRefreshIntervalInMs); | ||
} | ||
|
||
if (refreshTokenValidInMs > accessTokenValidInMs) { | ||
// when refresh token is longer valid than access token we need to | ||
// logout if the refresh token expires | ||
window.setTimeout(() => { | ||
if (refreshInterval) { | ||
window.clearInterval(refreshInterval); | ||
} | ||
|
||
logout(true); | ||
}, refreshTokenValidInMs); | ||
} | ||
}; | ||
|
||
export const handleTokenRefresh = (): Promise<void> => { | ||
return new Promise((resolve) => { | ||
const currentTime = new Date().getTime(); | ||
const tokenExpiry = getTokenExpiryFromLocalStorage(); | ||
const accessTokenValidInMs = | ||
tokenExpiry.accessTokenValidUntilTime - currentTime; | ||
|
||
const refreshTokenValidInMs = | ||
tokenExpiry.refreshTokenValidUntilTime - currentTime; | ||
|
||
if (refreshTokenValidInMs <= 0 && accessTokenValidInMs <= 0) { | ||
// access token and refresh token no longer valid, logout | ||
logout(true); | ||
resolve(); | ||
} else if (accessTokenValidInMs <= 0) { | ||
// access token no longer valid but refresh token still valid, refresh tokens | ||
refreshTokens().then(() => { | ||
startTimers({ | ||
accessTokenValidInMs, | ||
refreshTokenValidInMs | ||
}); | ||
resolve(); | ||
}); | ||
} else { | ||
// access token and refresh token still valid, just start the timers | ||
startTimers({ | ||
accessTokenValidInMs, | ||
refreshTokenValidInMs | ||
}); | ||
resolve(); | ||
} | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.