Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refresh tokens #7

Merged
merged 11 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
coverage/
dist/
# dist/
elenik72 marked this conversation as resolved.
Show resolved Hide resolved
yarn-*.log
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ And after that you redirect to the callback route `<CALLBACK_URL>` with query st
try {
const authorizationCode = "12345"

const response = await auth.getTokens(authorizationCode) // return Promise
const response = await auth.fetchTokens(authorizationCode) // return Promise
const tokens = response.json()

const { accessToken, refreshToken } = tokens
Expand Down
780 changes: 780 additions & 0 deletions dist/auther-client.cjs.js

Large diffs are not rendered by default.

777 changes: 777 additions & 0 deletions dist/auther-client.es.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cadolabs/auther-client",
"version": "0.2.0",
"version": "1.0.0",
"main": "dist/auther-client.cjs.js",
"module": "dist/auther-client.es.js",
"description": "Client for working with auther on the frontend side",
Expand Down
72 changes: 70 additions & 2 deletions src/client/AutherClient.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { verify, decode } from "../lib/jwt"

const ONE_MINUTE_MS = 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000

export class AutherClient {
#location = window.location

Expand All @@ -6,11 +11,12 @@ export class AutherClient {
#REFRESH_PATH = "/tokens/refresh"
#LOGIN_PATH = "/login"

constructor ({ redirectUri, autherUrl, http, appcode }) {
constructor ({ redirectUri, autherUrl, http, appcode, logger }) {
this.redirectUri = redirectUri
this.autherUrl = autherUrl
this.http = http
this.appcode = appcode
this.logger = logger
}

#buildOauthUrl = () => {
Expand All @@ -23,6 +29,56 @@ export class AutherClient {
return redirectUrl.toString()
}

#refreshTokens = async ({ getTokens, saveTokens }) => {
const currentTime = `${new Date()} [${new Date().toUTCString()}]`
const { refreshToken } = getTokens()

verify(refreshToken)

const response = await this.updateTokens(refreshToken)
const tokens = await response.json()

saveTokens(tokens)

this.logger.log(`Token has been refreshed successfully at ${currentTime}`)
}

#scheduleTokensRefreshing = ({ getTokens, saveTokens }) => {
const { accessToken } = getTokens()

verify(accessToken)

const decodedToken = decode(accessToken)
const tokenExpDateMs = decodedToken.payload.exp * 1000
let refreshTimeout = (tokenExpDateMs - new Date()) / 2

if (refreshTimeout < ONE_MINUTE_MS) {
refreshTimeout = ONE_MINUTE_MS
}

if (refreshTimeout > ONE_DAY_MS) {
elenik72 marked this conversation as resolved.
Show resolved Hide resolved
refreshTimeout = ONE_DAY_MS
}

const tokenExpDate = new Date(tokenExpDateMs)
const refreshDate = new Date(Date.now() + refreshTimeout)

this.logger.log(`Token will expire at ${(tokenExpDate)} [${tokenExpDate.toUTCString()}]`)
this.logger.log(`Token will be refreshed at ${refreshDate} [${refreshDate.toUTCString()}]`)

setTimeout(async () => {
try {
await this.#refreshTokens({ getTokens, saveTokens })
this.#scheduleTokensRefreshing({ getTokens, saveTokens })
}
catch (error) {
this.logger.error(
`Error during tokens refreshing at ${new Date()} [${new Date().toUTCString()}]`,
)
}
}, refreshTimeout)
}

login = () => {
return this.#location.replace(this.#buildOauthUrl())
}
Expand All @@ -38,7 +94,7 @@ export class AutherClient {
})
}

getTokens = authorizationCode => {
fetchTokens = authorizationCode => {
if (!authorizationCode) {
throw new Error("invalid.authorization_code")
}
Expand All @@ -56,4 +112,16 @@ export class AutherClient {

return this.http({ path: this.#REFRESH_PATH, body: { refreshToken } })
}

authentication = async ({ getTokens, saveTokens }) => {
const { accessToken } = getTokens()
try {
verify(accessToken)
}
catch (err) {
await this.#refreshTokens({ getTokens, saveTokens })
}

this.#scheduleTokensRefreshing({ getTokens, saveTokens })
}
}
3 changes: 2 additions & 1 deletion src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { doFetch } from "../lib/http"
import { AutherClient } from "./AutherClient"

export default class {
static init ({ redirectUri, autherUrl, appcode }) {
static init ({ redirectUri, autherUrl, appcode, logger = console }) {
return new AutherClient({
http: doFetch(autherUrl),
redirectUri,
autherUrl,
appcode,
logger,
})
}
}
3 changes: 2 additions & 1 deletion src/lib/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ const verify = token => {
}

const { payload } = decode(token)
const currentTime = new Date().getTime()

if (!payload.iat || !payload.exp) {
throw new Error("token.invalid")
}

const currentTime = new Date().getTime()

if (currentTime > payload.exp * 1000) {
throw new Error("token.expired")
}
Expand Down
204 changes: 199 additions & 5 deletions tests/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,56 @@ const APPCODE = "appcode"
const AUTHER_URL = "http://localhost/"
const TEST_AUTHORIZATION_CODE = "75e1cece-2991-4be5-9fb4-c6968e5f3311"

const createAutherClient = () => {
const createAutherClient = (params = {}) => {
return AutherClient.init({
autherUrl: AUTHER_URL,
redirectUri: RETURN_URI,
appcode: APPCODE,
scope: "test-scope",
http: doFetch(AUTHER_URL),
...params,
})
}

const getTokenPayload = expDate => {
const expiredAt = expDate ? new Date(expDate) : new Date()
const issuedAt = new Date(expiredAt)
if (!expDate) {
expiredAt.setHours(expiredAt.getHours() + 1)
}
const iat = issuedAt.getTime() / 1000 // in seconds
const exp = expiredAt.getTime() / 1000 // in seconds
return { iat, exp }
}

const getToken = (params = {}) => {
const { expDate, type } = params
const payload = btoa(JSON.stringify({ ...getTokenPayload(expDate), type }))
const header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
const signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
const jwtLikeToken = `${header}.${payload}.${signature}`
return jwtLikeToken
}

const getAccessToken = expDate => {
return getToken({ type: "access", expDate })
}

const getRefreshToken = expDate => {
return getToken({ type: "refresh", expDate })
}

const getAuthCallbacks = ({ accessTokenExpDate, refreshTokenExpDate } = {}) => {
let accessToken = getAccessToken(accessTokenExpDate)
let refreshToken = getRefreshToken(refreshTokenExpDate)
const getTokens = () => ({ accessToken, refreshToken })
const saveTokens = (tokens = {}) => {
if (tokens.accessToken) accessToken = tokens.accessToken
if (tokens.refreshToken) refreshToken = tokens.refreshToken
}

return { getTokens, saveTokens }
}

describe("When use auther methods", () => {
it("should trigger a redirect", () => {
const returnUrl = new URL(RETURN_URI)
Expand Down Expand Up @@ -83,7 +123,7 @@ describe("When use auther methods", () => {
it("should make a request to get tokens", async () => {
const auth = createAutherClient()

const response = await auth.getTokens(TEST_AUTHORIZATION_CODE)
const response = await auth.fetchTokens(TEST_AUTHORIZATION_CODE)

const expectedUrl = "http://localhost/tokens/initiate"
const expectedBody = JSON.stringify({ authorization_code: TEST_AUTHORIZATION_CODE })
Expand All @@ -99,6 +139,160 @@ describe("When use auther methods", () => {
headers: expectedHeaders,
})
})

describe("authenticate method", () => {
beforeEach(() => {
jest.useFakeTimers()
})

it("should authenticate with fresh tokens", async () => {
fetch.mockResponseOnce(JSON.stringify({
accessToken: getAccessToken(),
refreshToken: getRefreshToken(),
}))

const auth = createAutherClient()
const callbacks = getAuthCallbacks()

await expect(() => auth.authentication(callbacks)).not.toThrowError()

jest.runOnlyPendingTimers()

const expectedUrl = "http://localhost/tokens/refresh"
const expectedBody = JSON.stringify({ refreshToken: callbacks.getTokens().refreshToken })
const expectedHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
}

expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "POST",
body: expectedBody,
headers: expectedHeaders,
})
})

it("should authenticate with fresh refresh token and expired access token", async () => {
fetch.mockResponseOnce(JSON.stringify({
accessToken: getAccessToken(),
refreshToken: getRefreshToken(),
}))

const auth = createAutherClient()
const expiredDate = new Date()
expiredDate.setHours(expiredDate.getHours() - 3)

const callbacks = getAuthCallbacks({ accessTokenExpDate: expiredDate })
await expect(() => auth.authentication(callbacks)).not.toThrowError()

jest.runOnlyPendingTimers()

const expectedUrl = "http://localhost/tokens/refresh"
const expectedBody = JSON.stringify({ refreshToken: callbacks.getTokens().refreshToken })
const expectedHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
}

expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "POST",
body: expectedBody,
headers: expectedHeaders,
})
})

it("should log error when refresh token expired", async () => {
fetch.mockResponseOnce(JSON.stringify({
accessToken: getAccessToken(),
refreshToken: getRefreshToken(),
}))
const mockLogger = { log: jest.fn(), error: jest.fn() }

const auth = createAutherClient({ logger: mockLogger })
const expiredDate = new Date()
expiredDate.setHours(expiredDate.getHours() - 3)
const callbacks = getAuthCallbacks({ refreshTokenExpDate: expiredDate })

await expect(() => auth.authentication(callbacks)).not.toThrowError()

jest.runAllTimers()

mockLogger.error.mockImplementationOnce(() => {
expect(mockLogger.error).toHaveBeenCalledWith(
`Error during tokens refreshing at ${new Date()} [${new Date().toUTCString()}]`,
)
})
})

it("should throw error when both tokens expired", async () => {
const auth = createAutherClient()
const expiredDate = new Date()
expiredDate.setHours(expiredDate.getHours() - 3)
const callbacks = getAuthCallbacks({
accessTokenExpDate: expiredDate,
refreshTokenExpDate: expiredDate,
})

await expect(() => auth.authentication(callbacks))
.rejects
.toThrow("token.expired")

expect(fetch).not.toHaveBeenCalled()
})

it("should log error when server fails to refresh tokens", async () => {
const mockLogger = { log: jest.fn(), error: jest.fn() }
const auth = createAutherClient({ logger: mockLogger })

fetch.mockRejectOnce()

const callbacks = getAuthCallbacks()

await expect(() => auth.authentication(callbacks)).not.toThrowError()

jest.runAllTimers()

mockLogger.error.mockImplementationOnce(() => {
expect(mockLogger.error).toHaveBeenCalledWith(
`Error during tokens refreshing at ${new Date()} [${new Date().toUTCString()}]`,
)
})
})

it("should set min refreshTimeout to one minute", async () => {
const mockLogger = { log: jest.fn() }

const auth = createAutherClient({ logger: mockLogger })
const expiredDate = new Date()
expiredDate.setSeconds(expiredDate.getSeconds() + 30)
const callbacks = getAuthCallbacks({ accessTokenExpDate: expiredDate })

await auth.authentication(callbacks)

const expectedRefreshDate = new Date(Date.now() + 60 * 1000) // one minute

expect(mockLogger.log).toHaveBeenCalledWith(
`Token will be refreshed at ${expectedRefreshDate} [${expectedRefreshDate.toUTCString()}]`,
)
})

it("should set max refreshTimeout to one day", async () => {
const mockLogger = { log: jest.fn() }

const auth = createAutherClient({ logger: mockLogger })
const expiredDate = new Date()
expiredDate.setDate(expiredDate.getDate() + 10)
const callbacks = getAuthCallbacks({ accessTokenExpDate: expiredDate })

await auth.authentication(callbacks)

const expectedRefreshDate = new Date(Date.now() + 24 * 60 * 60 * 1000) // one day

expect(mockLogger.log).toHaveBeenCalledWith(
`Token will be refreshed at ${expectedRefreshDate} [${expectedRefreshDate.toUTCString()}]`,
)
})
})
})

describe("When params is missing", () => {
Expand All @@ -112,9 +306,9 @@ describe("When params is missing", () => {

it("should show an error invalid authorization code", async () => {
const auth = createAutherClient()
const getTokens = () => auth.getTokens(undefined)
const fetchTokens = () => auth.fetchTokens(undefined)

expect(getTokens).toThrow("invalid.authorization_code")
expect(fetchTokens).toThrow("invalid.authorization_code")
expect(fetchMock).not.toHaveBeenCalled()
})

Expand Down
Loading