Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
bakatz committed Sep 3, 2023
1 parent 279dc79 commit f6ff5f3
Show file tree
Hide file tree
Showing 1,987 changed files with 652,062 additions and 0 deletions.
180 changes: 180 additions & 0 deletions __tests__/index.spec.js
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)
})
164 changes: 164 additions & 0 deletions index.js
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)
}
}
1 change: 1 addition & 0 deletions node_modules/.bin/acorn

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/esbuild

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/nanoid

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/rollup

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/vite

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/vite-node

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/vitest

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions node_modules/.bin/why-is-node-running

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit f6ff5f3

Please sign in to comment.