-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
import { expect, test, vi } from 'vitest' | ||
import { AuthStatusCode, SignInStatusCode, SimpleOTP } from '../index' | ||
import http from 'axios' | ||
|
||
test('new SimpleOTP() throws when the siteID is invalid', () => { | ||
const invalidFuncs = [() => new SimpleOTP(), () => new SimpleOTP(''), () => new SimpleOTP(1), () => new SimpleOTP(null), () => new SimpleOTP(undefined)] | ||
for (let invalidFunc of invalidFuncs) { | ||
expect(invalidFunc).toThrow() | ||
} | ||
}) | ||
|
||
test('new SimpleOTP() throws when the apiURL is invalid', () => { | ||
const invalidFuncs = [() => new SimpleOTP('site', 'notaurl'), () => new SimpleOTP('site', '1'), () => new SimpleOTP('site', 1)] | ||
for (let invalidFunc of invalidFuncs) { | ||
expect(invalidFunc).toThrow() | ||
} | ||
}) | ||
|
||
test('new SimpleOTP() initializes the constructor params and the user key when the params are valid with a default apiURL', () => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
|
||
expect(simpleOTP).toBeTruthy() | ||
expect(simpleOTP.apiURL).toBe('https://api.simpleotp.com') | ||
expect(simpleOTP.simpleOTPUserKey).toBe('simpleotp_user:mocksiteid') | ||
}) | ||
|
||
test('new SimpleOTP() initializes the constructor params and the user key when the params are valid with a custom apiURL', () => { | ||
const simpleOTP = new SimpleOTP('mocksiteid', 'https://google.com') | ||
|
||
expect(simpleOTP).toBeTruthy() | ||
expect(simpleOTP.apiURL).toBe('https://google.com') | ||
expect(simpleOTP.simpleOTPUserKey).toBe('simpleotp_user:mocksiteid') | ||
}) | ||
|
||
test('signIn() throws when the email is invalid', async () => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
expect(async () => await simpleOTP.signIn()).rejects.toThrow() | ||
expect(async () => await simpleOTP.signIn(2)).rejects.toThrow() | ||
expect(async () => await simpleOTP.signIn('')).rejects.toThrow() | ||
}) | ||
|
||
test('signIn() returns the code and message when the http call succeeds', async () => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
return { data: { code: 'ok', message: 'all good' } } | ||
}) | ||
|
||
const resp = await simpleOTP.signIn('[email protected]') | ||
expect(resp.code).toBe('ok') | ||
expect(resp.message).toBe('all good') | ||
}) | ||
|
||
test('signIn() returns the code and message when the http call fails', async () => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
throw { response: { data: { code: 'uh_oh', message: 'oh noes' } } } | ||
}) | ||
|
||
const resp = await simpleOTP.signIn('[email protected]') | ||
expect(resp.code).toBe('uh_oh') | ||
expect(resp.message).toBe('oh noes') | ||
}) | ||
|
||
test('signIn() returns a networking error when the http call does not have a response prop', async () => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
throw { response2: { data: { code: 'uh_oh', message: 'oh noes' } } } | ||
}) | ||
|
||
const resp = await simpleOTP.signIn('[email protected]') | ||
expect(resp.code).toBe(SignInStatusCode.NetworkingError.description) | ||
expect(resp.message).toBe('Could not connect to the server. Try again in a few moments.') | ||
}) | ||
|
||
|
||
test('authWithURLCode() updates localStorage with the user details when the http call succeeds', async() => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
return { data: { data: { email: '[email protected]', token: 'reallysecuretoken' } } } | ||
}) | ||
|
||
window.location = { search: '?simpleotp_code=reallysecurecode'} | ||
|
||
const authResponse = await simpleOTP.authWithURLCode('[email protected]') | ||
expect(authResponse).toBeTruthy() | ||
expect(authResponse.data).toBeTruthy() | ||
expect(authResponse.data.email).toBe('[email protected]') | ||
expect(authResponse.data.token).toBe('reallysecuretoken') | ||
|
||
// Make sure the user saved to local storage has the same props as the user returned above | ||
expect(simpleOTP.getUser().email).toBe(authResponse.data.email) | ||
expect(simpleOTP.getUser().token).toBe(authResponse.data.token) | ||
expect(simpleOTP.isAuthenticated()).toBe(true) | ||
}) | ||
|
||
test('authWithURLCode() throws when the code is missing from the url params', async() => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
simpleOTP.signOut() | ||
mockPost.mockImplementation(() => { | ||
return { data: { data: { email: '[email protected]', token: 'reallysecuretoken' } } } | ||
}) | ||
|
||
window.location = { search: '?not_a_simpleotp_code=reallysecurecode'} | ||
|
||
expect(async () => await simpleOTP.authWithURLCode('[email protected]')).rejects.toThrow() | ||
expect(simpleOTP.getUser()).toBeNull() | ||
}) | ||
|
||
test('authWithURLCode() returns the error code in the response when the http call returns an error response', async() => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
throw { | ||
response: { | ||
data: { | ||
code: 'invalid_auth_code', | ||
message: 'bad auth code', | ||
data: { email: '[email protected]', token: 'reallysecuretoken' } | ||
} | ||
} | ||
} | ||
}) | ||
|
||
window.location = { search: '?simpleotp_code=reallysecurecode'} | ||
|
||
const res = await simpleOTP.authWithURLCode('[email protected]') | ||
expect(res.code).toBe('invalid_auth_code') | ||
expect(res.message).toBe('bad auth code') | ||
|
||
expect(simpleOTP.getUser()).toBeNull() | ||
expect(simpleOTP.isAuthenticated()).toBe(false) | ||
}) | ||
|
||
test('authWithURLCode() returns a networking error in the response when the http call response does not have a response prop', async() => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
throw { | ||
response2: { | ||
data: { | ||
code: 'invalid_auth_code', | ||
message: 'bad auth code', | ||
data: { email: '[email protected]', token: 'reallysecuretoken' } | ||
} | ||
} | ||
} | ||
}) | ||
|
||
window.location = { search: '?simpleotp_code=reallysecurecode'} | ||
|
||
const res = await simpleOTP.authWithURLCode('[email protected]') | ||
expect(res.code).toBe(AuthStatusCode.NetworkingError.description) | ||
expect(res.message).toBe('Could not connect to the server. Try again in a few moments.') | ||
|
||
expect(simpleOTP.getUser()).toBeNull() | ||
expect(simpleOTP.isAuthenticated()).toBe(false) | ||
}) | ||
|
||
test('isAuthenticated() returns false after the user is signed out', async() => { | ||
const simpleOTP = new SimpleOTP('mocksiteid') | ||
const mockPost = vi.spyOn(http, 'post') | ||
mockPost.mockImplementation(() => { | ||
return { data: { data: { email: '[email protected]', token: 'reallysecuretoken' } } } | ||
}) | ||
|
||
window.location = { search: '?simpleotp_code=reallysecurecode'} | ||
|
||
const user = await simpleOTP.authWithURLCode('[email protected]') | ||
expect(user).toBeTruthy() | ||
expect(simpleOTP.getUser()).toBeTruthy() | ||
expect(simpleOTP.isAuthenticated()).toBe(true) | ||
simpleOTP.signOut() | ||
expect(simpleOTP.isAuthenticated()).toBe(false) | ||
expect(simpleOTP.getUser()).toBe(null) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
import http from 'axios' | ||
|
||
const SIMPLEOTP_USER_PREFIX = 'simpleotp_user' | ||
const SIMPLEOTP_CODE_PARAM_NAME = 'simpleotp_code' | ||
const DEFAULT_API_URL = 'https://api.simpleotp.com' | ||
|
||
const NETWORKING_ERROR_MESSAGE = 'Could not connect to the server. Try again in a few moments.' | ||
|
||
export const SignInStatusCode = Object.freeze({ | ||
OK: Symbol('ok'), | ||
Unauthorized: Symbol('unauthorized'), | ||
InvalidEmail: Symbol('invalid_email'), | ||
InternalServerError: Symbol('internal_server_error'), | ||
InvalidSite: Symbol('invalid_site'), | ||
SiteNotFound: Symbol('site_not_found'), | ||
NetworkingError: Symbol('networking_error') | ||
}) | ||
|
||
export const AuthStatusCode = Object.freeze({ | ||
OK: Symbol('ok'), | ||
CodeNotFound: Symbol('code_not_found'), | ||
InvalidAuthCode: Symbol('invalid_auth_code'), | ||
InternalServerError: Symbol('internal_server_error'), | ||
NetworkingError: Symbol('networking_error') | ||
}) | ||
|
||
export class AuthenticatedUser { | ||
/** | ||
* | ||
* @param {string} email | ||
* @param {string} token | ||
*/ | ||
constructor (email, token) { | ||
this.email = email | ||
this.token = token | ||
} | ||
} | ||
|
||
class SiteSignInResponse { | ||
constructor(code, message) { | ||
this.code = code | ||
this.message = message | ||
} | ||
} | ||
|
||
class SiteAuthResponse { | ||
constructor(code, message, data) { | ||
this.code = code | ||
this.message = message | ||
this.data = data | ||
} | ||
} | ||
|
||
function isValidURL(urlString) { | ||
try { | ||
return Boolean(new URL(urlString)) | ||
} catch (e) { | ||
return false | ||
} | ||
} | ||
|
||
export class SimpleOTP { | ||
constructor(siteID, apiURL = null) { | ||
if (!siteID || typeof(siteID) !== 'string') { | ||
throw Error('siteID must be a non-empty string') | ||
} | ||
|
||
if (apiURL && !isValidURL(apiURL)) { | ||
throw Error('apiURL must be a valid URL if defined') | ||
} | ||
|
||
this.siteID = siteID | ||
this.simpleOTPUserKey = `${SIMPLEOTP_USER_PREFIX}:${this.siteID}` | ||
if (apiURL) { | ||
this.apiURL = apiURL | ||
} else { | ||
this.apiURL = DEFAULT_API_URL | ||
} | ||
} | ||
|
||
/** | ||
* Sends a magic sign-in link to the given email address | ||
* Note that this method does not authenticate the user - the user has to click the magic link and you must call authWithURLCode() when your authentication page loads. | ||
* @returns {Promise<APIResponse>} | ||
*/ | ||
async signIn(email) { | ||
if (!email || typeof(email) !== 'string') { | ||
throw Error('email must be a non-empty string') | ||
} | ||
|
||
try { | ||
const response = await http.post(`${this.apiURL}/v1/sites/${this.siteID}/sign-in`, { email }) | ||
const responseData = response.data | ||
return new SiteSignInResponse(responseData.code, responseData.message) | ||
} catch (e) { | ||
const responseData = e.response?.data | ||
if (responseData) { | ||
return new SiteSignInResponse(responseData.code, responseData.message) | ||
} else { | ||
return new SiteSignInResponse(SignInStatusCode.NetworkingError.description, NETWORKING_ERROR_MESSAGE) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Authenticates a user based on the code supplied in the URL and returns a User with an auth token if the code was found. | ||
* The User is also saved in localStorage so that you can reference it elsewhere in the app. Use getUser() for this purpose. | ||
* @returns {Promise<SiteAuthResponse>} | ||
*/ | ||
async authWithURLCode() { | ||
const urlParams = new URLSearchParams(window.location.search) | ||
const code = urlParams.get(SIMPLEOTP_CODE_PARAM_NAME) | ||
if (!code) { | ||
throw Error(SIMPLEOTP_CODE_PARAM_NAME + ' was not found in the url params.') | ||
} | ||
|
||
let response = null | ||
try { | ||
response = await http.post(`${this.apiURL}/v1/sites/${this.siteID}/auth`, { code }) | ||
} catch(e) { | ||
const errorHTTPResponseData = e.response?.data | ||
if (errorHTTPResponseData) { | ||
return new SiteAuthResponse(errorHTTPResponseData.code, errorHTTPResponseData.message, errorHTTPResponseData.data) | ||
} else { | ||
return new SiteAuthResponse(SignInStatusCode.NetworkingError.description, NETWORKING_ERROR_MESSAGE, null) | ||
} | ||
} | ||
|
||
const httpResponseData = response.data | ||
const apiResponseData = httpResponseData.data | ||
const user = new AuthenticatedUser(apiResponseData.email, apiResponseData.token) | ||
localStorage.setItem(this.simpleOTPUserKey, JSON.stringify(user)) | ||
return new SiteAuthResponse(httpResponseData.code, httpResponseData.message, httpResponseData.data) | ||
} | ||
|
||
/** | ||
* Fetches the currently authenticated user, if any, from localStorage and returns it. If there isn't an authenticated user, | ||
* this function returns null. | ||
* @returns {AuthenticatedUser} | ||
*/ | ||
getUser() { | ||
const user = localStorage.getItem(this.simpleOTPUserKey) | ||
if (!user) { | ||
return null | ||
} | ||
const userObj = JSON.parse(user) | ||
return new AuthenticatedUser(userObj.email, userObj.token) | ||
} | ||
|
||
/** | ||
* Returns true if the user is authenticated, false otherwise. | ||
* @returns {Boolean} | ||
*/ | ||
isAuthenticated() { | ||
return Boolean(localStorage.getItem(this.simpleOTPUserKey)) | ||
} | ||
|
||
/** | ||
* Removes the User from localStorage, thereby signing the user out. | ||
*/ | ||
signOut() { | ||
localStorage.removeItem(this.simpleOTPUserKey) | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.