Compare commits

..

7 Commits

Author SHA1 Message Date
Pilou
d8d1423158 Merge pull request #577 from nhost/changeset-release/main
chore: update versions
2022-05-19 13:42:24 +02:00
github-actions[bot]
e95881089b chore: update versions 2022-05-19 10:04:56 +00:00
Szilárd Dóró
8726458df9 Merge pull request #572 from nhost/chore/auth-test-coverage
chore: Auth test coverage
2022-05-19 12:04:04 +02:00
Szilárd Dóró
6c4233948d added patch notes 2022-05-19 10:49:44 +02:00
Szilárd Dóró
c16f630a7b Added refreshIntervalTime related tests 2022-05-19 10:05:03 +02:00
Szilárd Dóró
4ecde10b99 Simplified time based token refresh tests
Also removed unnecessary error checks from auth machine
2022-05-18 15:49:26 +02:00
Szilárd Dóró
0530bac1f1 Token auto-refresh tests
- Added token auto-refresh related tests
- Simplified test context initialization
2022-05-18 15:02:02 +02:00
29 changed files with 319 additions and 72 deletions

View File

@@ -1,5 +1,11 @@
# @nhost/apollo
## 0.5.6
### Patch Changes
- @nhost/nhost-js@1.1.13
## 0.5.5
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/apollo",
"version": "0.5.5",
"version": "0.5.6",
"description": "Nhost Apollo Client library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,11 @@
# @nhost/core
## 0.5.6
### Patch Changes
- 6c423394: Improved authentication state machine logic
## 0.5.5
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/core",
"version": "0.5.5",
"version": "0.5.6",
"description": "Nhost core client library",
"license": "MIT",
"keywords": [

View File

@@ -535,7 +535,7 @@ export const createAuthMachine = ({
}
}),
saveMfaTicket: assign({
mfa: (_, e: any) => e.data?.mfa ?? null
mfa: (_, e: any) => e.data?.mfa
}),
resetTimer: assign({
@@ -637,7 +637,7 @@ export const createAuthMachine = ({
if (refreshIntervalTime) {
// * If a refreshIntervalTime has been passed on as an option, it will notify
// * the token should be refershed when this interval is overdue
const elapsed = Date.now() - (ctx.refreshTimer.startedAt?.getTime() || 0)
const elapsed = Date.now() - ctx.refreshTimer.startedAt!.getTime()
if (elapsed > refreshIntervalTime * 1_000) {
return true
}

View File

@@ -5,13 +5,12 @@ import { AuthClient } from '../src/client'
import { INVALID_EMAIL_ERROR } from '../src/errors'
import { createAuthMachine, createChangeEmailMachine } from '../src/machines'
import { Typegen0 } from '../src/machines/change-email.typegen'
import { INITIAL_MACHINE_CONTEXT } from '../src/machines/context'
import { BASE_URL } from './helpers/config'
import { changeEmailInternalErrorHandler, changeEmailNetworkErrorHandler } from './helpers/handlers'
import contextWithUser from './helpers/mocks/contextWithUser'
import server from './helpers/server'
import CustomClientStorage from './helpers/storage'
import { GeneralChangeEmailState } from './helpers/types'
import fakeUser from './helpers/__mocks__/user'
type ChangeEmailState = GeneralChangeEmailState<Typegen0>
@@ -23,15 +22,10 @@ const authClient = new AuthClient({
})
authClient.interpreter = interpret(
createAuthMachine({ backendUrl: BASE_URL, clientUrl: 'http://localhost:3000' }).withContext({
...INITIAL_MACHINE_CONTEXT,
user: fakeUser,
accessToken: {
value: faker.datatype.string(40),
expiresAt: faker.date.future()
},
refreshToken: { value: faker.datatype.uuid() }
})
createAuthMachine({
backendUrl: BASE_URL,
clientUrl: 'http://localhost:3000'
}).withContext(contextWithUser)
).start()
const changeEmailMachine = createChangeEmailMachine(authClient)

View File

@@ -5,16 +5,15 @@ import { AuthClient } from '../src/client'
import { INVALID_PASSWORD_ERROR } from '../src/errors'
import { createAuthMachine, createChangePasswordMachine } from '../src/machines'
import { Typegen0 } from '../src/machines/change-password.typegen'
import { INITIAL_MACHINE_CONTEXT } from '../src/machines/context'
import { BASE_URL } from './helpers/config'
import {
changePasswordInternalErrorHandler,
changePasswordNetworkErrorHandler
} from './helpers/handlers'
import contextWithUser from './helpers/mocks/contextWithUser'
import server from './helpers/server'
import CustomClientStorage from './helpers/storage'
import { GeneralChangePasswordState } from './helpers/types'
import fakeUser from './helpers/__mocks__/user'
type ChangePasswordState = GeneralChangePasswordState<Typegen0>
@@ -26,15 +25,10 @@ const authClient = new AuthClient({
})
authClient.interpreter = interpret(
createAuthMachine({ backendUrl: BASE_URL, clientUrl: 'http://localhost:3000' }).withContext({
...INITIAL_MACHINE_CONTEXT,
user: fakeUser,
accessToken: {
value: faker.datatype.string(40),
expiresAt: faker.date.future()
},
refreshToken: { value: faker.datatype.uuid() }
})
createAuthMachine({
backendUrl: BASE_URL,
clientUrl: 'http://localhost:3000'
}).withContext(contextWithUser)
).start()
const changePasswordMachine = createChangePasswordMachine(authClient)

View File

@@ -1,15 +1,10 @@
import faker from '@faker-js/faker'
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { interpret, Interpreter } from 'xstate'
import { interpret } from 'xstate'
import { waitFor } from 'xstate/lib/waitFor'
import { AuthClient } from '../src/client'
import {
INVALID_MFA_CODE_ERROR,
INVALID_MFA_TICKET_ERROR,
INVALID_MFA_TYPE_ERROR
} from '../src/errors'
import { INVALID_MFA_CODE_ERROR, INVALID_MFA_TYPE_ERROR } from '../src/errors'
import { createAuthMachine, createEnableMfaMachine } from '../src/machines'
import { INITIAL_MACHINE_CONTEXT } from '../src/machines/context'
import { Typegen0 } from '../src/machines/enable-mfa.typegen'
import { BASE_URL } from './helpers/config'
import {
@@ -20,10 +15,10 @@ import {
generateMfaTotpNetworkErrorHandler,
generateMfaTotpUnauthorizedErrorHandler
} from './helpers/handlers'
import contextWithUser from './helpers/mocks/contextWithUser'
import server from './helpers/server'
import CustomClientStorage from './helpers/storage'
import { GeneralEnableMfaState } from './helpers/types'
import fakeUser from './helpers/__mocks__/user'
type EnableMfaState = GeneralEnableMfaState<Typegen0>
@@ -41,15 +36,7 @@ authClient.interpreter = interpret(
clientUrl: 'http://localhost:3000',
clientStorageType: 'custom',
clientStorage: customStorage
}).withContext({
...INITIAL_MACHINE_CONTEXT,
user: fakeUser,
accessToken: {
value: faker.datatype.string(40),
expiresAt: faker.date.future()
},
refreshToken: { value: faker.datatype.uuid() }
})
}).withContext(contextWithUser)
).start()
describe(`Generation`, () => {

View File

@@ -2,7 +2,7 @@ import faker from '@faker-js/faker'
import { rest } from 'msw'
import { NhostSession } from '../../../src/types'
import { BASE_URL } from '../config'
import fakeUser from '../__mocks__/user'
import fakeUser from '../mocks/user'
/**
* Request handler for MSW to mock a successful request for a new access token.

View File

@@ -2,7 +2,7 @@ import faker from '@faker-js/faker'
import { rest } from 'msw'
import { Mfa, NhostSession } from '../../../src/types'
import { BASE_URL } from '../config'
import fakeUser from '../__mocks__/user'
import fakeUser from '../mocks/user'
/**
* Request handler for MSW to mock a network error when trying to sign in using the MFA TOTP sign in

View File

@@ -2,7 +2,7 @@ import faker from '@faker-js/faker'
import { rest } from 'msw'
import { Mfa, NhostSession } from '../../../src/types'
import { BASE_URL } from '../config'
import fakeUser from '../__mocks__/user'
import fakeUser from '../mocks/user'
/**
* Request handler for MSW to mock a successful sign in request when using the email and password

View File

@@ -2,7 +2,7 @@ import faker from '@faker-js/faker'
import { rest } from 'msw'
import { Mfa, NhostSession } from '../../../src/types'
import { BASE_URL } from '../config'
import fakeUser from '../__mocks__/user'
import fakeUser from '../mocks/user'
/**
* Request handler for MSW to mock a successful sign in request using the passwordless email sign in

View File

@@ -2,7 +2,7 @@ import faker from '@faker-js/faker'
import { rest } from 'msw'
import { Mfa, NhostSession } from '../../../src/types'
import { BASE_URL } from '../config'
import fakeUser from '../__mocks__/user'
import fakeUser from '../mocks/user'
/**
* Request handler for MSW to mock a network error when trying to sign up.

View File

@@ -0,0 +1,17 @@
import faker from '@faker-js/faker'
import { AuthContext, INITIAL_MACHINE_CONTEXT } from '../../../src/machines/context'
import fakeUser from './user'
export const contextWithUser: AuthContext = {
...INITIAL_MACHINE_CONTEXT,
accessToken: {
value: faker.datatype.string(40),
expiresAt: faker.date.future()
},
refreshToken: {
value: faker.datatype.uuid()
},
user: fakeUser
}
export default contextWithUser

View File

@@ -3,7 +3,6 @@ import { interpret } from 'xstate'
import { waitFor } from 'xstate/lib/waitFor'
import { INVALID_EMAIL_ERROR, INVALID_PASSWORD_ERROR } from '../src/errors'
import { createAuthMachine } from '../src/machines'
import { INITIAL_MACHINE_CONTEXT } from '../src/machines/context'
import { Typegen0 } from '../src/machines/index.typegen'
import { BASE_URL } from './helpers/config'
import {
@@ -13,10 +12,11 @@ import {
incorrectEmailPasswordHandler,
unverifiedEmailErrorHandler
} from './helpers/handlers'
import contextWithUser from './helpers/mocks/contextWithUser'
import fakeUser from './helpers/mocks/user'
import server from './helpers/server'
import CustomClientStorage from './helpers/storage'
import { GeneralAuthState } from './helpers/types'
import fakeUser from './helpers/__mocks__/user'
type AuthState = GeneralAuthState<Typegen0>
@@ -245,18 +245,8 @@ test(`should succeed if correct credentials are provided`, async () => {
test(`should transition to signed in state if user is already signed in`, async () => {
const user = { ...fakeUser }
const accessToken = faker.datatype.string(40)
const refreshToken = faker.datatype.uuid()
const expiresAt = new Date(Date.now() * 900000)
const authServiceWithInitialUser = interpret(
authMachine.withContext({
...INITIAL_MACHINE_CONTEXT,
user,
accessToken: { value: accessToken, expiresAt },
refreshToken: { value: refreshToken }
})
)
const authServiceWithInitialUser = interpret(authMachine.withContext(contextWithUser))
authServiceWithInitialUser.start()

View File

@@ -2,7 +2,11 @@ import faker from '@faker-js/faker'
import { afterAll, afterEach, beforeAll, beforeEach, describe, test, vi } from 'vitest'
import { BaseActionObject, interpret, Interpreter, ResolveTypegenMeta, ServiceMap } from 'xstate'
import { waitFor } from 'xstate/lib/waitFor'
import { NHOST_JWT_EXPIRES_AT_KEY, NHOST_REFRESH_TOKEN_KEY } from '../src/constants'
import {
NHOST_JWT_EXPIRES_AT_KEY,
NHOST_REFRESH_TOKEN_KEY,
TOKEN_REFRESH_MARGIN
} from '../src/constants'
import { INVALID_REFRESH_TOKEN } from '../src/errors'
import { AuthContext, AuthEvents, createAuthMachine } from '../src/machines'
import { Typegen0 } from '../src/machines/index.typegen'
@@ -12,13 +16,190 @@ import {
authTokenNetworkErrorHandler,
authTokenUnauthorizedHandler
} from './helpers/handlers'
import contextWithUser from './helpers/mocks/contextWithUser'
import fakeUser from './helpers/mocks/user'
import server from './helpers/server'
import CustomClientStorage from './helpers/storage'
import { GeneralAuthState } from './helpers/types'
import fakeUser from './helpers/__mocks__/user'
type AuthState = GeneralAuthState<Typegen0>
describe(`Time based token refresh`, () => {
const initialToken = faker.datatype.uuid()
const initialExpiration = faker.date.future()
const customStorage = new CustomClientStorage(new Map())
const authMachineWithInitialSession = createAuthMachine({
backendUrl: BASE_URL,
clientUrl: 'http://localhost:3000',
clientStorage: customStorage,
clientStorageType: 'custom',
autoSignIn: false
}).withContext({
...contextWithUser,
accessToken: {
value: initialToken,
expiresAt: initialExpiration
}
})
const authServiceWithInitialSession = interpret(authMachineWithInitialSession).start()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterAll(() => server.close())
beforeEach(() => {
customStorage.setItem(NHOST_JWT_EXPIRES_AT_KEY, faker.date.future().toISOString())
customStorage.setItem(NHOST_REFRESH_TOKEN_KEY, faker.datatype.uuid())
authServiceWithInitialSession.start()
})
afterEach(() => {
authServiceWithInitialSession.stop()
customStorage.clear()
server.resetHandlers()
})
test(`token refresh should fail if the signed-in user's refresh token was invalid`, async () => {
server.use(authTokenUnauthorizedHandler)
// Fast forwarding to initial expiration date
vi.setSystemTime(initialExpiration)
await waitFor(authServiceWithInitialSession, (state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'refreshing' } } } })
)
const state: AuthState = await waitFor(authServiceWithInitialSession, (state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'pending' } } } })
)
expect(state.context.refreshTimer.attempts).toBeGreaterThan(0)
})
test(`access token should always be refreshed when reaching the expiration margin`, async () => {
// Fast forward to the initial expiration date
vi.setSystemTime(new Date(initialExpiration.getTime() - TOKEN_REFRESH_MARGIN * 1000))
await waitFor(authServiceWithInitialSession, (state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'refreshing' } } } })
)
const firstRefreshState: AuthState = await waitFor(
authServiceWithInitialSession,
(state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'pending' } } } })
)
const firstRefreshAccessToken = firstRefreshState.context.accessToken.value
const firstRefreshAccessTokenExpiration = firstRefreshState.context.accessToken.expiresAt
expect(firstRefreshAccessToken).not.toBeNull()
expect(firstRefreshAccessToken).not.toBe(initialToken)
expect(firstRefreshAccessTokenExpiration.getTime()).toBeGreaterThan(initialExpiration.getTime())
// Fast forward to the expiration date of the access token
vi.setSystemTime(
new Date(firstRefreshAccessTokenExpiration.getTime() - TOKEN_REFRESH_MARGIN * 1000)
)
await waitFor(authServiceWithInitialSession, (state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'refreshing' } } } })
)
const secondRefreshState: AuthState = await waitFor(
authServiceWithInitialSession,
(state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'pending' } } } })
)
const secondRefreshAccessToken = secondRefreshState.context.accessToken.value
const secondRefreshAccessTokenExpiration = secondRefreshState.context.accessToken.expiresAt
expect(secondRefreshAccessToken).not.toBeNull()
expect(secondRefreshAccessToken).not.toBe(firstRefreshAccessToken)
expect(secondRefreshAccessTokenExpiration.getTime()).toBeGreaterThan(
firstRefreshAccessTokenExpiration.getTime()
)
// Fast forward to a time when the access token is still valid, so nothing should be refreshed
vi.setSystemTime(
new Date(secondRefreshAccessTokenExpiration.getTime() - TOKEN_REFRESH_MARGIN * 5 * 1000)
)
const thirdRefreshState: AuthState = await waitFor(
authServiceWithInitialSession,
(state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'pending' } } } })
)
const thirdRefreshAccessToken = thirdRefreshState.context.accessToken.value
const thirdRefreshAccessTokenExpiration = thirdRefreshState.context.accessToken.expiresAt
expect(thirdRefreshAccessToken).toBe(secondRefreshAccessToken)
expect(thirdRefreshAccessTokenExpiration.getTime()).toBe(
thirdRefreshAccessTokenExpiration.getTime()
)
})
test(`token should be refreshed every N seconds based on the refresh interval`, async () => {
const refreshIntervalTime = faker.datatype.number({ min: 800, max: 900 })
const authMachineWithInitialSession = createAuthMachine({
backendUrl: BASE_URL,
clientUrl: 'http://localhost:3000',
clientStorage: customStorage,
clientStorageType: 'custom',
refreshIntervalTime,
autoSignIn: false
}).withContext({
...contextWithUser,
accessToken: {
value: initialToken,
expiresAt: initialExpiration
}
})
const authServiceWithInitialSession = interpret(authMachineWithInitialSession).start()
// Fast N seconds to the refresh interval
vi.setSystemTime(new Date(Date.now() + refreshIntervalTime * 1000))
await waitFor(authServiceWithInitialSession, (state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'refreshing' } } } })
)
const firstRefreshState: AuthState = await waitFor(
authServiceWithInitialSession,
(state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'pending' } } } })
)
expect(firstRefreshState.context.accessToken.value).not.toBeNull()
expect(firstRefreshState.context.accessToken.value).not.toBe(initialToken)
// Fast N seconds to the refresh interval
vi.setSystemTime(new Date(Date.now() + refreshIntervalTime * 1000))
await waitFor(authServiceWithInitialSession, (state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'refreshing' } } } })
)
const secondRefreshState: AuthState = await waitFor(
authServiceWithInitialSession,
(state: AuthState) =>
state.matches({ authentication: { signedIn: { refreshTimer: { running: 'pending' } } } })
)
expect(secondRefreshState.context.accessToken.value).not.toBeNull()
expect(secondRefreshState.context.accessToken.value).not.toBe(
firstRefreshState.context.accessToken.value
)
authServiceWithInitialSession.stop()
})
})
describe('General and disabled auto-sign in', () => {
const customStorage = new CustomClientStorage(new Map())
@@ -209,7 +390,8 @@ describe(`Auto sign-in`, () => {
vi.restoreAllMocks()
})
test(`should throw an error if "error" was in the URL`, async () => {
test(`should throw an error if "error" was in the URL when opening the application`, async () => {
// Scenario 1: Testing when `errorDescription` is provided.
windowSpy.mockImplementation(() => ({
...originalWindow,
location: {
@@ -220,11 +402,11 @@ describe(`Auto sign-in`, () => {
authService.start()
const state: AuthState = await waitFor(authService, (state: AuthState) =>
const firstState: AuthState = await waitFor(authService, (state: AuthState) =>
state.matches({ authentication: { signedOut: 'noErrors' } })
)
expect(state.context.errors).toMatchInlineSnapshot(`
expect(firstState.context.errors).toMatchInlineSnapshot(`
{
"authentication": {
"error": "invalid-refresh-token",
@@ -233,6 +415,33 @@ describe(`Auto sign-in`, () => {
},
}
`)
authService.stop()
// Scenario 2: Testing when `errorDescription` is not provided.
windowSpy.mockImplementation(() => ({
...originalWindow,
location: {
...originalWindow.location,
href: `http://localhost:3000/?error=${INVALID_REFRESH_TOKEN.error}`
}
}))
authService.start()
const secondState: AuthState = await waitFor(authService, (state: AuthState) =>
state.matches({ authentication: { signedOut: 'noErrors' } })
)
expect(secondState.context.errors).toMatchInlineSnapshot(`
{
"authentication": {
"error": "invalid-refresh-token",
"message": "invalid-refresh-token",
"status": 10,
},
}
`)
})
test(`should fail if network is unavailable`, async () => {

View File

@@ -1,5 +1,12 @@
# @nhost/hasura-auth-js
## 1.1.8
### Patch Changes
- Updated dependencies [6c423394]
- @nhost/core@0.5.6
## 1.1.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/hasura-auth-js",
"version": "1.1.7",
"version": "1.1.8",
"description": "Hasura-auth client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,14 @@
# @nhost/nextjs
## 1.2.7
### Patch Changes
- Updated dependencies [6c423394]
- @nhost/core@0.5.6
- @nhost/react@0.7.7
- @nhost/nhost-js@1.1.13
## 1.2.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/nextjs",
"version": "1.2.6",
"version": "1.2.7",
"description": "Nhost NextJS library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,11 @@
# @nhost/nhost-js
## 1.1.13
### Patch Changes
- @nhost/hasura-auth-js@1.1.8
## 1.1.12
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/nhost-js",
"version": "1.1.12",
"version": "1.1.13",
"description": "Nhost JavaScript SDK",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,12 @@
# @nhost/react-apollo
## 4.2.7
### Patch Changes
- @nhost/react@0.7.7
- @nhost/apollo@0.5.6
## 4.2.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-apollo",
"version": "4.2.6",
"version": "4.2.7",
"description": "Nhost React Apollo client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,12 @@
# @nhost/react-auth
## 3.0.5
### Patch Changes
- @nhost/hasura-auth-js@1.1.8
- @nhost/react@0.7.7
## 3.0.4
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-auth",
"version": "3.0.4",
"version": "3.0.5",
"description": "Nhost React client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,13 @@
# @nhost/react
## 0.7.7
### Patch Changes
- Updated dependencies [6c423394]
- @nhost/core@0.5.6
- @nhost/nhost-js@1.1.13
## 0.7.6
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react",
"version": "0.7.6",
"version": "0.7.7",
"description": "Nhost React library",
"license": "MIT",
"keywords": [