diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 67507abec..ab7ac6455 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -23,6 +23,7 @@ const TEST_BASE64_ENCODED_STRING = 'base64-url-encoded-string'; const TEST_CODE = 'code'; const TEST_ID_TOKEN = 'id-token'; const TEST_ACCESS_TOKEN = 'access-token'; +const TEST_REFRESH_TOKEN = 'refresh-token'; const TEST_USER_ID = 'user-id'; const TEST_USER_EMAIL = 'user@email.com'; const TEST_APP_STATE = { bestPet: 'dog' }; @@ -66,7 +67,7 @@ const setup = async (options = {}) => { utils.createQueryParams.mockReturnValue(TEST_QUERY_PARAMS); utils.getUniqueScopes.mockReturnValue(TEST_SCOPES); - utils.encodeState.mockReturnValue(TEST_ENCODED_STATE); + utils.encode.mockReturnValue(TEST_ENCODED_STATE); utils.createRandomString.mockReturnValue(TEST_RANDOM_STRING); utils.sha256.mockReturnValue(Promise.resolve(TEST_ARRAY_BUFFER)); utils.bufferToBase64UrlEncoded.mockReturnValue(TEST_BASE64_ENCODED_STRING); @@ -159,7 +160,7 @@ describe('Auth0', () => { const { auth0, utils } = await setup(); await auth0.loginWithPopup({}); - expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING); + expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING); }); it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => { const { auth0, utils } = await setup(); @@ -182,7 +183,7 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'web_message', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: 'http://localhost', code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', @@ -201,7 +202,7 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'web_message', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: 'http://localhost', code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', @@ -221,7 +222,7 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'web_message', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256' @@ -238,7 +239,7 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'web_message', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: 'http://localhost', code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256' @@ -282,12 +283,13 @@ describe('Auth0', () => { const { auth0, utils } = await setup(); await auth0.loginWithPopup({}); + expect(utils.oauthToken).toHaveBeenCalledWith({ - audience: undefined, baseUrl: 'https://test.auth0.com', client_id: TEST_CLIENT_ID, code: TEST_CODE, - code_verifier: TEST_RANDOM_STRING + code_verifier: TEST_RANDOM_STRING, + grant_type: 'authorization_code' }); }); it('calls oauth/token with correct params', async () => { @@ -295,11 +297,11 @@ describe('Auth0', () => { await auth0.loginWithPopup({ audience: 'test-audience' }); expect(utils.oauthToken).toHaveBeenCalledWith({ - audience: 'test-audience', baseUrl: 'https://test.auth0.com', client_id: TEST_CLIENT_ID, code: TEST_CODE, - code_verifier: TEST_RANDOM_STRING + code_verifier: TEST_RANDOM_STRING, + grant_type: 'authorization_code' }); }); it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => { @@ -308,7 +310,7 @@ describe('Auth0', () => { await auth0.loginWithPopup({}); expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -324,7 +326,7 @@ describe('Auth0', () => { expect(tokenVerifier).toHaveBeenCalledWith({ aud: 'test-client-id', id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, iss: 'https://test-123.auth0.com/', leeway: undefined, max_age: undefined @@ -336,7 +338,7 @@ describe('Auth0', () => { await auth0.loginWithPopup({}); expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: 10, @@ -349,7 +351,7 @@ describe('Auth0', () => { await auth0.loginWithPopup({}); expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -362,7 +364,7 @@ describe('Auth0', () => { await auth0.loginWithPopup({}); expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -375,7 +377,7 @@ describe('Auth0', () => { await auth0.loginWithPopup({}); expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -416,18 +418,20 @@ describe('Auth0', () => { }); }); - describe('buildAuthorizeUrl()', () => { + describe('buildAuthorizeUrl', () => { const REDIRECT_OPTIONS = { redirect_uri: 'https://redirect.uri', appState: TEST_APP_STATE, connection: 'test-connection' }; + it('encodes state with random string', async () => { const { auth0, utils } = await setup(); await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS); - expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING); + expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING); }); + it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => { const { auth0, utils } = await setup(); @@ -437,23 +441,64 @@ describe('Auth0', () => { TEST_ARRAY_BUFFER ); }); + it('creates correct query params', async () => { const { auth0, utils } = await setup(); await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + undefined, + undefined + ); + expect(utils.createQueryParams).toHaveBeenCalledWith({ client_id: TEST_CLIENT_ID, scope: TEST_SCOPES, response_type: TEST_CODE, response_mode: 'query', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: REDIRECT_OPTIONS.redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', connection: 'test-connection' }); }); + + it('creates correct query params when using refresh tokens', async () => { + const utils = require('../src/utils'); + utils.getUniqueScopes.mockReturnValue('offline_access'); + + const { auth0 } = await setup({ + useRefreshTokens: true + }); + + // utils.getUniqueScopes.mockReturnValue(`${TEST_SCOPES} offline_access`); + + await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + 'offline_access', + undefined + ); + + expect(utils.createQueryParams).toHaveBeenCalledWith({ + client_id: TEST_CLIENT_ID, + scope: TEST_SCOPES, + response_type: TEST_CODE, + response_mode: 'query', + state: TEST_ENCODED_STATE, + nonce: TEST_ENCODED_STATE, + redirect_uri: REDIRECT_OPTIONS.redirect_uri, + code_challenge: TEST_BASE64_ENCODED_STRING, + code_challenge_method: 'S256', + connection: 'test-connection' + }); + }); + it('creates correct query params without leeway', async () => { const { auth0, utils } = await setup({ leeway: 10 }); @@ -464,13 +509,14 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'query', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: REDIRECT_OPTIONS.redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', connection: 'test-connection' }); }); + it('creates correct query params when providing a default redirect_uri', async () => { const redirect_uri = 'https://custom-redirect-uri/callback'; const { redirect_uri: _ignore, ...options } = REDIRECT_OPTIONS; @@ -486,13 +532,14 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'query', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', connection: 'test-connection' }); }); + it('creates correct query params when overriding redirect_uri', async () => { const redirect_uri = 'https://custom-redirect-uri/callback'; const { auth0, utils } = await setup({ @@ -507,17 +554,19 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'query', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: REDIRECT_OPTIONS.redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', connection: 'test-connection' }); }); + it('creates correct query params with custom params', async () => { const { auth0, utils } = await setup(); await auth0.buildAuthorizeUrl({ ...REDIRECT_OPTIONS, audience: 'test' }); + expect(utils.createQueryParams).toHaveBeenCalledWith({ audience: 'test', client_id: TEST_CLIENT_ID, @@ -525,13 +574,14 @@ describe('Auth0', () => { response_type: TEST_CODE, response_mode: 'query', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: REDIRECT_OPTIONS.redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256', connection: 'test-connection' }); }); + it('calls `transactionManager.create` with new transaction', async () => { const { auth0, transactionManager } = await setup(); @@ -542,25 +592,29 @@ describe('Auth0', () => { appState: TEST_APP_STATE, audience: 'default', code_verifier: TEST_RANDOM_STRING, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, scope: TEST_SCOPES } ); }); + it('returns the url', async () => { const { auth0 } = await setup(); const url = await auth0.buildAuthorizeUrl({ ...REDIRECT_OPTIONS }); + expect(url).toBe( `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}` ); }); + it('returns the url when no arguments are passed', async () => { const { auth0 } = await setup(); const url = await auth0.buildAuthorizeUrl(); + expect(url).toBe( `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}` ); @@ -573,6 +627,7 @@ describe('Auth0', () => { appState: TEST_APP_STATE, connection: 'test-connection' }; + it('calls `window.location.assign` with the correct url', async () => { const { auth0 } = await setup(); @@ -581,6 +636,7 @@ describe('Auth0', () => { `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}` ); }); + it('calls `window.location.assign` with the correct url and fragment if provided', async () => { const { auth0 } = await setup(); @@ -592,6 +648,7 @@ describe('Auth0', () => { `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}#/reset` ); }); + it('can be called with no arguments', async () => { const { auth0 } = await setup(); @@ -621,7 +678,7 @@ describe('Auth0', () => { const result = await setup(); result.transactionManager.get.mockReturnValue({ code_verifier: TEST_RANDOM_STRING, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, audience: 'default', scope: TEST_SCOPES, appState: TEST_APP_STATE @@ -724,11 +781,11 @@ describe('Auth0', () => { await auth0.handleRedirectCallback(); expect(utils.oauthToken).toHaveBeenCalledWith({ - audience: undefined, baseUrl: 'https://test.auth0.com', client_id: TEST_CLIENT_ID, code: TEST_CODE, - code_verifier: TEST_RANDOM_STRING + code_verifier: TEST_RANDOM_STRING, + grant_type: 'authorization_code' }); }); it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => { @@ -738,7 +795,7 @@ describe('Auth0', () => { expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -794,7 +851,7 @@ describe('Auth0', () => { const result = await setup(); result.transactionManager.get.mockReturnValue({ code_verifier: TEST_RANDOM_STRING, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, audience: 'default', scope: TEST_SCOPES, appState: TEST_APP_STATE @@ -905,11 +962,11 @@ describe('Auth0', () => { await auth0.handleRedirectCallback(); expect(utils.oauthToken).toHaveBeenCalledWith({ - audience: undefined, baseUrl: 'https://test.auth0.com', client_id: TEST_CLIENT_ID, code: TEST_CODE, - code_verifier: TEST_RANDOM_STRING + code_verifier: TEST_RANDOM_STRING, + grant_type: 'authorization_code' }); }); it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => { @@ -919,7 +976,7 @@ describe('Auth0', () => { expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -1030,6 +1087,7 @@ describe('Auth0', () => { const decodedToken = await auth0.getIdTokenClaims(); expect(decodedToken).toBeUndefined(); }); + it('returns full decoded token if there is a cache entry', async () => { const { auth0, cache } = await setup(); const userIn = { @@ -1046,41 +1104,97 @@ describe('Auth0', () => { const userOut = await auth0.getIdTokenClaims(); expect(userOut).toEqual(userIn.decodedToken.claims); }); - it('uses default options', async () => { - const { auth0, utils, cache } = await setup(); - await auth0.getIdTokenClaims(); + describe('when using refresh tokens', () => { + it('uses default options with offine_access', async () => { + const utils = require('../src/utils'); + utils.getUniqueScopes.mockReturnValue('offline_access'); - expect(cache.get).toHaveBeenCalledWith({ - audience: 'default', - scope: TEST_SCOPES, - client_id: TEST_CLIENT_ID + const { auth0, cache } = await setup({ + useRefreshTokens: true + }); + + await auth0.getIdTokenClaims(); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'default', + scope: TEST_SCOPES, + client_id: TEST_CLIENT_ID + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + 'offline_access', + 'offline_access' + ); }); - expect(utils.getUniqueScopes).toHaveBeenCalledWith( - 'openid profile email', - 'openid profile email' - ); - }); + it('uses custom options when provided with offline_access', async () => { + const utils = require('../src/utils'); + utils.getUniqueScopes.mockReturnValue('offline_access'); - it('uses custom options when provided', async () => { - const { auth0, utils, cache } = await setup(); + const { auth0, cache } = await setup({ + useRefreshTokens: true + }); - await auth0.getIdTokenClaims({ - audience: 'the-audience', - scope: 'the-scope' + await auth0.getIdTokenClaims({ + audience: 'the-audience', + scope: 'the-scope' + }); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'the-audience', + scope: TEST_SCOPES, + client_id: TEST_CLIENT_ID + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + 'offline_access', + 'the-scope' + ); }); + }); - expect(cache.get).toHaveBeenCalledWith({ - audience: 'the-audience', - scope: TEST_SCOPES, - client_id: TEST_CLIENT_ID + describe('when not using refresh tokens', () => { + it('uses default options', async () => { + const { auth0, utils, cache } = await setup(); + + await auth0.getIdTokenClaims(); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'default', + scope: TEST_SCOPES, + client_id: TEST_CLIENT_ID + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + undefined, + 'openid profile email' + ); }); - expect(utils.getUniqueScopes).toHaveBeenCalledWith( - 'openid profile email', - 'the-scope' - ); + it('uses custom options when provided', async () => { + const { auth0, utils, cache } = await setup(); + + await auth0.getIdTokenClaims({ + audience: 'the-audience', + scope: 'the-scope' + }); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'the-audience', + scope: TEST_SCOPES, + client_id: TEST_CLIENT_ID + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + undefined, + 'the-scope' + ); + }); }); }); @@ -1105,51 +1219,156 @@ describe('Auth0', () => { describe('getTokenSilently()', () => { describe('when `options.ignoreCache` is false', async () => { - it('calls `cache.get` with the correct options', async () => { - const { auth0, cache, utils } = await setup(); - cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); + describe('when refresh tokens are not used', () => { + it('calls `cache.get` with the correct options', async () => { + const { auth0, cache, utils } = await setup(); + cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); + + await auth0.getTokenSilently(); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'default', + scope: TEST_SCOPES, + client_id: TEST_CLIENT_ID + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + undefined, + undefined + ); + }); - await auth0.getTokenSilently(); + it('returns cached access_token when there is a cache', async () => { + const { auth0, cache } = await setup(); + cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); - expect(cache.get).toHaveBeenCalledWith({ - audience: 'default', - scope: TEST_SCOPES, - client_id: TEST_CLIENT_ID + const token = await auth0.getTokenSilently(); + + expect(token).toBe(TEST_ACCESS_TOKEN); }); - expect(utils.getUniqueScopes).toHaveBeenCalledWith( - 'openid profile email', - 'openid profile email' - ); - }); + it('acquires and releases lock when there is a cache', async () => { + const { auth0, cache, lock } = await setup(); + cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); - it('returns cached access_token when there is a cache', async () => { - const { auth0, cache } = await setup(); - cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); + await auth0.getTokenSilently(); + expect(lock.acquireLockMock).toHaveBeenCalledWith( + GET_TOKEN_SILENTLY_LOCK_KEY, + 5000 + ); + expect(lock.releaseLockMock).toHaveBeenCalledWith( + GET_TOKEN_SILENTLY_LOCK_KEY + ); + }); - const token = await auth0.getTokenSilently(); + it('continues method execution when there is no cache available', async () => { + const { auth0, utils } = await setup(); - expect(token).toBe(TEST_ACCESS_TOKEN); - }); - it('acquires and releases lock when there is a cache', async () => { - const { auth0, cache, lock } = await setup(); - cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); + await auth0.getTokenSilently(); - await auth0.getTokenSilently(); - expect(lock.acquireLockMock).toHaveBeenCalledWith( - GET_TOKEN_SILENTLY_LOCK_KEY, - 5000 - ); - expect(lock.releaseLockMock).toHaveBeenCalledWith( - GET_TOKEN_SILENTLY_LOCK_KEY - ); + //we only evaluate that the code didn't bail out because of the cache + expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING); + }); }); - it('continues method execution when there is no cache available', async () => { - const { auth0, utils } = await setup(); - await auth0.getTokenSilently(); - //we only evaluate that the code didn't bail out because of the cache - expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING); + describe('when refresh tokens are used', () => { + it('calls `cache.get` with the correct options', async () => { + const utils = require('../src/utils'); + utils.getUniqueScopes.mockReturnValue('offline_access'); + + const { auth0, cache } = await setup({ + useRefreshTokens: true + }); + + utils.getUniqueScopes.mockReturnValue( + `${TEST_SCOPES} offline_access` + ); + + cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); + + await auth0.getTokenSilently(); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'default', + scope: `${TEST_SCOPES} offline_access`, + client_id: TEST_CLIENT_ID + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'openid profile email', + 'offline_access', + undefined + ); + }); + + it('calls the token endpoint with the correct params', async () => { + const { auth0, cache, utils } = await setup({ + useRefreshTokens: true + }); + + utils.getUniqueScopes.mockReturnValue( + `${TEST_SCOPES} offline_access` + ); + + utils.oauthToken.mockReturnValue( + Promise.resolve({ + id_token: TEST_ID_TOKEN, + access_token: TEST_ACCESS_TOKEN, + refresh_token: TEST_REFRESH_TOKEN + }) + ); + + cache.get.mockReturnValue({ refresh_token: TEST_REFRESH_TOKEN }); + + await auth0.getTokenSilently({ ignoreCache: true }); + + expect(cache.get).toHaveBeenCalledWith({ + audience: 'default', + scope: `${TEST_SCOPES} offline_access`, + client_id: TEST_CLIENT_ID + }); + + expect(utils.oauthToken).toHaveBeenCalledWith({ + baseUrl: 'https://test.auth0.com', + refresh_token: TEST_REFRESH_TOKEN, + client_id: TEST_CLIENT_ID, + grant_type: 'refresh_token' + }); + + expect(cache.save).toHaveBeenCalledWith({ + client_id: TEST_CLIENT_ID, + refresh_token: TEST_REFRESH_TOKEN, + access_token: TEST_ACCESS_TOKEN, + id_token: TEST_ID_TOKEN, + scope: `${TEST_SCOPES} offline_access`, + audience: 'default', + decodedToken: { + claims: { sub: TEST_USER_ID, aud: TEST_CLIENT_ID }, + user: { sub: TEST_USER_ID } + } + }); + }); + + it('fails with an error when no refresh token is available in the cache', async () => { + const { auth0, cache, utils } = await setup({ + useRefreshTokens: true + }); + + utils.getUniqueScopes.mockReturnValue( + `${TEST_SCOPES} offline_access` + ); + + cache.get.mockReturnValue({ access_token: TEST_ACCESS_TOKEN }); + + await auth0.getTokenSilently({ ignoreCache: true }).catch(e => { + expect(e.error).toBe('missing_refresh_token'); + expect(e.error_description).toBe( + 'No refresh token is available to fetch a new access token. The user should be reauthenticated.' + ); + expect(utils.oauthToken).not.toHaveBeenCalled(); + }); + }); }); }); @@ -1181,7 +1400,7 @@ describe('Auth0', () => { const { auth0, utils } = await setup(); await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue); - expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING); + expect(utils.encode).toHaveBeenCalledWith(TEST_RANDOM_STRING); }); it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => { @@ -1198,6 +1417,7 @@ describe('Auth0', () => { const { auth0, utils } = await setup(); await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue); + expect(utils.createQueryParams).toHaveBeenCalledWith({ audience: defaultOptionsIgnoreCacheTrue.audience, client_id: TEST_CLIENT_ID, @@ -1206,7 +1426,7 @@ describe('Auth0', () => { response_mode: 'web_message', prompt: 'none', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: 'http://localhost', code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256' @@ -1225,7 +1445,7 @@ describe('Auth0', () => { response_mode: 'web_message', prompt: 'none', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: 'http://localhost', code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256' @@ -1247,7 +1467,7 @@ describe('Auth0', () => { response_mode: 'web_message', prompt: 'none', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri, code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256' @@ -1258,6 +1478,7 @@ describe('Auth0', () => { const { auth0, utils } = await setup(); await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue); + expect(utils.createQueryParams).toHaveBeenCalledWith({ audience: defaultOptionsIgnoreCacheTrue.audience, client_id: TEST_CLIENT_ID, @@ -1266,11 +1487,12 @@ describe('Auth0', () => { response_mode: 'web_message', prompt: 'none', state: TEST_ENCODED_STATE, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, redirect_uri: 'http://localhost', code_challenge: TEST_BASE64_ENCODED_STRING, code_challenge_method: 'S256' }); + expect(utils.getUniqueScopes).toHaveBeenCalledWith( 'openid profile email', undefined, @@ -1309,21 +1531,23 @@ describe('Auth0', () => { const { auth0, utils } = await setup(); await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue); + expect(utils.oauthToken).toHaveBeenCalledWith({ - audience: defaultOptionsIgnoreCacheTrue.audience, baseUrl: 'https://test.auth0.com', client_id: TEST_CLIENT_ID, code: TEST_CODE, - code_verifier: TEST_RANDOM_STRING + code_verifier: TEST_RANDOM_STRING, + grant_type: 'authorization_code' }); }); + it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => { const { auth0, tokenVerifier } = await setup(); await auth0.getTokenSilently(defaultOptionsIgnoreCacheTrue); expect(tokenVerifier).toHaveBeenCalledWith({ id_token: TEST_ID_TOKEN, - nonce: TEST_RANDOM_STRING, + nonce: TEST_ENCODED_STATE, aud: 'test-client-id', iss: 'https://test.auth0.com/', leeway: undefined, @@ -1537,7 +1761,9 @@ describe('default creation function', () => { it('calls getTokenSilently if there is a storage item with key `auth0.is.authenticated`', async () => { Auth0Client.prototype.getTokenSilently = jest.fn(); + require('../src/storage').get = () => true; + const auth0 = await createAuth0Client({ domain: TEST_DOMAIN, client_id: TEST_CLIENT_ID @@ -1545,25 +1771,68 @@ describe('default creation function', () => { expect(auth0.getTokenSilently).toHaveBeenCalledWith({ audience: undefined, - ignoreCache: true, - scope: undefined + ignoreCache: true }); }); - it('calls getTokenSilently with audience and scope', async () => { - const options = { - audience: 'the-audience', - scope: 'the-scope' - }; - Auth0Client.prototype.getTokenSilently = jest.fn(); - require('../src/storage').get = () => true; - const auth0 = await createAuth0Client({ - domain: TEST_DOMAIN, - client_id: TEST_CLIENT_ID, - ...options + + describe('when refresh tokens are not used', () => { + it('calls getTokenSilently with audience and scope', async () => { + const utils = require('../src/utils'); + + const options = { + audience: 'the-audience', + scope: 'the-scope' + }; + + Auth0Client.prototype.getTokenSilently = jest.fn(); + + require('../src/storage').get = () => true; + utils.getUniqueScopes = jest.fn(() => options.scope); + + const auth0 = await createAuth0Client({ + domain: TEST_DOMAIN, + client_id: TEST_CLIENT_ID, + ...options + }); + + expect(auth0.getTokenSilently).toHaveBeenCalledWith({ + ignoreCache: true, + ...options + }); }); - expect(auth0.getTokenSilently).toHaveBeenCalledWith({ - ignoreCache: true, - ...options + }); + + describe('when refresh tokens are used', () => { + it('creates the client with the correct scopes', async () => { + const utils = require('../src/utils'); + + const options = { + audience: 'the-audience', + scope: 'the-scope', + useRefreshTokens: true + }; + + Auth0Client.prototype.getTokenSilently = jest.fn(); + + require('../src/storage').get = () => true; + utils.getUniqueScopes = jest.fn(() => `${options.scope} offline_access`); + + const auth0 = await createAuth0Client({ + domain: TEST_DOMAIN, + client_id: TEST_CLIENT_ID, + ...options + }); + + expect(utils.getUniqueScopes).toHaveBeenCalledWith( + 'the-scope', + 'offline_access' + ); + + expect(auth0.getTokenSilently).toHaveBeenCalledWith({ + ignoreCache: true, + scope: 'the-scope offline_access', + audience: 'the-audience' + }); }); }); }); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 584f2d3d0..dd1f88ef6 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -5,8 +5,8 @@ import { createQueryParams, bufferToBase64UrlEncoded, createRandomString, - encodeState, - decodeState, + encode, + decode, sha256, openPopup, runPopup, @@ -138,14 +138,14 @@ describe('utils', () => { expect(result.length).toBeLessThanOrEqual(128); }); }); - describe('encodeState', () => { + describe('encode', () => { it('encodes state', () => { - expect(encodeState('test')).toBe('dGVzdA=='); + expect(encode('test')).toBe('dGVzdA=='); }); }); - describe('decodeState', () => { + describe('decode', () => { it('decodes state', () => { - expect(decodeState('dGVzdA==')).toBe('test'); + expect(decode('dGVzdA==')).toBe('test'); }); }); describe('sha256', () => { @@ -270,14 +270,16 @@ describe('utils', () => { ) ); await oauthToken({ + grant_type: 'authorization_code', baseUrl: 'https://test.com', client_id: 'client_idIn', code: 'codeIn', code_verifier: 'code_verifierIn' }); + expect(mockUnfetch).toHaveBeenCalledWith('https://test.com/oauth/token', { body: - '{"grant_type":"authorization_code","redirect_uri":"http://localhost","client_id":"client_idIn","code":"codeIn","code_verifier":"code_verifierIn"}', + '{"redirect_uri":"http://localhost","grant_type":"authorization_code","client_id":"client_idIn","code":"codeIn","code_verifier":"code_verifierIn"}', headers: { 'Content-type': 'application/json' }, method: 'POST' }); diff --git a/cypress/integration/getTokenSilently.js b/cypress/integration/getTokenSilently.js index ded476466..15f737350 100644 --- a/cypress/integration/getTokenSilently.js +++ b/cypress/integration/getTokenSilently.js @@ -9,8 +9,9 @@ import { describe('getTokenSilently', function() { beforeEach(cy.resetTests); + afterEach(cy.logout); - it('return error when not logged in', function(done) { + it('returns an error when not logged in', function(done) { whenReady().then(win => win.auth0.getTokenSilently().catch(error => { shouldBe('login_required', error.error); @@ -19,73 +20,84 @@ describe('getTokenSilently', function() { ); }); - it.skip('Builds URL correctly', function(done) { - cy.login().then(() => { - whenReady().then(win => { - var iframe = win.document.createElement('iframe'); - cy.stub(win.document, 'createElement', type => - type === 'iframe' ? iframe : window.document.createElement - ); - return win.auth0.getTokenSilently().then(() => { - const parsedUrl = new URL(iframe.src); - shouldBe(parsedUrl.host, 'brucke.auth0.com'); - const pageParams = decode(parsedUrl.search.substr(1)); - shouldBeUndefined(pageParams.code_verifier); - shouldNotBeUndefined(pageParams.code_challenge); - shouldNotBeUndefined(pageParams.code_challenge_method); - shouldNotBeUndefined(pageParams.state); - shouldNotBeUndefined(pageParams.nonce); - shouldBe(pageParams.redirect_uri, win.location.origin); - shouldBe(pageParams.response_mode, 'web_message'); - shouldBe(pageParams.response_type, 'code'); - shouldBe(pageParams.scope, 'openid profile email'); - shouldBe(pageParams.client_id, 'wLSIP47wM39wKdDmOj6Zb5eSEw3JVhVp'); - done(); + describe('when using an iframe', () => { + describe('using an in-memory store', () => { + it('gets a new access token', () => { + return whenReady().then(win => { + cy.login().then(() => { + cy.get('[data-cy=get-token]').click(); + cy.get('[data-cy=access-token]').should('have.length', 2); // 1 from handleRedirectCallback, 1 from clicking "Get access token" + cy.get('[data-cy=error]').should('not.exist'); + }); }); }); - }); - }); - it('return cached token after login', function(done) { - cy.login().then(() => { - whenReady().then(win => - win.auth0.getTokenSilently().then(token => { - shouldNotBeUndefined(token); - win.auth0.getTokenSilently().then(token2 => { - shouldNotBeUndefined(token2); - shouldBe(token, token2); - done(); + it('can get the access token after refreshing the page', () => { + return whenReady().then(win => { + cy.login().then(() => { + cy.reload(); + + cy.get('[data-cy=get-token]') + .click() + .wait(500) + .get('[data-cy=access-token]') + .should('have.length', 1); + + cy.get('[data-cy=error]').should('not.exist'); }); - }) - ); + }); + }); }); - }); - it('ignores cache if `ignoreCache:true`', function(done) { - cy.login().then(() => { - whenReady().then(win => - win.auth0.getTokenSilently().then(token => { - shouldNotBeUndefined(token); - win.auth0.getTokenSilently({ ignoreCache: true }).then(token2 => { - shouldNotBeUndefined(token2); - shouldNotBe(token, token2); - done(); + describe('using local storage', () => { + it('can get the access token after refreshing the page', () => { + return whenReady().then(win => { + cy.toggleSwitch('local-storage'); + + cy.login().then(() => { + cy.reload(); + + cy.get('[data-cy=get-token]') + .click() + .wait(500) + .get('[data-cy=access-token]') + .should('have.length', 1) + .then(() => { + expect( + win.localStorage.getItem( + '@@auth0spajs@@::wLSIP47wM39wKdDmOj6Zb5eSEw3JVhVp::default::openid profile email' + ) + ).to.not.be.null; + }); + + cy.get('[data-cy=error]').should('not.exist'); }); - }) - ); - }); - }); + }); + }); + + describe('when using refresh tokens', () => { + it('displays an error when trying to get an access token when the RT is missing', () => { + return whenReady().then(win => { + cy.toggleSwitch('local-storage'); + cy.toggleSwitch('use-cache'); + + cy.login().then(() => { + cy.reload(); - it('returns consent_required when using an audience without consent', function(done) { - cy.login().then(() => { - whenReady().then(win => - win.auth0 - .getTokenSilently({ audience: 'https://brucke.auth0.com/api/v2/' }) - .catch(error => { - shouldBe('consent_required', error.error); - done(); - }) - ); + cy.toggleSwitch('refresh-tokens').wait(500); + + cy.get('[data-cy=get-token]') + .click() + .wait(500); + + cy.get('[data-cy=error]').should( + 'contain', + 'No refresh token is available to fetch a new access token' + ); + }); + }); + }); + }); }); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 7d14c4265..b5e346575 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -12,19 +12,36 @@ import { whenReady } from './utils'; // // // -- This is a parent command -- + Cypress.Commands.add('login', () => { cy.get('#login_redirect').click(); cy.get('.auth0-lock-input-username .auth0-lock-input') .clear() .type('johnfoo+integration@gmail.com'); + cy.get('.auth0-lock-input-password .auth0-lock-input') .clear() .type('1234'); cy.get('.auth0-lock-submit').click(); - return whenReady().then(win => win.auth0.handleRedirectCallback()); + + return whenReady().then(() => { + cy.get('#handle_redirect_callback').click(); + return cy.wait(250); + }); }); +Cypress.Commands.add('handleRedirectCallback', () => { + cy.get('#handle_redirect_callback').click(); + return cy.wait(250); +}); + +Cypress.Commands.add('logout', () => cy.get('[data-cy=logout]').click()); + +Cypress.Commands.add('toggleSwitch', name => + cy.get(`[data-cy=switch-${name}]`).click() +); + Cypress.Commands.add('loginNoCallback', () => { cy.get('#login_redirect').click(); @@ -39,5 +56,6 @@ Cypress.Commands.add('loginNoCallback', () => { Cypress.Commands.add('resetTests', () => { cy.visit('http://localhost:3000'); + cy.get('#reset-config').click(); cy.get('#logout').click(); }); diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index 8c7de7bd2..376ad59bd 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -5,7 +5,7 @@ import { createQueryParams, runPopup, parseQueryResult, - encodeState, + encode, createRandomString, runIframe, sha256, @@ -17,7 +17,7 @@ import { import { InMemoryCache, ICache, LocalStorageCache } from './cache'; import TransactionManager from './transaction-manager'; import { verify as verifyIdToken } from './jwt'; -import { AuthenticationError } from './errors'; +import { AuthenticationError, GenericError } from './errors'; import * as ClientStorage from './storage'; import { DEFAULT_POPUP_CONFIG_OPTIONS } from './constants'; import version from './version'; @@ -81,7 +81,13 @@ export default class Auth0Client { code_challenge: string, redirect_uri: string ): AuthorizeOptions { - const { domain, leeway, ...withoutDomain } = this.options; + const { + domain, + leeway, + useRefreshTokens, + cacheStrategy, + ...withoutDomain + } = this.options; return { ...withoutDomain, ...authorizeOptions, @@ -134,13 +140,20 @@ export default class Auth0Client { public async buildAuthorizeUrl( options: RedirectLoginOptions = {} ): Promise { - const { redirect_uri, appState, ...authorizeOptions } = options; - const stateIn = encodeState(createRandomString()); - const nonceIn = createRandomString(); + const { + redirect_uri, + appState, + cacheStrategy, + ...authorizeOptions + } = options; + + const stateIn = encode(createRandomString()); + const nonceIn = encode(createRandomString()); const code_verifier = createRandomString(); const code_challengeBuffer = await sha256(code_verifier); const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer); const fragment = options.fragment ? `#${options.fragment}` : ''; + const params = this._getParams( authorizeOptions, stateIn, @@ -148,7 +161,9 @@ export default class Auth0Client { code_challenge, redirect_uri ); + const url = this._authorizeUrl(params); + this.transactionManager.create(stateIn, { nonce: nonceIn, code_verifier, @@ -156,6 +171,7 @@ export default class Auth0Client { scope: params.scope, audience: params.audience || 'default' }); + return url + fragment; } @@ -181,11 +197,12 @@ export default class Auth0Client { ) { const popup = await openPopup(); const { ...authorizeOptions } = options; - const stateIn = encodeState(createRandomString()); - const nonceIn = createRandomString(); + const stateIn = encode(createRandomString()); + const nonceIn = encode(createRandomString()); const code_verifier = createRandomString(); const code_challengeBuffer = await sha256(code_verifier); const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer); + const params = this._getParams( authorizeOptions, stateIn, @@ -193,22 +210,28 @@ export default class Auth0Client { code_challenge, this.options.redirect_uri || window.location.origin ); + const url = this._authorizeUrl({ ...params, response_mode: 'web_message' }); + const codeResult = await runPopup(popup, url, config); + if (stateIn !== codeResult.state) { throw new Error('Invalid state'); } + const authResult = await oauthToken({ baseUrl: this.domainUrl, - audience: options.audience || this.options.audience, client_id: this.options.client_id, code_verifier, - code: codeResult.code - }); + code: codeResult.code, + grant_type: 'authorization_code' + } as OAuthTokenOptions); + const decodedToken = this._verifyIdToken(authResult.id_token, nonceIn); + const cacheEntry = { ...authResult, decodedToken, @@ -216,7 +239,9 @@ export default class Auth0Client { audience: params.audience || 'default', client_id: this.options.client_id }; + this.cache.save(cacheEntry); + ClientStorage.save('auth0.is.authenticated', true, { daysUntilExpire: 1 }); } @@ -261,7 +286,11 @@ export default class Auth0Client { scope: this.options.scope || this.DEFAULT_SCOPE } ) { - options.scope = getUniqueScopes(this.DEFAULT_SCOPE, options.scope); + options.scope = getUniqueScopes( + this.DEFAULT_SCOPE, + this.options.scope, + options.scope + ); const cache = this.cache.get({ client_id: this.options.client_id, @@ -317,11 +346,11 @@ export default class Auth0Client { const authResult = await oauthToken({ baseUrl: this.domainUrl, - audience: this.options.audience, client_id: this.options.client_id, code_verifier: transaction.code_verifier, - code - }); + code, + grant_type: 'authorization_code' + } as OAuthTokenOptions); const decodedToken = this._verifyIdToken( authResult.id_token, @@ -358,14 +387,17 @@ export default class Auth0Client { * * @param options */ - public async getTokenSilently( - options: GetTokenSilentlyOptions = { + public async getTokenSilently(options: GetTokenSilentlyOptions = {}) { + options = { audience: this.options.audience, - scope: this.options.scope || this.DEFAULT_SCOPE, - ignoreCache: false - } - ) { - options.scope = getUniqueScopes(this.DEFAULT_SCOPE, options.scope); + scope: getUniqueScopes( + this.DEFAULT_SCOPE, + this.options.scope, + options.scope + ), + ignoreCache: false, + ...options + }; try { await lock.acquireLock(GET_TOKEN_SILENTLY_LOCK_KEY, 5000); @@ -383,56 +415,11 @@ export default class Auth0Client { } } - const stateIn = encodeState(createRandomString()); - const nonceIn = createRandomString(); - const code_verifier = createRandomString(); - const code_challengeBuffer = await sha256(code_verifier); - const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer); - - const authorizeOptions = { - audience: options.audience, - scope: options.scope - }; - - const params = this._getParams( - authorizeOptions, - stateIn, - nonceIn, - code_challenge, - this.options.redirect_uri || window.location.origin - ); - - const url = this._authorizeUrl({ - ...params, - prompt: 'none', - response_mode: 'web_message' - }); - - const codeResult = await runIframe(url, this.domainUrl); - - if (stateIn !== codeResult.state) { - throw new Error('Invalid state'); - } - - const authResult = await oauthToken({ - baseUrl: this.domainUrl, - audience: options.audience || this.options.audience, - client_id: this.options.client_id, - code_verifier, - code: codeResult.code - }); - - const decodedToken = this._verifyIdToken(authResult.id_token, nonceIn); - - const cacheEntry = { - ...authResult, - decodedToken, - scope: params.scope, - audience: params.audience || 'default', - client_id: this.options.client_id - }; + const authResult = this.options.useRefreshTokens + ? await this._getTokenUsingRefreshToken(options) + : await this._getTokenFromIFrame(options); - this.cache.save(cacheEntry); + this.cache.save({ client_id: this.options.client_id, ...authResult }); ClientStorage.save('auth0.is.authenticated', true, { daysUntilExpire: 1 @@ -519,4 +506,89 @@ export default class Auth0Client { const url = this._url(`/v2/logout?${createQueryParams(logoutOptions)}`); window.location.assign(`${url}${federatedQuery}`); } + + private async _getTokenFromIFrame( + options: GetTokenSilentlyOptions + ): Promise { + const stateIn = encode(createRandomString()); + const nonceIn = encode(createRandomString()); + const code_verifier = createRandomString(); + const code_challengeBuffer = await sha256(code_verifier); + const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer); + + const authorizeOptions = { + audience: options.audience, + scope: options.scope + }; + + const params = this._getParams( + authorizeOptions, + stateIn, + nonceIn, + code_challenge, + this.options.redirect_uri || window.location.origin + ); + + const url = this._authorizeUrl({ + ...params, + prompt: 'none', + response_mode: 'web_message' + }); + + const codeResult = await runIframe(url, this.domainUrl); + + if (stateIn !== codeResult.state) { + throw new Error('Invalid state'); + } + + const tokenResult = await oauthToken({ + baseUrl: this.domainUrl, + client_id: this.options.client_id, + code_verifier, + code: codeResult.code, + grant_type: 'authorization_code' + } as OAuthTokenOptions); + + const decodedToken = this._verifyIdToken(tokenResult.id_token, nonceIn); + + return { + ...tokenResult, + decodedToken, + scope: params.scope, + audience: params.audience || 'default' + }; + } + + private async _getTokenUsingRefreshToken( + options: GetTokenSilentlyOptions + ): Promise { + const cache = this.cache.get({ + scope: options.scope, + audience: options.audience || 'default', + client_id: this.options.client_id + }); + + if (!cache || !cache.refresh_token) { + throw new GenericError( + 'missing_refresh_token', + 'No refresh token is available to fetch a new access token. The user should be reauthenticated.' + ); + } + + const tokenResult = await oauthToken({ + baseUrl: this.domainUrl, + client_id: this.options.client_id, + grant_type: 'refresh_token', + refresh_token: cache.refresh_token + } as RefreshTokenOptions); + + const decodedToken = this._verifyIdToken(tokenResult.id_token); + + return { + ...tokenResult, + decodedToken, + scope: options.scope, + audience: options.audience || 'default' + }; + } } diff --git a/src/cache.ts b/src/cache.ts index cca46c84c..6cba73f6c 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -17,6 +17,7 @@ interface CacheEntry { audience: string; scope: string; client_id: string; + refresh_token?: string; } interface CachedTokens { diff --git a/src/errors.ts b/src/errors.ts index d169e2842..ba23c6a15 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,10 +1,13 @@ -export class AuthenticationError extends Error { - constructor( - public error: string, - public error_description: string, - public state: string - ) { +export class GenericError extends Error { + constructor(public error: string, public error_description: string) { super(error_description); + + Object.setPrototypeOf(this, GenericError.prototype); + } +} +export class AuthenticationError extends GenericError { + constructor(error: string, error_description: string, public state: string) { + super(error, error_description); //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work Object.setPrototypeOf(this, AuthenticationError.prototype); } diff --git a/src/global.ts b/src/global.ts index 8d40afa36..1c91dd245 100644 --- a/src/global.ts +++ b/src/global.ts @@ -92,10 +92,16 @@ interface Auth0ClientOptions extends BaseLoginOptions { leeway?: number; /** - * The strategy to use when storing cache data. Valid values are `memory` or `localstorage`. + * The location to use when storing cache data. Valid values are `memory` or `localstorage`. * The default setting is `memory`. */ cacheLocation?: 'memory' | 'localstorage'; + + /** + * If true, refresh tokens are used to fetch new access tokens from the Auth0 server. If false, the legacy technique of using a hidden iframe and the `authorization_code` grant with `prompt=none` is used. + * The default setting is 'false'. + */ + useRefreshTokens?: boolean; } /** @@ -169,7 +175,7 @@ interface getIdTokenClaimsOptions { audience: string; } -interface GetTokenSilentlyOptions extends GetUserOptions { +interface GetTokenSilentlyOptions { /** * When `true`, ignores the cache and always sends a * request to Auth0. @@ -186,6 +192,16 @@ interface GetTokenSilentlyOptions extends GetUserOptions { */ redirect_uri?: string; + /** + * The scope that was used in the authentication request + */ + scope?: string; + + /** + * The audience that was used in the authentication request + */ + audience?: string; + /** * If you need to send custom parameters to the Authorization Server, * make sure to use the original parameter name. @@ -231,17 +247,27 @@ interface AuthenticationResult { error_description?: string; } +interface TokenEndpointOptions { + baseUrl: string; + client_id: string; + grant_type: string; +} + /** * @ignore */ -interface OAuthTokenOptions { - baseUrl: string; - client_id: string; - audience?: string; +interface OAuthTokenOptions extends TokenEndpointOptions { code_verifier: string; code: string; } +/** + * @ignore + */ +interface RefreshTokenOptions extends TokenEndpointOptions { + refresh_token: string; +} + /** * @ignore */ diff --git a/src/index.ts b/src/index.ts index ccc605b98..c9883071c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,11 +10,15 @@ import * as ClientStorage from './storage'; //this is necessary to export the type definitions used in this file import './global'; -import { validateCrypto } from './utils'; +import { validateCrypto, getUniqueScopes } from './utils'; export default async function createAuth0Client(options: Auth0ClientOptions) { validateCrypto(); + if (options.useRefreshTokens) { + options.scope = getUniqueScopes(options.scope, 'offline_access'); + } + const auth0 = new Auth0Client(options); if (!ClientStorage.get('auth0.is.authenticated')) { diff --git a/src/utils.ts b/src/utils.ts index 1bc56d246..061c3812b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -105,8 +105,8 @@ export const createRandomString = () => { return random; }; -export const encodeState = (state: string) => btoa(state); -export const decodeState = (state: string) => atob(state); +export const encode = (value: string) => btoa(value); +export const decode = (value: string) => atob(value); export const createQueryParams = (params: any) => { return Object.keys(params) @@ -187,11 +187,13 @@ const getJSON = async (url, options) => { return success; }; -export const oauthToken = async ({ baseUrl, ...options }: OAuthTokenOptions) => +export const oauthToken = async ({ + baseUrl, + ...options +}: TokenEndpointOptions) => await getJSON(`${baseUrl}/oauth/token`, { method: 'POST', body: JSON.stringify({ - grant_type: 'authorization_code', redirect_uri: window.location.origin, ...options }), diff --git a/static/index.html b/static/index.html index ff72b108a..024e961dc 100644 --- a/static/index.html +++ b/static/index.html @@ -33,13 +33,21 @@

Auth0 SPA JS Playground

Login redirect -
- @@ -57,7 +65,12 @@

Auth0 SPA JS Playground

- @@ -66,6 +79,12 @@

Auth0 SPA JS Playground

+ +
@@ -83,7 +102,7 @@

Auth0 SPA JS Playground

Access Tokens
    -
  • {{token}}
  • +
  • {{token}}
@@ -124,13 +143,53 @@

Auth0 SPA JS Playground

id="storage-switch" v-model="useLocalStorage" /> - - - + + @@ -144,44 +203,51 @@

Auth0 SPA JS Playground

var app = new Vue({ el: '#app', - data() { + data: function() { + var savedData = localStorage.getItem('spa-playground-data'); + + var data = savedData ? JSON.parse(savedData) : {}; + return { auth0: null, loading: true, - useLocalStorage: false, + useLocalStorage: data.useLocalStorage || false, + useRefreshTokens: data.useRefreshTokens || false, + useCache: data.useCache === false ? false : true, profile: null, access_tokens: [], id_token: '', isAuthenticated: false, - domain: defaultDomain, - clientId: defaultClientId + domain: data.domain || defaultDomain, + clientId: data.clientId || defaultClientId, + error: null }; }, - created() { + created: function() { this.initializeClient(); - - var savedData = localStorage.getItem('spa-playground-data'); - - if (savedData) { - var data = JSON.parse(savedData); - this.domain = data.domain; - this.clientId = data.clientId; - this.useLocalStorage = data.useLocalStorage || false; - } }, watch: { - useLocalStorage() { + useLocalStorage: function() { + this.initializeClient(); + this.saveForm(); + }, + useRefreshTokens: function() { this.initializeClient(); + this.saveForm(); + }, + useCache: function() { + this.saveForm(); } }, methods: { - initializeClient() { + initializeClient: function() { var _self = this; createAuth0Client({ domain: _self.domain, client_id: _self.clientId, - cacheLocation: _self.useLocalStorage ? 'localstorage' : 'memory' + cacheLocation: _self.useLocalStorage ? 'localstorage' : 'memory', + useRefreshTokens: _self.useRefreshTokens }).then(function(auth0) { _self.auth0 = auth0; window.auth0 = auth0; // Cypress integration tests support @@ -192,22 +258,27 @@

Auth0 SPA JS Playground

}); }); }, - saveForm() { + saveForm: function() { localStorage.setItem( 'spa-playground-data', JSON.stringify({ domain: this.domain, clientId: this.clientId, - useLocalStorage: this.useLocalStorage + useLocalStorage: this.useLocalStorage, + useRefreshTokens: this.useRefreshTokens, + useCache: this.useCache }) ); }, - resetForm() { + resetForm: function() { this.domain = defaultDomain; this.clientId = defaultClientId; + this.useLocalStorage = false; + this.useRefreshTokens = false; + this.useCache = true; this.saveForm(); }, - showAuth0Info() { + showAuth0Info: function() { var _self = this; _self.access_tokens = []; @@ -223,7 +294,7 @@

Auth0 SPA JS Playground

_self.id_token = claims.__raw; }); }, - loginPopup() { + loginPopup: function() { var _self = this; _self.auth0 @@ -234,12 +305,12 @@

Auth0 SPA JS Playground

_self.showAuth0Info(); }); }, - loginRedirect() { + loginRedirect: function() { this.auth0.loginWithRedirect({ redirect_uri: 'http://localhost:3000/' }); }, - loginHandleRedirectCallback() { + loginHandleRedirectCallback: function() { var _self = this; _self.auth0.handleRedirectCallback().then(function() { @@ -249,17 +320,32 @@

Auth0 SPA JS Playground

window.location.origin + '/' ); - _self.showAuth0Info(); + auth0.isAuthenticated().then(function(isAuthenticated) { + _self.isAuthenticated = isAuthenticated; + _self.showAuth0Info(); + }); }); }, - getToken() { + getToken: function() { var _self = this; - _self.auth0.getTokenSilently().then(function(token) { - _self.access_tokens.push(token); - }); + _self.auth0 + .getTokenSilently({ ignoreCache: !_self.useCache }) + .then(function(token) { + _self.access_tokens.push(token); + _self.error = null; + }) + .catch(function(e) { + console.error(e); + + if (e.message) { + _self.error = e.message; + } else { + _self.error = e; + } + }); }, - getMultipleTokens() { + getMultipleTokens: function() { var _self = this; Promise.all([ @@ -269,7 +355,7 @@

Auth0 SPA JS Playground

_self.access_tokens = [tokens[0], tokens[1]]; }); }, - getTokenPopup() { + getTokenPopup: function() { var _self = this; _self.auth0 @@ -281,13 +367,14 @@

Auth0 SPA JS Playground

_self.access_tokens = [token]; }); }, - getTokenAudience() { + getTokenAudience: function() { var _self = this; var differentAudienceOptions = { audience: 'https://brucke.auth0.com/api/v2/', scope: 'read:rules', - redirect_uri: 'http://localhost:3000/callback.html' + redirect_uri: 'http://localhost:3000/callback.html', + ignoreCache: !_self.useCache }; _self.auth0 @@ -296,12 +383,12 @@

Auth0 SPA JS Playground

_self.access_tokens = [token]; }); }, - logout() { + logout: function() { this.auth0.logout({ returnTo: 'http://localhost:3000/' }); }, - logoutNoClient() { + logoutNoClient: function() { this.auth0.logout({ client_id: null, returnTo: 'http://localhost:3000/'