├── .gitignore ├── README.md ├── package.json ├── src ├── Auth.test.ts ├── Auth.ts ├── NhostClient.ts ├── Storage.ts ├── UserSession.ts ├── index.ts ├── test │ ├── .env │ ├── .gitignore │ ├── custom │ │ ├── emails │ │ │ ├── activate-account │ │ │ │ ├── html.ejs │ │ │ │ └── subject.ejs │ │ │ ├── change-email │ │ │ │ ├── html.ejs │ │ │ │ └── subject.ejs │ │ │ ├── lost-password │ │ │ │ ├── html.ejs │ │ │ │ └── subject.ejs │ │ │ ├── magic-link │ │ │ │ ├── html.ejs │ │ │ │ └── subject.ejs │ │ │ └── notify-email-change │ │ │ │ ├── html.ejs │ │ │ │ └── subject.ejs │ │ ├── keys │ │ │ └── .gitkeep │ │ └── storage-rules │ │ │ └── rules.yaml │ ├── docker-compose.yaml │ ├── globalSetup.ts │ ├── globalTeardown.ts │ ├── migrations │ │ └── 1_init │ │ │ ├── down.yaml │ │ │ ├── up.sql │ │ │ └── up.yaml │ └── test-utils.ts ├── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | yarn-error.log 5 | package-lock.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Deprecation notice 2 | 3 | This repository is the source code of the Nhost V1 SDK and is **deprecated** and not actively maintained. New SDK version can be found on the main [nhost-js repository](https://github.com/nhost/nhost-js). 4 | 5 | # Nhost JS SDK 6 | 7 | Nhost JS SDK to handle **Auth** and **Storage** with [Nhost](https://nhost.io). 8 | 9 | ## Install 10 | 11 | `$ npm install nhost-js-sdk` 12 | 13 | or 14 | 15 | `$ yarn add nhost-js-sdk` 16 | 17 | ## Documentation 18 | 19 | [https://docs.nhost.io/libraries/nhost-js-sdk](https://docs.nhost.io/libraries/nhost-js-sdk) 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nhost-js-sdk", 3 | "version": "3.1.0", 4 | "description": "Nhost JS SDK", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "/dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "watch": "tsc -w", 13 | "test": "jest --runInBand --coverage", 14 | "version": "tsc" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/nhost/nhost-js-sdk.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/nhost/nhost-js-sdk/issues" 22 | }, 23 | "author": "Nhost", 24 | "license": "MIT", 25 | "dependencies": { 26 | "@types/jwt-decode": "^2.2.1", 27 | "axios": "^0.21.1", 28 | "jwt-decode": "^2.2.0", 29 | "query-string": "^6.13.1" 30 | }, 31 | "devDependencies": { 32 | "@types/fs-extra": "^9.0.8", 33 | "@types/jest": "^26.0.0", 34 | "docker-compose": "^0.23.4", 35 | "fs-extra": "^9.0.1", 36 | "jest": "^26.6.3", 37 | "jest-extended": "^0.11.5", 38 | "ts-jest": "^26.1.0", 39 | "typescript": "^3.9.5" 40 | }, 41 | "jest": { 42 | "verbose": true, 43 | "testEnvironment": "jsdom", 44 | "globalSetup": "./src/test/globalSetup.ts", 45 | "globalTeardown": "./src/test/globalTeardown.ts", 46 | "preset": "ts-jest" 47 | }, 48 | "np": { 49 | "yarn": true, 50 | "contents": "src/dist" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Auth.test.ts: -------------------------------------------------------------------------------- 1 | import "jest-extended"; 2 | import { nhost, auth } from "./test/test-utils"; 3 | 4 | jest.useFakeTimers("modern"); 5 | 6 | it("should register first user", async () => { 7 | await expect( 8 | auth.register({ email: "user-1@nhost.io", password: "password-1" }) 9 | ).toResolve(); 10 | }); 11 | 12 | it("should register second user", async () => { 13 | await expect( 14 | auth.register({ email: "user-2@nhost.io", password: "password-2" }) 15 | ).toResolve(); 16 | }); 17 | 18 | it("should register a magic link user when magic link mode is enabled", async () => { 19 | await nhost.withEnv({ 20 | ENABLE_MAGIC_LINK: 'true', 21 | }, async () => { 22 | await expect( 23 | auth.register({ email: "magic-link-user@nhost.io" }) 24 | ).toResolve(); 25 | }, { 26 | ENABLE_MAGIC_LINK: 'false' 27 | }) 28 | }); 29 | 30 | it("should not register a magic link user when magic link mode is disabled", async () => { 31 | await nhost.withEnv({ 32 | ENABLE_MAGIC_LINK: 'false', 33 | }, async () => { 34 | await expect( 35 | auth.register({ email: "magic-link-user@nhost.io" }) 36 | ).toReject(); 37 | }) 38 | }); 39 | 40 | it("should not be able to register same user twice", async () => { 41 | await expect( 42 | auth.register({ email: "user-2@nhost.io", password: "password-2" }) 43 | ).toReject(); 44 | }); 45 | 46 | it("should not be able to register user with invalid email", async () => { 47 | await expect( 48 | auth.register({ email: "invalid-email.com", password: "password" }) 49 | ).toReject(); 50 | }); 51 | 52 | it("should not be able to register without a password", async () => { 53 | await expect( 54 | auth.register({ email: "invalid-email.com", password: "" }) 55 | ).toReject(); 56 | }); 57 | 58 | it("should not be able to register without an email", async () => { 59 | await expect(auth.register({ email: "", password: "password" })).toReject(); 60 | }); 61 | 62 | it("should not be able to register without an email and password", async () => { 63 | await expect(auth.register({ email: "", password: "" })).toReject(); 64 | }); 65 | 66 | it("should not be able to register with a short password", async () => { 67 | await expect( 68 | auth.register({ email: "user-1@nhost.io", password: "" }) 69 | ).toReject(); 70 | }); 71 | 72 | it("should not be able to login with wrong password", async () => { 73 | await expect( 74 | auth.login({ email: "user-1@nhost.io", password: "wrong-password-1" }) 75 | ).toReject(); 76 | }); 77 | 78 | it("should be able to login with correct password", async () => { 79 | await expect( 80 | auth.login({ email: "user-1@nhost.io", password: "password-1" }) 81 | ).toResolve(); 82 | }); 83 | 84 | it("should be able to retreive JWT Token", async () => { 85 | const JWTToken = auth.getJWTToken(); 86 | expect(JWTToken).toBeString(); 87 | }); 88 | 89 | it("should be able to get user id as JWT claim", async () => { 90 | const userId = auth.getClaim("x-hasura-user-id"); 91 | expect(userId).toBeString(); 92 | }); 93 | 94 | it("should be authenticated", async () => { 95 | await expect(auth.isAuthenticated()).toBe(true); 96 | }); 97 | 98 | it("should be abele to logout", async () => { 99 | await expect(auth.logout()).toResolve(); 100 | }); 101 | 102 | it("should be able to logout twice", async () => { 103 | await expect(auth.logout()).toResolve(); 104 | }); 105 | 106 | it("should not be authenticated", async () => { 107 | await expect(auth.isAuthenticated()).toBe(false); 108 | }); 109 | 110 | it("should not be able to retreive JWT token after logout", () => { 111 | 112 | const JWTToken = auth.getJWTToken(); 113 | expect(JWTToken).toBeNull(); 114 | }); 115 | 116 | it("should not be able to retreive JWT claim after logout", () => { 117 | expect(auth.getClaim("x-hasura-user-id")).toBeNull(); 118 | }); 119 | 120 | it("should be able to login without a password when magic link mode is enabled", async () => { 121 | await nhost.withEnv({ 122 | ENABLE_MAGIC_LINK: 'true' 123 | }, async () => { 124 | await expect( 125 | auth.login({ email: "magic-link-user@nhost.io" }) 126 | ).toResolve(); 127 | }, { 128 | ENABLE_MAGIC_LINK: 'false' 129 | }) 130 | }); 131 | 132 | it("should not be able to login with an empty string password when magic link mode is enabled", async () => { 133 | await nhost.withEnv({ 134 | ENABLE_MAGIC_LINK: 'true' 135 | }, async () => { 136 | await expect( 137 | auth.login({ email: "magic-link-user@nhost.io", password: '' }) 138 | ).toReject(); 139 | }, { 140 | ENABLE_MAGIC_LINK: 'false' 141 | }) 142 | }); 143 | 144 | it("should not be able to login without a password when magic link mode is disabled", async () => { 145 | await nhost.withEnv({ 146 | ENABLE_MAGIC_LINK: 'false' 147 | }, async () => { 148 | await expect( 149 | auth.login({ email: "magic-link-user@nhost.io" }) 150 | ).toReject(); 151 | }) 152 | }); 153 | 154 | describe("testing onAuthStateChanged", () => { 155 | let authStateVar: boolean; 156 | 157 | const unsubscribe = auth.onAuthStateChanged((d: boolean) => { 158 | authStateVar = d; 159 | }); 160 | 161 | it("login should set authStateVar to true", async () => { 162 | await auth.login({ email: "user-1@nhost.io", password: "password-1" }); 163 | expect(authStateVar).toBe(true); 164 | }); 165 | 166 | it("logout should set authStateVar to false", async () => { 167 | await auth.logout(); 168 | expect(authStateVar).toBe(false); 169 | }); 170 | 171 | it("unsubscribe auth state changes, login, authStateVar should be unchanged", async () => { 172 | unsubscribe(); 173 | await auth.login({ email: "user-1@nhost.io", password: "password-1" }); 174 | expect(authStateVar).toBe(false); 175 | }); 176 | }); 177 | 178 | describe.skip("Refresh time interval", () => { 179 | it("should retreive new jwt token after 3000 seconds based on automatic refresh interval", async () => { 180 | jest.useFakeTimers(); 181 | 182 | await auth.login({ email: "user-1@nhost.io", password: "password-1" }); 183 | 184 | const jwt_token = auth.getJWTToken(); 185 | 186 | jest.advanceTimersByTime(960000); // 16 min 187 | 188 | const newJWTToken = auth.getJWTToken(); 189 | 190 | expect(newJWTToken).not.toBe(jwt_token); 191 | }); 192 | 193 | it("should retreive new jwt token after 3000 seconds based on automatic refresh interval", async () => { 194 | jest.useFakeTimers(); 195 | 196 | let tokenStateVar = 0; 197 | auth.onTokenChanged(() => { 198 | tokenStateVar++; 199 | }); 200 | 201 | await auth.login({ email: "user-1@nhost.io", password: "password-1" }); 202 | 203 | expect(tokenStateVar).toBe(1); 204 | jest.advanceTimersByTime(960000); // 16 min 205 | expect(tokenStateVar).toBe(2); 206 | }); 207 | }); 208 | 209 | describe("password change", () => { 210 | it("Should be able to logout and login", async () => { 211 | auth.logout(); 212 | await expect( 213 | auth.login({ email: "user-1@nhost.io", password: "password-1" }) 214 | ).toResolve(); 215 | }); 216 | 217 | it("should be able to change password", async () => { 218 | auth.logout(); 219 | await auth.login({ email: "user-1@nhost.io", password: "password-1" }); 220 | await expect( 221 | auth.changePassword("password-1", "password-1-new") 222 | ).toResolve(); 223 | }); 224 | 225 | it("should be able to logout and login with new password", async () => { 226 | auth.logout(); 227 | await expect( 228 | auth.login({ email: "user-1@nhost.io", password: "password-1-new" }) 229 | ).toResolve(); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /src/Auth.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import queryString from "query-string"; 3 | import * as types from "./types"; 4 | import UserSession from "./UserSession"; 5 | 6 | export type AuthChangedFunction = (isAuthenticated: boolean) => void; 7 | 8 | export default class Auth { 9 | private httpClient: AxiosInstance; 10 | private tokenChangedFunctions: Function[]; 11 | private authChangedFunctions: AuthChangedFunction[]; 12 | 13 | private refreshInterval: any; 14 | private useCookies: boolean; 15 | private refreshIntervalTime: number | null; 16 | private clientStorage: types.ClientStorage; 17 | private clientStorageType: string; 18 | 19 | private ssr: boolean | undefined; 20 | private refreshTokenLock: boolean; 21 | private baseURL: string; 22 | private currentUser: types.User | null; 23 | private currentSession: UserSession; 24 | private loading: boolean; 25 | private refreshSleepCheckInterval: any; 26 | private refreshIntervalSleepCheckLastSample: number; 27 | private sampleRate: number; 28 | 29 | constructor(config: types.AuthConfig, session: UserSession) { 30 | const { 31 | baseURL, 32 | useCookies, 33 | refreshIntervalTime, 34 | clientStorage, 35 | clientStorageType, 36 | ssr, 37 | autoLogin, 38 | } = config; 39 | 40 | this.useCookies = useCookies; 41 | this.refreshIntervalTime = refreshIntervalTime; 42 | this.clientStorage = clientStorage; 43 | this.clientStorageType = clientStorageType; 44 | this.tokenChangedFunctions = []; 45 | this.authChangedFunctions = []; 46 | this.refreshInterval; 47 | 48 | this.refreshSleepCheckInterval = 0; 49 | this.refreshIntervalSleepCheckLastSample = Date.now(); 50 | this.sampleRate = 2000; // check every 2 seconds 51 | this.ssr = ssr; 52 | 53 | this.refreshTokenLock = false; 54 | this.baseURL = baseURL; 55 | this.loading = true; 56 | 57 | this.currentUser = null; 58 | this.currentSession = session; 59 | 60 | this.httpClient = axios.create({ 61 | baseURL: `${this.baseURL}/auth`, 62 | timeout: 10000, 63 | withCredentials: this.useCookies, 64 | }); 65 | 66 | // get refresh token from query param (from external OAuth provider callback) 67 | let refreshToken: string | null = null; 68 | 69 | if (!ssr) { 70 | try { 71 | const parsed = queryString.parse(window.location.search); 72 | refreshToken = 73 | "refresh_token" in parsed ? (parsed.refresh_token as string) : null; 74 | 75 | if (refreshToken) { 76 | let newURL = this._removeParam("refresh_token", window.location.href); 77 | try { 78 | window.history.pushState({}, document.title, newURL); 79 | } catch { 80 | // noop 81 | // window object not available 82 | } 83 | } 84 | } catch (e) { 85 | // noop. `window` not available probably. 86 | } 87 | } 88 | 89 | // if empty string, then set it to null 90 | refreshToken = refreshToken ? refreshToken : null; 91 | 92 | if (autoLogin) { 93 | this._autoLogin(refreshToken); 94 | } else if (refreshToken) { 95 | this._setItem("nhostRefreshToken", refreshToken); 96 | } 97 | } 98 | 99 | public user(): types.User | null { 100 | return this.currentUser; 101 | } 102 | 103 | public async register({ 104 | email, 105 | password, 106 | options = {}, 107 | }: types.UserCredentials): Promise<{ 108 | session: types.Session | null; 109 | user: types.User; 110 | }> { 111 | const { userData, defaultRole, allowedRoles } = options; 112 | 113 | const registerOptions = 114 | defaultRole || allowedRoles 115 | ? { 116 | default_role: defaultRole, 117 | allowed_roles: allowedRoles, 118 | } 119 | : undefined; 120 | 121 | let res; 122 | try { 123 | res = await this.httpClient.post("/register", { 124 | email, 125 | password, 126 | cookie: this.useCookies, 127 | user_data: userData, 128 | register_options: registerOptions, 129 | }); 130 | } catch (error) { 131 | throw error; 132 | } 133 | 134 | if (res.data.jwt_token) { 135 | this._setSession(res.data); 136 | 137 | return { session: res.data, user: res.data.user }; 138 | } else { 139 | // if AUTO_ACTIVATE_NEW_USERS is false 140 | return { session: null, user: res.data.user }; 141 | } 142 | } 143 | 144 | public async login({ 145 | email, 146 | password, 147 | provider, 148 | }: types.UserCredentials): Promise<{ 149 | session: types.Session | null; 150 | user: types.User | null; 151 | mfa?: { 152 | ticket: string; 153 | }; 154 | magicLink?: true; 155 | }> { 156 | if (provider) { 157 | window.location.href = `${this.baseURL}/auth/providers/${provider}`; 158 | return { session: null, user: null }; 159 | } 160 | 161 | let res; 162 | try { 163 | res = await this.httpClient.post("/login", { 164 | email, 165 | password, 166 | cookie: this.useCookies, 167 | }); 168 | } catch (error) { 169 | this._clearSession(); 170 | throw error; 171 | } 172 | 173 | if ("mfa" in res.data) { 174 | return { session: null, user: null, mfa: { ticket: res.data.ticket } }; 175 | } 176 | 177 | if ("magicLink" in res.data) { 178 | return { session: null, user: null, magicLink: true }; 179 | } 180 | 181 | this._setSession(res.data); 182 | 183 | return { session: res.data, user: res.data.user }; 184 | } 185 | 186 | public async logout(all: boolean = false): Promise<{ 187 | session: null; 188 | user: null; 189 | }> { 190 | try { 191 | await this.httpClient.post( 192 | "/logout", 193 | { 194 | all, 195 | }, 196 | { 197 | params: { 198 | refresh_token: await this._getItem("nhostRefreshToken"), 199 | }, 200 | } 201 | ); 202 | } catch (error) { 203 | // throw error; 204 | // noop 205 | } 206 | 207 | this._clearSession(); 208 | 209 | return { session: null, user: null }; 210 | } 211 | 212 | public onTokenChanged(fn: Function): Function { 213 | this.tokenChangedFunctions.push(fn); 214 | 215 | // get index; 216 | const tokenChangedFunctionIndex = this.tokenChangedFunctions.length - 1; 217 | 218 | const unsubscribe = () => { 219 | try { 220 | // replace onTokenChanged with empty function 221 | this.tokenChangedFunctions[tokenChangedFunctionIndex] = () => {}; 222 | } catch (err) { 223 | console.warn( 224 | "Unable to unsubscribe onTokenChanged function. Maybe you already did?" 225 | ); 226 | } 227 | }; 228 | 229 | return unsubscribe; 230 | } 231 | 232 | public onAuthStateChanged(fn: AuthChangedFunction): Function { 233 | this.authChangedFunctions.push(fn); 234 | 235 | // get index; 236 | const authStateChangedFunctionIndex = this.authChangedFunctions.length - 1; 237 | 238 | const unsubscribe = () => { 239 | try { 240 | // replace onAuthStateChanged with empty function 241 | this.authChangedFunctions[authStateChangedFunctionIndex] = () => {}; 242 | } catch (err) { 243 | console.warn( 244 | "Unable to unsubscribe onAuthStateChanged function. Maybe you already did?" 245 | ); 246 | } 247 | }; 248 | 249 | return unsubscribe; 250 | } 251 | 252 | public isAuthenticated(): boolean | null { 253 | if (this.loading) return null; 254 | return this.currentSession.getSession() !== null; 255 | } 256 | 257 | public isAuthenticatedAsync(): Promise { 258 | const isAuthenticated = this.isAuthenticated(); 259 | 260 | return new Promise((resolve) => { 261 | if (isAuthenticated !== null) resolve(isAuthenticated); 262 | else { 263 | const unsubscribe = this.onAuthStateChanged((isAuthenticated) => { 264 | resolve(isAuthenticated); 265 | unsubscribe(); 266 | }); 267 | } 268 | }); 269 | } 270 | 271 | public getJWTToken(): string | null { 272 | return this.currentSession.getSession()?.jwt_token || null; 273 | } 274 | 275 | public getClaim(claim: string): string | string[] | null { 276 | return this.currentSession.getClaim(claim); 277 | } 278 | 279 | public async refreshSession(initRefreshToken?: string | null): Promise { 280 | return await this._refreshToken(initRefreshToken); 281 | } 282 | 283 | public async activate(ticket: string): Promise { 284 | await this.httpClient.get(`/activate?ticket=${ticket}`); 285 | } 286 | 287 | public async changeEmail(new_email: string): Promise { 288 | await this.httpClient.post( 289 | "/change-email", 290 | { 291 | new_email, 292 | }, 293 | { 294 | headers: this._generateHeaders(), 295 | } 296 | ); 297 | } 298 | 299 | public async requestEmailChange(new_email: string): Promise { 300 | await this.httpClient.post( 301 | "/change-email/request", 302 | { 303 | new_email, 304 | }, 305 | { 306 | headers: this._generateHeaders(), 307 | } 308 | ); 309 | } 310 | 311 | public async confirmEmailChange(ticket: string): Promise { 312 | await this.httpClient.post("/change-email/change", { 313 | ticket, 314 | }); 315 | } 316 | 317 | public async changePassword( 318 | oldPassword: string, 319 | newPassword: string 320 | ): Promise { 321 | await this.httpClient.post( 322 | "/change-password", 323 | { 324 | old_password: oldPassword, 325 | new_password: newPassword, 326 | }, 327 | { 328 | headers: this._generateHeaders(), 329 | } 330 | ); 331 | } 332 | 333 | public async requestPasswordChange(email: string): Promise { 334 | await this.httpClient.post("/change-password/request", { 335 | email, 336 | }); 337 | } 338 | 339 | public async confirmPasswordChange( 340 | newPassword: string, 341 | ticket: string 342 | ): Promise { 343 | await this.httpClient.post("/change-password/change", { 344 | new_password: newPassword, 345 | ticket, 346 | }); 347 | } 348 | 349 | public async MFAGenerate(): Promise { 350 | const res = await this.httpClient.post( 351 | "/mfa/generate", 352 | {}, 353 | { 354 | headers: this._generateHeaders(), 355 | } 356 | ); 357 | return res.data; 358 | } 359 | 360 | public async MFAEnable(code: string): Promise { 361 | await this.httpClient.post( 362 | "/mfa/enable", 363 | { 364 | code, 365 | }, 366 | { 367 | headers: this._generateHeaders(), 368 | } 369 | ); 370 | } 371 | 372 | public async MFADisable(code: string): Promise { 373 | await this.httpClient.post( 374 | "/mfa/disable", 375 | { 376 | code, 377 | }, 378 | { 379 | headers: this._generateHeaders(), 380 | } 381 | ); 382 | } 383 | 384 | public async MFATotp( 385 | code: string, 386 | ticket: string 387 | ): Promise<{ 388 | session: types.Session; 389 | user: types.User; 390 | }> { 391 | const res = await this.httpClient.post("/mfa/totp", { 392 | code, 393 | ticket, 394 | cookie: this.useCookies, 395 | }); 396 | 397 | this._setSession(res.data); 398 | 399 | return { session: res.data, user: res.data.user }; 400 | } 401 | 402 | private _removeParam(key: string, sourceURL: string) { 403 | var rtn = sourceURL.split("?")[0], 404 | param, 405 | params_arr = [], 406 | queryString = 407 | sourceURL.indexOf("?") !== -1 ? sourceURL.split("?")[1] : ""; 408 | if (queryString !== "") { 409 | params_arr = queryString.split("&"); 410 | for (var i = params_arr.length - 1; i >= 0; i -= 1) { 411 | param = params_arr[i].split("=")[0]; 412 | if (param === key) { 413 | params_arr.splice(i, 1); 414 | } 415 | } 416 | if (params_arr.length > 0) { 417 | rtn = rtn + "?" + params_arr.join("&"); 418 | } 419 | } 420 | return rtn; 421 | } 422 | 423 | private async _setItem(key: string, value: string): Promise { 424 | if (typeof value !== "string") { 425 | console.error(`value is not of type "string"`); 426 | return; 427 | } 428 | 429 | switch (this.clientStorageType) { 430 | case "web": 431 | if (typeof this.clientStorage.setItem !== "function") { 432 | console.error(`this.clientStorage.setItem is not a function`); 433 | break; 434 | } 435 | this.clientStorage.setItem(key, value); 436 | break; 437 | case "custom": 438 | case "react-native": 439 | if (typeof this.clientStorage.setItem !== "function") { 440 | console.error(`this.clientStorage.setItem is not a function`); 441 | break; 442 | } 443 | await this.clientStorage.setItem(key, value); 444 | break; 445 | case "capacitor": 446 | if (typeof this.clientStorage.set !== "function") { 447 | console.error(`this.clientStorage.set is not a function`); 448 | break; 449 | } 450 | await this.clientStorage.set({ key, value }); 451 | break; 452 | case "expo-secure-storage": 453 | if (typeof this.clientStorage.setItemAsync !== "function") { 454 | console.error(`this.clientStorage.setItemAsync is not a function`); 455 | break; 456 | } 457 | this.clientStorage.setItemAsync(key, value); 458 | break; 459 | default: 460 | break; 461 | } 462 | } 463 | 464 | private async _getItem(key: string): Promise { 465 | switch (this.clientStorageType) { 466 | case "web": 467 | if (typeof this.clientStorage.getItem !== "function") { 468 | console.error(`this.clientStorage.getItem is not a function`); 469 | break; 470 | } 471 | return this.clientStorage.getItem(key); 472 | case "custom": 473 | case "react-native": 474 | if (typeof this.clientStorage.getItem !== "function") { 475 | console.error(`this.clientStorage.getItem is not a function`); 476 | break; 477 | } 478 | return await this.clientStorage.getItem(key); 479 | case "capacitor": 480 | if (typeof this.clientStorage.get !== "function") { 481 | console.error(`this.clientStorage.get is not a function`); 482 | break; 483 | } 484 | const res = await this.clientStorage.get({ key }); 485 | return res.value; 486 | case "expo-secure-storage": 487 | if (typeof this.clientStorage.getItemAsync !== "function") { 488 | console.error(`this.clientStorage.getItemAsync is not a function`); 489 | break; 490 | } 491 | return this.clientStorage.getItemAsync(key); 492 | default: 493 | break; 494 | } 495 | } 496 | 497 | private async _removeItem(key: string): Promise { 498 | switch (this.clientStorageType) { 499 | case "web": 500 | if (typeof this.clientStorage.removeItem !== "function") { 501 | console.error(`this.clientStorage.removeItem is not a function`); 502 | break; 503 | } 504 | return this.clientStorage.removeItem(key); 505 | case "custom": 506 | case "react-native": 507 | if (typeof this.clientStorage.removeItem !== "function") { 508 | console.error(`this.clientStorage.removeItem is not a function`); 509 | break; 510 | } 511 | return await this.clientStorage.removeItem(key); 512 | case "capacitor": 513 | if (typeof this.clientStorage.remove !== "function") { 514 | console.error(`this.clientStorage.remove is not a function`); 515 | break; 516 | } 517 | await this.clientStorage.remove({ key }); 518 | break; 519 | case "expo-secure-storage": 520 | if (typeof this.clientStorage.deleteItemAsync !== "function") { 521 | console.error(`this.clientStorage.deleteItemAsync is not a function`); 522 | break; 523 | } 524 | this.clientStorage.deleteItemAsync(key); 525 | break; 526 | default: 527 | break; 528 | } 529 | } 530 | 531 | private _generateHeaders(): null | types.Headers { 532 | if (this.useCookies) return null; 533 | 534 | return { 535 | Authorization: `Bearer ${this.currentSession.getSession()?.jwt_token}`, 536 | }; 537 | } 538 | 539 | private _autoLogin(refreshToken: string | null): void { 540 | if (this.ssr) { 541 | return; 542 | } 543 | 544 | this._refreshToken(refreshToken); 545 | } 546 | 547 | private async _refreshToken(initRefreshToken?: string | null): Promise { 548 | const refreshToken = 549 | initRefreshToken || (await this._getItem("nhostRefreshToken")); 550 | 551 | if (!this.useCookies && !refreshToken) { 552 | // place at end of call-stack to let frontend get `null` first (to match SSR) 553 | setTimeout(() => { 554 | this._clearSession(); 555 | }, 0); 556 | 557 | return; 558 | } 559 | 560 | let res; 561 | try { 562 | // set lock to avoid two refresh token request being sent at the same time with the same token. 563 | // If so, the last request will fail because the first request used the refresh token 564 | if (this.refreshTokenLock) { 565 | return; 566 | } 567 | this.refreshTokenLock = true; 568 | 569 | // make refresh token request 570 | res = await this.httpClient.get("/token/refresh", { 571 | params: { 572 | refresh_token: refreshToken, 573 | }, 574 | }); 575 | } catch (error) { 576 | if (error.response?.status === 401) { 577 | await this.logout(); 578 | return; 579 | } else { 580 | return; // silent fail 581 | } 582 | } finally { 583 | // release lock 584 | this.refreshTokenLock = false; 585 | } 586 | 587 | this._setSession(res.data); 588 | this.tokenChanged(); 589 | } 590 | 591 | private tokenChanged(): void { 592 | for (const tokenChangedFunction of this.tokenChangedFunctions) { 593 | tokenChangedFunction(); 594 | } 595 | } 596 | 597 | private authStateChanged(state: boolean): void { 598 | for (const authChangedFunction of this.authChangedFunctions) { 599 | authChangedFunction(state); 600 | } 601 | } 602 | 603 | private async _clearSession(): Promise { 604 | // early exit 605 | if (this.isAuthenticated() === false) { 606 | return; 607 | } 608 | 609 | clearInterval(this.refreshInterval); 610 | clearInterval(this.refreshSleepCheckInterval); 611 | 612 | this.currentSession.clearSession(); 613 | this._removeItem("nhostRefreshToken"); 614 | 615 | this.loading = false; 616 | this.authStateChanged(false); 617 | } 618 | 619 | private async _setSession(session: types.Session) { 620 | const previouslyAuthenticated = this.isAuthenticated(); 621 | this.currentSession.setSession(session); 622 | this.currentUser = session.user; 623 | 624 | if (!this.useCookies && session.refresh_token) { 625 | await this._setItem("nhostRefreshToken", session.refresh_token); 626 | } 627 | 628 | if (!previouslyAuthenticated) { 629 | // start refresh token interval after logging in 630 | const JWTExpiresIn = session.jwt_expires_in; 631 | const refreshIntervalTime = this.refreshIntervalTime 632 | ? this.refreshIntervalTime 633 | : Math.max(30 * 1000, JWTExpiresIn - 45000); //45 sec before expires 634 | this.refreshInterval = setInterval( 635 | this._refreshToken.bind(this), 636 | refreshIntervalTime 637 | ); 638 | 639 | // refresh token after computer has been sleeping 640 | // https://stackoverflow.com/questions/14112708/start-calling-js-function-when-pc-wakeup-from-sleep-mode 641 | this.refreshIntervalSleepCheckLastSample = Date.now(); 642 | this.refreshSleepCheckInterval = setInterval(() => { 643 | if ( 644 | Date.now() - this.refreshIntervalSleepCheckLastSample >= 645 | this.sampleRate * 2 646 | ) { 647 | this._refreshToken(); 648 | } 649 | this.refreshIntervalSleepCheckLastSample = Date.now(); 650 | }, this.sampleRate); 651 | 652 | this.authStateChanged(true); 653 | } 654 | 655 | this.loading = false; 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /src/NhostClient.ts: -------------------------------------------------------------------------------- 1 | import NhostAuth from "./Auth"; 2 | import NhostStorage from "./Storage"; 3 | import UserSession from "./UserSession"; 4 | import * as types from "./types"; 5 | 6 | export default class NhostClient { 7 | protected baseURL: string; 8 | protected useCookies: boolean; 9 | private refreshIntervalTime: number | null; 10 | private clientStorage: types.ClientStorage; 11 | private clientStorageType: string; 12 | private ssr: boolean; 13 | private autoLogin: boolean; 14 | private session: UserSession; 15 | 16 | auth: NhostAuth; 17 | storage: NhostStorage; 18 | 19 | constructor(config: types.UserConfig) { 20 | if (!config.baseURL) 21 | throw "Please specify a baseURL. More information at https://docs.nhost.io/libraries/nhost-js-sdk#setup."; 22 | 23 | this.baseURL = config.baseURL; 24 | this.ssr = config.ssr ?? typeof window === "undefined"; 25 | this.useCookies = config.useCookies ?? false; 26 | this.autoLogin = config.autoLogin ?? true; 27 | 28 | this.session = new UserSession(); 29 | // Default JWTExpiresIn is 15 minutes (900000 miliseconds) 30 | this.refreshIntervalTime = config.refreshIntervalTime || null; 31 | 32 | this.clientStorage = this.ssr 33 | ? {} 34 | : config.clientStorage || window.localStorage; 35 | 36 | this.clientStorageType = config.clientStorageType 37 | ? config.clientStorageType 38 | : "web"; 39 | 40 | this.auth = new NhostAuth( 41 | { 42 | baseURL: this.baseURL, 43 | useCookies: this.useCookies, 44 | refreshIntervalTime: this.refreshIntervalTime, 45 | clientStorage: this.clientStorage, 46 | clientStorageType: this.clientStorageType, 47 | ssr: this.ssr, 48 | autoLogin: this.autoLogin, 49 | }, 50 | this.session 51 | ); 52 | // this.auth = new NhostAuth(authConfig, this.session); 53 | 54 | this.storage = new NhostStorage( 55 | { 56 | baseURL: this.baseURL, 57 | useCookies: this.useCookies, 58 | }, 59 | this.session 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Storage.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import * as types from "./types"; 3 | import UserSession from "./UserSession"; 4 | import { 5 | StringFormat, 6 | base64Bytes, 7 | utf8Bytes, 8 | percentEncodedBytes, 9 | } from "./utils"; 10 | 11 | export default class Storage { 12 | private httpClient: AxiosInstance; 13 | private useCookies: boolean; 14 | private currentSession: UserSession; 15 | 16 | constructor(config: types.StorageConfig, session: UserSession) { 17 | this.currentSession = session; 18 | this.useCookies = config.useCookies; 19 | 20 | this.httpClient = axios.create({ 21 | baseURL: config.baseURL, 22 | timeout: 120 * 1000, // milliseconds 23 | withCredentials: this.useCookies, 24 | }); 25 | } 26 | 27 | private generateAuthorizationHeader(): null | types.Headers { 28 | if (this.useCookies) return null; 29 | 30 | const JWTToken = this.currentSession.getSession()?.jwt_token; 31 | 32 | if (JWTToken) { 33 | return { 34 | Authorization: `Bearer ${JWTToken}`, 35 | }; 36 | } else { 37 | return null; 38 | } 39 | } 40 | 41 | async put( 42 | path: string, 43 | file: File, 44 | metadata: object | null = null, 45 | onUploadProgress: any | undefined = undefined 46 | ) { 47 | if (!path.startsWith("/")) { 48 | throw new Error("`path` must start with `/`"); 49 | } 50 | 51 | let formData = new FormData(); 52 | formData.append("file", file); 53 | 54 | // todo: handle metadata 55 | if (metadata !== null) { 56 | console.warn("Metadata is not yet handled in this version."); 57 | } 58 | 59 | const upload_res = await this.httpClient.post( 60 | `/storage/o${path}`, 61 | formData, 62 | { 63 | headers: { 64 | "Content-Type": "multipart/form-data", 65 | ...this.generateAuthorizationHeader(), 66 | }, 67 | onUploadProgress, 68 | } 69 | ); 70 | 71 | return upload_res.data; 72 | } 73 | 74 | async putString( 75 | path: string, 76 | data: string, 77 | type: "raw" | "data_url" = "raw", 78 | metadata: { "content-type": string } | null = null, 79 | onUploadProgress: any | undefined = undefined 80 | ) { 81 | if (!path.startsWith("/")) { 82 | throw new Error("`path` must start with `/`"); 83 | } 84 | 85 | let fileData; 86 | let contentType: string | undefined; 87 | if (type === "raw") { 88 | fileData = utf8Bytes(data); 89 | contentType = 90 | metadata && metadata.hasOwnProperty("content-type") 91 | ? metadata["content-type"] 92 | : undefined; 93 | } else if (type === "data_url") { 94 | let isBase64 = false; 95 | const matches = data.match(/^data:([^,]+)?,/); 96 | if (matches === null) { 97 | throw "Data must be formatted 'data:[][;base64],"; 98 | } 99 | const middle = matches[1] || null; 100 | if (middle != null) { 101 | isBase64 = middle.endsWith(";base64"); 102 | contentType = isBase64 103 | ? middle.substring(0, middle.length - ";base64".length) 104 | : middle; 105 | } 106 | const restData = data.substring(data.indexOf(",") + 1); 107 | fileData = isBase64 108 | ? base64Bytes(StringFormat.BASE64, restData) 109 | : percentEncodedBytes(restData); 110 | } 111 | 112 | if (!fileData) { 113 | throw new Error("Unbale to generate file data"); 114 | } 115 | 116 | const file = new File([fileData], "untitled", { type: contentType }); 117 | 118 | // create form data 119 | let form_data = new FormData(); 120 | form_data.append("file", file); 121 | 122 | const uploadRes = await this.httpClient.post( 123 | `/storage/o${path}`, 124 | form_data, 125 | { 126 | headers: { 127 | "Content-Type": "multipart/form-data", 128 | ...this.generateAuthorizationHeader(), 129 | }, 130 | onUploadProgress, 131 | } 132 | ); 133 | 134 | return uploadRes.data; 135 | } 136 | 137 | async delete(path: string) { 138 | if (!path.startsWith("/")) { 139 | throw new Error("`path` must start with `/`"); 140 | } 141 | const requestRes = await this.httpClient.delete(`storage/o${path}`, { 142 | headers: { 143 | ...this.generateAuthorizationHeader(), 144 | }, 145 | }); 146 | return requestRes.data; 147 | } 148 | 149 | async getMetadata(path: string): Promise { 150 | if (!path.startsWith("/")) { 151 | throw new Error("`path` must start with `/`"); 152 | } 153 | const res = await this.httpClient.get(`storage/m${path}`, { 154 | headers: { 155 | ...this.generateAuthorizationHeader(), 156 | }, 157 | }); 158 | return res.data; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/UserSession.ts: -------------------------------------------------------------------------------- 1 | import jwt_decode from "jwt-decode"; 2 | import { Session, JWTClaims, JWTHasuraClaims } from "./types"; 3 | 4 | export default class UserSession { 5 | private session: Session | null; 6 | private claims: JWTHasuraClaims | null; 7 | 8 | constructor() { 9 | this.session = null; 10 | this.claims = null; 11 | } 12 | 13 | public setSession(session: Session) { 14 | this.session = session; 15 | 16 | const jwtTokenDecoded: JWTClaims = jwt_decode(session.jwt_token); 17 | this.claims = jwtTokenDecoded["https://hasura.io/jwt/claims"]; 18 | } 19 | 20 | public clearSession() { 21 | this.session = null; 22 | this.claims = null; 23 | } 24 | 25 | public getSession(): Session | null { 26 | return this.session; 27 | } 28 | 29 | public getClaim(claim: string): string | string[] | null { 30 | if (this.claims) { 31 | return this.claims[claim]; 32 | } else { 33 | return null; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import NhostClient from "./NhostClient"; 2 | import { UserConfig, User, Session } from "./types"; 3 | 4 | const createClient = (config: UserConfig) => { 5 | return new NhostClient(config); 6 | }; 7 | 8 | export { NhostClient, createClient, User, Session, UserConfig }; 9 | -------------------------------------------------------------------------------- /src/test/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_PASSWORD=hejsan123 2 | 3 | HASURA_GRAPHQL_DATABASE_URL=postgres://postgres:hejsan123@postgres:5432/postgres 4 | HASURA_GRAPHQL_JWT_SECRET={"type": "HS256", "key": "hkjsadkajsdhakjshdakjshdkjshaskjhdjkashduga8s7da8sdg8asdg8as7dg8agd8a7gdad"} 5 | HASURA_GRAPHQL_ENABLE_CONSOLE=true 6 | JWT_KEY=hkjsadkajsdhakjshdakjshdkjshaskjhdjkashduga8s7da8sdg8asdg8as7dg8agd8a7gdad 7 | JWT_ALGORITHM=HS256 8 | HASURA_GRAPHQL_MIGRATIONS_SERVER_TIMEOUT=5 9 | 10 | SERVER_URL=http://localhost:3000 11 | HASURA_ENDPOINT=http://graphql-engine:8080/v1/graphql 12 | HASURA_GRAPHQL_ADMIN_SECRET=hejsan 13 | 14 | REDIRECT_URL_ERROR=http://localhost:3000/error 15 | REDIRECT_URL_SUCCESS=http://localhost:3000/healthz 16 | 17 | HIBP_ENABLE=false 18 | 19 | # A string or array used for signing cookies (optional) 20 | COOKIE_SECRET=a_cookie_secret_1234 21 | 22 | # SERVER_PORT=3000 23 | AUTO_ACTIVATE_NEW_USERS=true 24 | VERIFY_EMAILS=true 25 | ALLOW_USER_SELF_DELETE=false 26 | LOST_PASSWORD_ENABLE=true 27 | 28 | # OAuth providers 29 | PROVIDER_SUCCESS_REDIRECT=http://localhost:3000/healthz 30 | PROVIDER_FAILURE_REDIRECT=http://localhost:3000/error 31 | 32 | GITHUB_ENABLE=true 33 | GITHUB_CLIENT_ID=ea7e608c2435935c498d 34 | GITHUB_CLIENT_SECRET=b1ebf4ab98af41c73573add2b23a33f6fef6a5e2 35 | 36 | PROVIDER_SUCCESS_REDIRECT=http://localhost:3001 37 | PROVIDER_FAILURE_REDIRECT=http://localhost:3001 38 | 39 | #GOOGLE_ENABLE=false 40 | 41 | # JWT_EXPIRES_IN=15 42 | # JWT_REFRESH_EXPIRES_IN=43200 43 | 44 | # MAX_REQUESTS=100 45 | # TIME_FRAME=15 * 60 * 1000 46 | 47 | # S3 settings 48 | S3_ENDPOINT=http://minio:9000 49 | S3_BUCKET=nhost 50 | S3_ACCESS_KEY_ID=minio_access_key 51 | S3_SECRET_ACCESS_KEY=mini_secret_access_key 52 | MINIO_ACCESS_KEY=minio_access_key 53 | MINIO_SECRET_KEY=mini_secret_access_key 54 | 55 | 56 | NOTIFY_EMAIL_CHANGE=false 57 | CHANGE_EMAIL_ENABLE=true 58 | EMAILS_ENABLE=true 59 | EMAILS_ENABLE=true 60 | SMTP_HOST=mailhog 61 | SMTP_PORT=1025 62 | SMTP_PASS=password 63 | SMTP_USER=user 64 | SMTP_SECURE=false 65 | SMTP_SENDER=hbp@hbp.com 66 | 67 | MAX_REQUEST=100 68 | TIME_FRAME=1000 69 | -------------------------------------------------------------------------------- /src/test/.gitignore: -------------------------------------------------------------------------------- 1 | db_data 2 | -------------------------------------------------------------------------------- /src/test/custom/emails/activate-account/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | 22 | 23 |

Hello <%= display_name %>

24 |

Please confirm your email address by clicking the button below:

25 | 26 | 28 |

Thanks,
The Team

29 | 30 | 31 | -------------------------------------------------------------------------------- /src/test/custom/emails/activate-account/subject.ejs: -------------------------------------------------------------------------------- 1 | Confirm your email address -------------------------------------------------------------------------------- /src/test/custom/emails/change-email/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 |

Hello.

15 |

Someone requested to change the email for your account.

16 |

If this was not you, please disregard this message.

17 |
18 |

Proceed the email change by using the following code:

19 |

<%= ticket %>

20 |

Thanks,
The Team

21 | 22 | 23 | -------------------------------------------------------------------------------- /src/test/custom/emails/change-email/subject.ejs: -------------------------------------------------------------------------------- 1 | Change your email address -------------------------------------------------------------------------------- /src/test/custom/emails/lost-password/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 |

Hello.

15 |

Someone requested to reset the password for your account.

16 |

If this was not you, please disregard this message.

17 |
18 |

Proceed the password reset by using the following code:

19 |

<%= ticket %>

20 |

Thanks,
The Team

21 | 22 | 23 | -------------------------------------------------------------------------------- /src/test/custom/emails/lost-password/subject.ejs: -------------------------------------------------------------------------------- 1 | Reset your password 2 | -------------------------------------------------------------------------------- /src/test/custom/emails/magic-link/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 22 | 23 | 24 |

Hello <%= display_name %> 25 |

26 |

Use this link to securely <%= action %>:

27 | 28 | 31 | 32 |

33 | You can also copy & paste this URL into your browser: 34 | 35 | <%= url %>/auth/magic-link?token=<%= token %>&action=<%= action.replace(/ /g, '-' ) %> 36 |

37 |

Thanks,
The Team

38 | 39 | 40 | -------------------------------------------------------------------------------- /src/test/custom/emails/magic-link/subject.ejs: -------------------------------------------------------------------------------- 1 | Secure <%= action %> link -------------------------------------------------------------------------------- /src/test/custom/emails/notify-email-change/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 |

Hello.

15 |

The email attached to your account has been changed.

16 |

If you did not ask for this, please contact our services.

17 |
18 |

Thanks,
The Team

19 | 20 | 21 | -------------------------------------------------------------------------------- /src/test/custom/emails/notify-email-change/subject.ejs: -------------------------------------------------------------------------------- 1 | The email attached to your account has been changed 2 | -------------------------------------------------------------------------------- /src/test/custom/keys/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/nhost-js-sdk/f0086438036f926f57c05ef04699e7ed6e2067da/src/test/custom/keys/.gitkeep -------------------------------------------------------------------------------- /src/test/custom/storage-rules/rules.yaml: -------------------------------------------------------------------------------- 1 | functions: 2 | isAuthenticated: 'return !!request.auth' 3 | isOwner: "return !!request.auth && userId === request.auth['user-id']" 4 | validToken: 'return request.query.token === resource.Metadata.token' 5 | paths: 6 | /user/:userId/: 7 | list: 'isOwner(userId)' 8 | /user/:userId/:fileId: 9 | read: 'isOwner(userId) || validToken()' 10 | write: 'isOwner(userId)' 11 | -------------------------------------------------------------------------------- /src/test/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | postgres: 4 | image: postgres:12 5 | restart: always 6 | volumes: 7 | - ./db_data:/var/lib/postgresql/data 8 | env_file: .env 9 | graphql-engine: 10 | image: hasura/graphql-engine:v1.3.3.cli-migrations 11 | depends_on: 12 | - "postgres" 13 | restart: always 14 | env_file: .env 15 | ports: 16 | - "8080:8080" 17 | volumes: 18 | - ./migrations:/hasura-migrations 19 | hasura-backend-plus: 20 | # image: nhost/hasura-backend-plus:v2.2.4 21 | build: 22 | context: ../../../hasura-backend-plus 23 | dockerfile: Dockerfile.dev 24 | ports: 25 | - "3000:3000" 26 | environment: 27 | PORT: "3000" 28 | HOST: "0.0.0.0" 29 | NODE_ENV: "TEST" 30 | env_file: .env 31 | volumes: 32 | - ./custom:/app/custom 33 | minio: 34 | image: minio/minio 35 | restart: always 36 | ports: 37 | - "9000:9000" 38 | env_file: .env 39 | entrypoint: sh 40 | volumes: 41 | - ./db_data:/data 42 | command: "-c 'mkdir -p /export/nhost && /usr/bin/minio server /export'" 43 | mailhog: 44 | image: mailhog/mailhog 45 | ports: 46 | - 1025:1025 # smtp server 47 | - 8025:8025 # web ui 48 | -------------------------------------------------------------------------------- /src/test/globalSetup.ts: -------------------------------------------------------------------------------- 1 | import * as compose from "docker-compose"; 2 | import path from "path"; 3 | import axios from "axios"; 4 | import fs from "fs-extra"; 5 | 6 | function sleep(ms: number) { 7 | return new Promise((resolve) => setTimeout(resolve, ms)); 8 | } 9 | 10 | export default async (): Promise => { 11 | console.log("global setup."); 12 | 13 | const test_path = path.join(__dirname); 14 | 15 | // stop previous docker compose, if ended unexpectedly 16 | await compose.down({ cwd: test_path, log: true }); 17 | 18 | //remove ./db_data folder 19 | await fs.removeSync(`${test_path}/db_data`); 20 | 21 | // start docker compose 22 | await compose.buildAll({ cwd: test_path, log: true, commandOptions: ['--no-cache'] }); 23 | await compose.upAll({ cwd: test_path, log: true }); 24 | 25 | // wait until HBP and Hasura is up 26 | let backendOnline = false; 27 | let retries = 0; 28 | const maxRetries = 20; 29 | while (!backendOnline && retries < maxRetries) { 30 | try { 31 | // both hbp and the graphql engine must be up 32 | await axios.get("http://localhost:3000/healthz"); 33 | await axios.get("http://localhost:8080/healthz"); 34 | backendOnline = true; 35 | } catch (error) { 36 | console.log(`Backend not online. Test ${retries}/${maxRetries}`); 37 | await sleep(5 * 1000); 38 | retries += 1; 39 | continue; 40 | } 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/test/globalTeardown.ts: -------------------------------------------------------------------------------- 1 | // const compose = require("docker-compose"); 2 | // const path = require("path"); 3 | 4 | import * as compose from "docker-compose"; 5 | import path from "path"; 6 | 7 | module.exports = async () => { 8 | await compose.down({ 9 | cwd: path.join(__dirname), 10 | log: true, 11 | }); 12 | 13 | console.log("global TEARDOWN"); 14 | }; 15 | -------------------------------------------------------------------------------- /src/test/migrations/1_init/down.yaml: -------------------------------------------------------------------------------- 1 | - args: 2 | relationship: account 3 | table: 4 | name: account_providers 5 | schema: auth 6 | type: drop_relationship 7 | - args: 8 | relationship: provider 9 | table: 10 | name: account_providers 11 | schema: auth 12 | type: drop_relationship 13 | - args: 14 | relationship: account 15 | table: 16 | name: account_roles 17 | schema: auth 18 | type: drop_relationship 19 | - args: 20 | relationship: roleByRole 21 | table: 22 | name: account_roles 23 | schema: auth 24 | type: drop_relationship 25 | - args: 26 | relationship: role 27 | table: 28 | name: accounts 29 | schema: auth 30 | type: drop_relationship 31 | - args: 32 | relationship: user 33 | table: 34 | name: accounts 35 | schema: auth 36 | type: drop_relationship 37 | - args: 38 | relationship: account_providers 39 | table: 40 | name: accounts 41 | schema: auth 42 | type: drop_relationship 43 | - args: 44 | relationship: account_roles 45 | table: 46 | name: accounts 47 | schema: auth 48 | type: drop_relationship 49 | - args: 50 | relationship: refresh_tokens 51 | table: 52 | name: accounts 53 | schema: auth 54 | type: drop_relationship 55 | - args: 56 | relationship: account_providers 57 | table: 58 | name: providers 59 | schema: auth 60 | type: drop_relationship 61 | - args: 62 | relationship: account 63 | table: 64 | name: refresh_tokens 65 | schema: auth 66 | type: drop_relationship 67 | - args: 68 | relationship: account_roles 69 | table: 70 | name: roles 71 | schema: auth 72 | type: drop_relationship 73 | - args: 74 | relationship: accounts 75 | table: 76 | name: roles 77 | schema: auth 78 | type: drop_relationship 79 | - args: 80 | relationship: account 81 | table: 82 | name: users 83 | schema: public 84 | type: drop_relationship 85 | -------------------------------------------------------------------------------- /src/test/migrations/1_init/up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 2 | CREATE EXTENSION IF NOT EXISTS citext; 3 | 4 | CREATE SCHEMA auth; 5 | CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger 6 | LANGUAGE plpgsql 7 | AS $$ 8 | declare 9 | _new record; 10 | begin 11 | _new := new; 12 | _new. "updated_at" = now(); 13 | return _new; 14 | end; 15 | $$; 16 | CREATE TABLE auth.account_providers ( 17 | id uuid DEFAULT public.gen_random_uuid() NOT NULL, 18 | created_at timestamp with time zone DEFAULT now() NOT NULL, 19 | updated_at timestamp with time zone DEFAULT now() NOT NULL, 20 | account_id uuid NOT NULL, 21 | auth_provider text NOT NULL, 22 | auth_provider_unique_id text NOT NULL 23 | ); 24 | CREATE TABLE auth.account_roles ( 25 | id uuid DEFAULT public.gen_random_uuid() NOT NULL, 26 | created_at timestamp with time zone DEFAULT now() NOT NULL, 27 | account_id uuid NOT NULL, 28 | role text NOT NULL 29 | ); 30 | CREATE TABLE auth.accounts ( 31 | id uuid DEFAULT public.gen_random_uuid() NOT NULL, 32 | created_at timestamp with time zone DEFAULT now() NOT NULL, 33 | updated_at timestamp with time zone DEFAULT now() NOT NULL, 34 | user_id uuid NOT NULL, 35 | active boolean DEFAULT false NOT NULL, 36 | email public.citext, 37 | new_email public.citext, 38 | password_hash text, 39 | default_role text DEFAULT 'user'::text NOT NULL, 40 | is_anonymous boolean DEFAULT false NOT NULL, 41 | custom_register_data jsonb, 42 | otp_secret text, 43 | mfa_enabled boolean DEFAULT false NOT NULL, 44 | ticket uuid DEFAULT public.gen_random_uuid() NOT NULL, 45 | ticket_expires_at timestamp with time zone DEFAULT now() NOT NULL, 46 | CONSTRAINT proper_email CHECK ((email OPERATOR(public.~*) '^[A-Za-z0-9._+%-]+@[A-Za-z0-9.-]+[.][A-Za-z]+$'::public.citext)), 47 | CONSTRAINT proper_new_email CHECK ((new_email OPERATOR(public.~*) '^[A-Za-z0-9._+%-]+@[A-Za-z0-9.-]+[.][A-Za-z]+$'::public.citext)) 48 | ); 49 | CREATE TABLE auth.providers ( 50 | provider text NOT NULL 51 | ); 52 | CREATE TABLE auth.refresh_tokens ( 53 | refresh_token uuid NOT NULL, 54 | created_at timestamp with time zone DEFAULT now() NOT NULL, 55 | expires_at timestamp with time zone NOT NULL, 56 | account_id uuid NOT NULL 57 | ); 58 | CREATE TABLE auth.roles ( 59 | role text NOT NULL 60 | ); 61 | CREATE TABLE public.users ( 62 | id uuid DEFAULT public.gen_random_uuid() NOT NULL, 63 | created_at timestamp with time zone DEFAULT now() NOT NULL, 64 | updated_at timestamp with time zone DEFAULT now() NOT NULL, 65 | display_name text, 66 | avatar_url text 67 | ); 68 | ALTER TABLE ONLY auth.account_providers 69 | ADD CONSTRAINT account_providers_account_id_auth_provider_key UNIQUE (account_id, auth_provider); 70 | ALTER TABLE ONLY auth.account_providers 71 | ADD CONSTRAINT account_providers_auth_provider_auth_provider_unique_id_key UNIQUE (auth_provider, auth_provider_unique_id); 72 | ALTER TABLE ONLY auth.account_providers 73 | ADD CONSTRAINT account_providers_pkey PRIMARY KEY (id); 74 | ALTER TABLE ONLY auth.account_roles 75 | ADD CONSTRAINT account_roles_pkey PRIMARY KEY (id); 76 | ALTER TABLE ONLY auth.accounts 77 | ADD CONSTRAINT accounts_email_key UNIQUE (email); 78 | ALTER TABLE ONLY auth.accounts 79 | ADD CONSTRAINT accounts_new_email_key UNIQUE (new_email); 80 | ALTER TABLE ONLY auth.accounts 81 | ADD CONSTRAINT accounts_pkey PRIMARY KEY (id); 82 | ALTER TABLE ONLY auth.accounts 83 | ADD CONSTRAINT accounts_user_id_key UNIQUE (user_id); 84 | ALTER TABLE ONLY auth.providers 85 | ADD CONSTRAINT providers_pkey PRIMARY KEY (provider); 86 | ALTER TABLE ONLY auth.refresh_tokens 87 | ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (refresh_token); 88 | ALTER TABLE ONLY auth.roles 89 | ADD CONSTRAINT roles_pkey PRIMARY KEY (role); 90 | ALTER TABLE ONLY auth.account_roles 91 | ADD CONSTRAINT user_roles_account_id_role_key UNIQUE (account_id, role); 92 | ALTER TABLE ONLY public.users 93 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 94 | CREATE TRIGGER set_auth_account_providers_updated_at BEFORE UPDATE ON auth.account_providers FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at(); 95 | CREATE TRIGGER set_auth_accounts_updated_at BEFORE UPDATE ON auth.accounts FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at(); 96 | CREATE TRIGGER set_public_users_updated_at BEFORE UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at(); 97 | ALTER TABLE ONLY auth.account_providers 98 | ADD CONSTRAINT account_providers_account_id_fkey FOREIGN KEY (account_id) REFERENCES auth.accounts(id) ON UPDATE CASCADE ON DELETE CASCADE; 99 | ALTER TABLE ONLY auth.account_providers 100 | ADD CONSTRAINT account_providers_auth_provider_fkey FOREIGN KEY (auth_provider) REFERENCES auth.providers(provider) ON UPDATE RESTRICT ON DELETE RESTRICT; 101 | ALTER TABLE ONLY auth.account_roles 102 | ADD CONSTRAINT account_roles_account_id_fkey FOREIGN KEY (account_id) REFERENCES auth.accounts(id) ON UPDATE CASCADE ON DELETE CASCADE; 103 | ALTER TABLE ONLY auth.account_roles 104 | ADD CONSTRAINT account_roles_role_fkey FOREIGN KEY (role) REFERENCES auth.roles(role) ON UPDATE CASCADE ON DELETE RESTRICT; 105 | ALTER TABLE ONLY auth.accounts 106 | ADD CONSTRAINT accounts_default_role_fkey FOREIGN KEY (default_role) REFERENCES auth.roles(role) ON UPDATE CASCADE ON DELETE RESTRICT; 107 | ALTER TABLE ONLY auth.accounts 108 | ADD CONSTRAINT accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE; 109 | ALTER TABLE ONLY auth.refresh_tokens 110 | ADD CONSTRAINT refresh_tokens_account_id_fkey FOREIGN KEY (account_id) REFERENCES auth.accounts(id) ON UPDATE CASCADE ON DELETE CASCADE; 111 | 112 | INSERT INTO auth.roles (role) 113 | VALUES ('user'), ('anonymous'); 114 | 115 | INSERT INTO auth.providers (provider) 116 | VALUES ('github'), ('facebook'), ('twitter'), ('google'), ('apple'), ('linkedin'), ('windowslive'); 117 | -------------------------------------------------------------------------------- /src/test/migrations/1_init/up.yaml: -------------------------------------------------------------------------------- 1 | - args: 2 | name: account_providers 3 | schema: auth 4 | type: add_existing_table_or_view 5 | - args: 6 | name: account_roles 7 | schema: auth 8 | type: add_existing_table_or_view 9 | - args: 10 | name: accounts 11 | schema: auth 12 | type: add_existing_table_or_view 13 | - args: 14 | name: providers 15 | schema: auth 16 | type: add_existing_table_or_view 17 | - args: 18 | name: refresh_tokens 19 | schema: auth 20 | type: add_existing_table_or_view 21 | - args: 22 | name: roles 23 | schema: auth 24 | type: add_existing_table_or_view 25 | - args: 26 | name: users 27 | schema: public 28 | type: add_existing_table_or_view 29 | - args: 30 | name: account 31 | table: 32 | name: account_providers 33 | schema: auth 34 | using: 35 | foreign_key_constraint_on: account_id 36 | type: create_object_relationship 37 | - args: 38 | name: provider 39 | table: 40 | name: account_providers 41 | schema: auth 42 | using: 43 | foreign_key_constraint_on: auth_provider 44 | type: create_object_relationship 45 | - args: 46 | name: account 47 | table: 48 | name: account_roles 49 | schema: auth 50 | using: 51 | foreign_key_constraint_on: account_id 52 | type: create_object_relationship 53 | - args: 54 | name: roleByRole 55 | table: 56 | name: account_roles 57 | schema: auth 58 | using: 59 | foreign_key_constraint_on: role 60 | type: create_object_relationship 61 | - args: 62 | name: role 63 | table: 64 | name: accounts 65 | schema: auth 66 | using: 67 | foreign_key_constraint_on: default_role 68 | type: create_object_relationship 69 | - args: 70 | name: user 71 | table: 72 | name: accounts 73 | schema: auth 74 | using: 75 | foreign_key_constraint_on: user_id 76 | type: create_object_relationship 77 | - args: 78 | name: account_providers 79 | table: 80 | name: accounts 81 | schema: auth 82 | using: 83 | foreign_key_constraint_on: 84 | column: account_id 85 | table: 86 | name: account_providers 87 | schema: auth 88 | type: create_array_relationship 89 | - args: 90 | name: account_roles 91 | table: 92 | name: accounts 93 | schema: auth 94 | using: 95 | foreign_key_constraint_on: 96 | column: account_id 97 | table: 98 | name: account_roles 99 | schema: auth 100 | type: create_array_relationship 101 | - args: 102 | name: refresh_tokens 103 | table: 104 | name: accounts 105 | schema: auth 106 | using: 107 | foreign_key_constraint_on: 108 | column: account_id 109 | table: 110 | name: refresh_tokens 111 | schema: auth 112 | type: create_array_relationship 113 | - args: 114 | name: account_providers 115 | table: 116 | name: providers 117 | schema: auth 118 | using: 119 | foreign_key_constraint_on: 120 | column: auth_provider 121 | table: 122 | name: account_providers 123 | schema: auth 124 | type: create_array_relationship 125 | - args: 126 | name: account 127 | table: 128 | name: refresh_tokens 129 | schema: auth 130 | using: 131 | foreign_key_constraint_on: account_id 132 | type: create_object_relationship 133 | - args: 134 | name: account_roles 135 | table: 136 | name: roles 137 | schema: auth 138 | using: 139 | foreign_key_constraint_on: 140 | column: role 141 | table: 142 | name: account_roles 143 | schema: auth 144 | type: create_array_relationship 145 | - args: 146 | name: accounts 147 | table: 148 | name: roles 149 | schema: auth 150 | using: 151 | foreign_key_constraint_on: 152 | column: default_role 153 | table: 154 | name: accounts 155 | schema: auth 156 | type: create_array_relationship 157 | - args: 158 | name: account 159 | table: 160 | name: users 161 | schema: public 162 | using: 163 | manual_configuration: 164 | column_mapping: 165 | id: user_id 166 | remote_table: 167 | name: accounts 168 | schema: auth 169 | type: create_object_relationship 170 | -------------------------------------------------------------------------------- /src/test/test-utils.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { NhostClient } from "../index"; 3 | 4 | const config = { 5 | baseURL: "http://localhost:3000", 6 | }; 7 | 8 | class TestNhostClient extends NhostClient { 9 | protected httpClient = axios.create({ 10 | baseURL: `${this.baseURL}`, 11 | timeout: 10000, 12 | withCredentials: this.useCookies, 13 | }); 14 | 15 | public async withEnv( 16 | env: Record, 17 | cb: () => Promise, 18 | rollbackEnv?: Record 19 | ) { 20 | await this.httpClient.post('/change-env', env); 21 | await cb(); 22 | if (rollbackEnv) { 23 | await this.httpClient.post('/change-env', rollbackEnv); 24 | } 25 | } 26 | } 27 | 28 | export const nhost = new TestNhostClient(config); 29 | 30 | export const auth = nhost.auth; 31 | 32 | export const storage = nhost.storage; 33 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface UserConfig { 2 | baseURL: string; 3 | useCookies?: boolean; 4 | refreshIntervalTime?: number | null; 5 | clientStorage?: ClientStorage; 6 | clientStorageType?: string; 7 | autoLogin?: boolean; 8 | ssr?: boolean; 9 | } 10 | 11 | export interface AuthConfig { 12 | baseURL: string; 13 | useCookies: boolean; 14 | refreshIntervalTime: number | null; 15 | clientStorage: ClientStorage; 16 | clientStorageType: string; 17 | ssr?: boolean; 18 | autoLogin: boolean; 19 | } 20 | 21 | export interface StorageConfig { 22 | baseURL: string; 23 | useCookies: boolean; 24 | } 25 | 26 | export interface ClientStorage { 27 | // custom 28 | // localStorage 29 | // AsyncStorage 30 | // https://react-native-community.github.io/async-storage/docs/usage 31 | setItem?: (key: string, value: string) => void; 32 | getItem?: (key: string) => any; 33 | removeItem?: (key: string) => void; 34 | 35 | // capacitor 36 | set?: (options: { key: string; value: string }) => void; 37 | get?: (options: { key: string }) => any; 38 | remove?: (options: { key: string }) => void; 39 | 40 | // expo-secure-storage 41 | setItemAsync?: (key: string, value: string) => void; 42 | getItemAsync?: (key: string) => any; 43 | deleteItemAsync?: (key: string) => void; 44 | } 45 | 46 | // supported client storage types 47 | export type ClientStorageType = 48 | | "web" 49 | | "react-native" 50 | | "capacitor" 51 | | "expo-secure-storage" 52 | | "custom"; 53 | 54 | export interface LoginData { 55 | mfa?: boolean; 56 | ticket?: string; 57 | } 58 | 59 | export interface Headers { 60 | Authorization?: string; 61 | } 62 | 63 | export type Provider = 64 | | "apple" 65 | | "facebook" 66 | | "github" 67 | | "google" 68 | | "linkedin" 69 | | "spotify" 70 | | "twitter" 71 | | "windowslive"; 72 | 73 | export interface UserCredentials { 74 | email?: string; 75 | password?: string; 76 | provider?: Provider; 77 | options?: { 78 | userData?: any; 79 | defaultRole?: string; 80 | allowedRoles?: string[]; 81 | }; 82 | } 83 | export interface Session { 84 | jwt_token: string; 85 | jwt_expires_in: number; 86 | user: User; 87 | refresh_token?: string; // not present if useCookie 88 | } 89 | 90 | export interface User { 91 | id: string; 92 | email?: string; 93 | display_name?: string; 94 | avatar_url?: string; 95 | } 96 | export interface JWTHasuraClaims { 97 | [claim: string]: string | string[]; 98 | "x-hasura-allowed-roles" : string[]; 99 | "x-hasura-default-role": string; 100 | "x-hasura-user-id": string; 101 | } 102 | 103 | // https://hasura.io/docs/1.0/graphql/core/auth/authentication/jwt.html#the-spec 104 | export interface JWTClaims { 105 | sub?: string; 106 | iat?: number; 107 | "https://hasura.io/jwt/claims": JWTHasuraClaims; 108 | } 109 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type StringFormat = string; 2 | export const StringFormat = { 3 | RAW: "raw", 4 | BASE64: "base64", 5 | BASE64URL: "base64url", 6 | DATA_URL: "data_url", 7 | }; 8 | 9 | export function base64Bytes(format: StringFormat, value: string): Uint8Array { 10 | switch (format) { 11 | case StringFormat.BASE64: { 12 | const hasMinus = value.indexOf("-") !== -1; 13 | const hasUnder = value.indexOf("_") !== -1; 14 | if (hasMinus || hasUnder) { 15 | const invalidChar = hasMinus ? "-" : "_"; 16 | throw `Invalid character '${invalidChar}' found: is it base64url encoded?`; 17 | } 18 | break; 19 | } 20 | case StringFormat.BASE64URL: { 21 | const hasPlus = value.indexOf("+") !== -1; 22 | const hasSlash = value.indexOf("/") !== -1; 23 | if (hasPlus || hasSlash) { 24 | const invalidChar = hasPlus ? "+" : "/"; 25 | throw `Invalid character '${invalidChar}' found: is it base64url encoded?`; 26 | } 27 | value = value.replace(/-/g, "+").replace(/_/g, "/"); 28 | break; 29 | } 30 | default: 31 | // do nothing 32 | } 33 | let bytes; 34 | try { 35 | bytes = atob(value); 36 | } catch (e) { 37 | throw `Invalid character found`; 38 | } 39 | const array = new Uint8Array(bytes.length); 40 | for (let i = 0; i < bytes.length; i++) { 41 | array[i] = bytes.charCodeAt(i); 42 | } 43 | return array; 44 | } 45 | 46 | export function utf8Bytes(value: string): Uint8Array { 47 | const b: number[] = []; 48 | for (let i = 0; i < value.length; i++) { 49 | let c = value.charCodeAt(i); 50 | if (c <= 127) { 51 | b.push(c); 52 | } else { 53 | if (c <= 2047) { 54 | b.push(192 | (c >> 6), 128 | (c & 63)); 55 | } else { 56 | if ((c & 64512) === 55296) { 57 | // The start of a surrogate pair. 58 | const valid = 59 | i < value.length - 1 && (value.charCodeAt(i + 1) & 64512) === 56320; 60 | if (!valid) { 61 | // The second surrogate wasn't there. 62 | b.push(239, 191, 189); 63 | } else { 64 | const hi = c; 65 | const lo = value.charCodeAt(++i); 66 | c = 65536 | ((hi & 1023) << 10) | (lo & 1023); 67 | b.push( 68 | 240 | (c >> 18), 69 | 128 | ((c >> 12) & 63), 70 | 128 | ((c >> 6) & 63), 71 | 128 | (c & 63) 72 | ); 73 | } 74 | } else { 75 | if ((c & 64512) === 56320) { 76 | // Invalid low surrogate. 77 | b.push(239, 191, 189); 78 | } else { 79 | b.push(224 | (c >> 12), 128 | ((c >> 6) & 63), 128 | (c & 63)); 80 | } 81 | } 82 | } 83 | } 84 | } 85 | return new Uint8Array(b); 86 | } 87 | 88 | export function percentEncodedBytes(value: string): Uint8Array { 89 | let decoded; 90 | try { 91 | decoded = decodeURIComponent(value); 92 | } catch (e) { 93 | throw "Malformed data URL."; 94 | } 95 | return utf8Bytes(decoded); 96 | } 97 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src" 4 | ], 5 | "exclude": [ 6 | "node_modules", 7 | "dist", 8 | "src/**/*.test.ts", 9 | "src/test" 10 | ], 11 | "compilerOptions": { 12 | "lib": [ 13 | "es2020", 14 | "dom" 15 | ], 16 | "esModuleInterop": true, 17 | "declaration": true, 18 | "strict": true, 19 | "target": "es5", 20 | "outDir": "dist", 21 | "module": "commonjs", 22 | "moduleResolution": "node", 23 | "sourceMap": true, 24 | "rootDir": "src", 25 | "noUnusedLocals": true 26 | } 27 | } --------------------------------------------------------------------------------