@edx/frontend-platform 4.6.0 → 4.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/.env.development +30 -0
  2. package/.env.test +30 -0
  3. package/.eslintignore +6 -0
  4. package/.eslintrc.js +28 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +13 -0
  6. package/.github/workflows/add-depr-ticket-to-depr-board.yml +19 -0
  7. package/.github/workflows/add-remove-label-on-comment.yml +20 -0
  8. package/.github/workflows/ci.yml +42 -0
  9. package/.github/workflows/commitlint.yml +10 -0
  10. package/.github/workflows/lockfileversion-check.yml +13 -0
  11. package/.github/workflows/manual-publish.yml +43 -0
  12. package/.github/workflows/npm-deprecate.yml +22 -0
  13. package/.github/workflows/release.yml +45 -0
  14. package/.github/workflows/self-assign-issue.yml +12 -0
  15. package/.github/workflows/update-browserslist-db.yml +12 -0
  16. package/.nvmrc +1 -0
  17. package/.releaserc +32 -0
  18. package/catalog-info.yaml +21 -0
  19. package/dist/LICENSE +661 -0
  20. package/dist/README.md +155 -0
  21. package/dist/package.json +86 -0
  22. package/docs/addTagsPlugin.js +10 -0
  23. package/docs/auth-API.md +114 -0
  24. package/docs/decisions/0001-record-architecture-decisions.rst +32 -0
  25. package/docs/decisions/0002-frontend-base-design-goals.rst +222 -0
  26. package/docs/decisions/0003-consolidation-into-frontend-platform.rst +71 -0
  27. package/docs/decisions/0004-axios-caching-implementation.rst +88 -0
  28. package/docs/decisions/0005-token-null-after-successful-refresh.rst +69 -0
  29. package/docs/decisions/0006-middleware-support-for-http-clients.rst +44 -0
  30. package/docs/decisions/0007-javascript-file-configuration.rst +143 -0
  31. package/docs/how_tos/automatic-case-conversion.rst +58 -0
  32. package/docs/how_tos/caching.rst +93 -0
  33. package/docs/how_tos/i18n.rst +305 -0
  34. package/docs/removeExport.js +24 -0
  35. package/docs/template/edx/README.md +12 -0
  36. package/docs/template/edx/publish.js +713 -0
  37. package/docs/template/edx/static/fonts/OpenSans-Bold-webfont.eot +0 -0
  38. package/docs/template/edx/static/fonts/OpenSans-Bold-webfont.svg +1830 -0
  39. package/docs/template/edx/static/fonts/OpenSans-Bold-webfont.woff +0 -0
  40. package/docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  41. package/docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.svg +1830 -0
  42. package/docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  43. package/docs/template/edx/static/fonts/OpenSans-Italic-webfont.eot +0 -0
  44. package/docs/template/edx/static/fonts/OpenSans-Italic-webfont.svg +1830 -0
  45. package/docs/template/edx/static/fonts/OpenSans-Italic-webfont.woff +0 -0
  46. package/docs/template/edx/static/fonts/OpenSans-Light-webfont.eot +0 -0
  47. package/docs/template/edx/static/fonts/OpenSans-Light-webfont.svg +1831 -0
  48. package/docs/template/edx/static/fonts/OpenSans-Light-webfont.woff +0 -0
  49. package/docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  50. package/docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.svg +1835 -0
  51. package/docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  52. package/docs/template/edx/static/fonts/OpenSans-Regular-webfont.eot +0 -0
  53. package/docs/template/edx/static/fonts/OpenSans-Regular-webfont.svg +1831 -0
  54. package/docs/template/edx/static/fonts/OpenSans-Regular-webfont.woff +0 -0
  55. package/docs/template/edx/static/scripts/linenumber.js +25 -0
  56. package/docs/template/edx/static/scripts/prettify/Apache-License-2.0.txt +202 -0
  57. package/docs/template/edx/static/scripts/prettify/lang-css.js +2 -0
  58. package/docs/template/edx/static/scripts/prettify/prettify.js +28 -0
  59. package/docs/template/edx/static/styles/jsdoc-default.css +356 -0
  60. package/docs/template/edx/static/styles/prettify-jsdoc.css +111 -0
  61. package/docs/template/edx/static/styles/prettify-tomorrow.css +132 -0
  62. package/docs/template/edx/tmpl/augments.tmpl +10 -0
  63. package/docs/template/edx/tmpl/container.tmpl +196 -0
  64. package/docs/template/edx/tmpl/details.tmpl +143 -0
  65. package/docs/template/edx/tmpl/example.tmpl +2 -0
  66. package/docs/template/edx/tmpl/examples.tmpl +13 -0
  67. package/docs/template/edx/tmpl/exceptions.tmpl +32 -0
  68. package/docs/template/edx/tmpl/layout.tmpl +39 -0
  69. package/docs/template/edx/tmpl/mainpage.tmpl +10 -0
  70. package/docs/template/edx/tmpl/members.tmpl +38 -0
  71. package/docs/template/edx/tmpl/method.tmpl +131 -0
  72. package/docs/template/edx/tmpl/modifies.tmpl +14 -0
  73. package/docs/template/edx/tmpl/params.tmpl +131 -0
  74. package/docs/template/edx/tmpl/properties.tmpl +108 -0
  75. package/docs/template/edx/tmpl/returns.tmpl +19 -0
  76. package/docs/template/edx/tmpl/source.tmpl +8 -0
  77. package/docs/template/edx/tmpl/tutorial.tmpl +19 -0
  78. package/docs/template/edx/tmpl/type.tmpl +7 -0
  79. package/env.config.js +8 -0
  80. package/jsdoc.json +36 -0
  81. package/openedx.yaml +12 -0
  82. package/package.json +6 -6
  83. package/service-interface.png +0 -0
  84. package/src/analytics/MockAnalyticsService.js +71 -0
  85. package/src/analytics/SegmentAnalyticsService.js +243 -0
  86. package/src/analytics/index.js +12 -0
  87. package/src/analytics/interface.js +142 -0
  88. package/src/auth/AxiosCsrfTokenService.js +60 -0
  89. package/src/auth/AxiosJwtAuthService.js +364 -0
  90. package/src/auth/AxiosJwtTokenService.js +134 -0
  91. package/src/auth/LocalForageCache.js +78 -0
  92. package/src/auth/MockAuthService.js +285 -0
  93. package/src/auth/index.js +19 -0
  94. package/src/auth/interceptors/createCsrfTokenProviderInterceptor.js +37 -0
  95. package/src/auth/interceptors/createJwtTokenProviderInterceptor.js +38 -0
  96. package/src/auth/interceptors/createProcessAxiosRequestErrorInterceptor.js +20 -0
  97. package/src/auth/interceptors/createRetryInterceptor.js +72 -0
  98. package/src/auth/interface.js +309 -0
  99. package/src/auth/utils.js +105 -0
  100. package/src/config.js +327 -0
  101. package/src/constants.js +66 -0
  102. package/src/i18n/countries.js +57 -0
  103. package/src/i18n/index.js +123 -0
  104. package/src/i18n/injectIntlWithShim.jsx +45 -0
  105. package/src/i18n/languages.js +60 -0
  106. package/src/i18n/lib.js +282 -0
  107. package/src/i18n/scripts/README.md +29 -0
  108. package/src/i18n/scripts/intl-imports.js +259 -0
  109. package/src/i18n/scripts/transifex-utils.js +75 -0
  110. package/src/index.js +42 -0
  111. package/src/initialize.js +357 -0
  112. package/src/logging/MockLoggingService.js +31 -0
  113. package/src/logging/NewRelicLoggingService.js +181 -0
  114. package/src/logging/index.js +9 -0
  115. package/src/logging/interface.js +110 -0
  116. package/src/pubSub.js +47 -0
  117. package/src/react/AppContext.jsx +24 -0
  118. package/src/react/AppProvider.jsx +93 -0
  119. package/src/react/AuthenticatedPageRoute.jsx +60 -0
  120. package/src/react/ErrorBoundary.jsx +44 -0
  121. package/src/react/ErrorPage.jsx +76 -0
  122. package/src/react/LoginRedirect.jsx +16 -0
  123. package/src/react/OptionalReduxProvider.jsx +28 -0
  124. package/src/react/PageRoute.jsx +31 -0
  125. package/src/react/hooks.js +50 -0
  126. package/src/react/index.js +16 -0
  127. package/src/scripts/GoogleAnalyticsLoader.js +53 -0
  128. package/src/scripts/index.js +2 -0
  129. package/src/testing/index.js +9 -0
  130. package/src/testing/initializeMockApp.js +77 -0
  131. package/src/testing/mockMessages.js +21 -0
  132. package/src/utils.js +167 -0
  133. /package/{analytics → dist/analytics}/MockAnalyticsService.js +0 -0
  134. /package/{analytics → dist/analytics}/MockAnalyticsService.js.map +0 -0
  135. /package/{analytics → dist/analytics}/SegmentAnalyticsService.js +0 -0
  136. /package/{analytics → dist/analytics}/SegmentAnalyticsService.js.map +0 -0
  137. /package/{analytics → dist/analytics}/index.js +0 -0
  138. /package/{analytics → dist/analytics}/index.js.map +0 -0
  139. /package/{analytics → dist/analytics}/interface.js +0 -0
  140. /package/{analytics → dist/analytics}/interface.js.map +0 -0
  141. /package/{auth → dist/auth}/AxiosCsrfTokenService.js +0 -0
  142. /package/{auth → dist/auth}/AxiosCsrfTokenService.js.map +0 -0
  143. /package/{auth → dist/auth}/AxiosJwtAuthService.js +0 -0
  144. /package/{auth → dist/auth}/AxiosJwtAuthService.js.map +0 -0
  145. /package/{auth → dist/auth}/AxiosJwtTokenService.js +0 -0
  146. /package/{auth → dist/auth}/AxiosJwtTokenService.js.map +0 -0
  147. /package/{auth → dist/auth}/LocalForageCache.js +0 -0
  148. /package/{auth → dist/auth}/LocalForageCache.js.map +0 -0
  149. /package/{auth → dist/auth}/MockAuthService.js +0 -0
  150. /package/{auth → dist/auth}/MockAuthService.js.map +0 -0
  151. /package/{auth → dist/auth}/index.js +0 -0
  152. /package/{auth → dist/auth}/index.js.map +0 -0
  153. /package/{auth → dist/auth}/interceptors/createCsrfTokenProviderInterceptor.js +0 -0
  154. /package/{auth → dist/auth}/interceptors/createCsrfTokenProviderInterceptor.js.map +0 -0
  155. /package/{auth → dist/auth}/interceptors/createJwtTokenProviderInterceptor.js +0 -0
  156. /package/{auth → dist/auth}/interceptors/createJwtTokenProviderInterceptor.js.map +0 -0
  157. /package/{auth → dist/auth}/interceptors/createProcessAxiosRequestErrorInterceptor.js +0 -0
  158. /package/{auth → dist/auth}/interceptors/createProcessAxiosRequestErrorInterceptor.js.map +0 -0
  159. /package/{auth → dist/auth}/interceptors/createRetryInterceptor.js +0 -0
  160. /package/{auth → dist/auth}/interceptors/createRetryInterceptor.js.map +0 -0
  161. /package/{auth → dist/auth}/interface.js +0 -0
  162. /package/{auth → dist/auth}/interface.js.map +0 -0
  163. /package/{auth → dist/auth}/utils.js +0 -0
  164. /package/{auth → dist/auth}/utils.js.map +0 -0
  165. /package/{config.js → dist/config.js} +0 -0
  166. /package/{config.js.map → dist/config.js.map} +0 -0
  167. /package/{constants.js → dist/constants.js} +0 -0
  168. /package/{constants.js.map → dist/constants.js.map} +0 -0
  169. /package/{i18n → dist/i18n}/countries.js +0 -0
  170. /package/{i18n → dist/i18n}/countries.js.map +0 -0
  171. /package/{i18n → dist/i18n}/index.js +0 -0
  172. /package/{i18n → dist/i18n}/index.js.map +0 -0
  173. /package/{i18n → dist/i18n}/injectIntlWithShim.js +0 -0
  174. /package/{i18n → dist/i18n}/injectIntlWithShim.js.map +0 -0
  175. /package/{i18n → dist/i18n}/languages.js +0 -0
  176. /package/{i18n → dist/i18n}/languages.js.map +0 -0
  177. /package/{i18n → dist/i18n}/lib.js +0 -0
  178. /package/{i18n → dist/i18n}/lib.js.map +0 -0
  179. /package/{i18n → dist/i18n}/scripts/README.md +0 -0
  180. /package/{i18n → dist/i18n}/scripts/intl-imports.js +0 -0
  181. /package/{i18n → dist/i18n}/scripts/intl-imports.js.map +0 -0
  182. /package/{i18n → dist/i18n}/scripts/transifex-utils.js +0 -0
  183. /package/{i18n → dist/i18n}/scripts/transifex-utils.js.map +0 -0
  184. /package/{index.js → dist/index.js} +0 -0
  185. /package/{index.js.map → dist/index.js.map} +0 -0
  186. /package/{initialize.js → dist/initialize.js} +0 -0
  187. /package/{initialize.js.map → dist/initialize.js.map} +0 -0
  188. /package/{logging → dist/logging}/MockLoggingService.js +0 -0
  189. /package/{logging → dist/logging}/MockLoggingService.js.map +0 -0
  190. /package/{logging → dist/logging}/NewRelicLoggingService.js +0 -0
  191. /package/{logging → dist/logging}/NewRelicLoggingService.js.map +0 -0
  192. /package/{logging → dist/logging}/index.js +0 -0
  193. /package/{logging → dist/logging}/index.js.map +0 -0
  194. /package/{logging → dist/logging}/interface.js +0 -0
  195. /package/{logging → dist/logging}/interface.js.map +0 -0
  196. /package/{pubSub.js → dist/pubSub.js} +0 -0
  197. /package/{pubSub.js.map → dist/pubSub.js.map} +0 -0
  198. /package/{react → dist/react}/AppContext.js +0 -0
  199. /package/{react → dist/react}/AppContext.js.map +0 -0
  200. /package/{react → dist/react}/AppProvider.js +0 -0
  201. /package/{react → dist/react}/AppProvider.js.map +0 -0
  202. /package/{react → dist/react}/AuthenticatedPageRoute.js +0 -0
  203. /package/{react → dist/react}/AuthenticatedPageRoute.js.map +0 -0
  204. /package/{react → dist/react}/ErrorBoundary.js +0 -0
  205. /package/{react → dist/react}/ErrorBoundary.js.map +0 -0
  206. /package/{react → dist/react}/ErrorPage.js +0 -0
  207. /package/{react → dist/react}/ErrorPage.js.map +0 -0
  208. /package/{react → dist/react}/LoginRedirect.js +0 -0
  209. /package/{react → dist/react}/LoginRedirect.js.map +0 -0
  210. /package/{react → dist/react}/OptionalReduxProvider.js +0 -0
  211. /package/{react → dist/react}/OptionalReduxProvider.js.map +0 -0
  212. /package/{react → dist/react}/PageRoute.js +0 -0
  213. /package/{react → dist/react}/PageRoute.js.map +0 -0
  214. /package/{react → dist/react}/hooks.js +0 -0
  215. /package/{react → dist/react}/hooks.js.map +0 -0
  216. /package/{react → dist/react}/index.js +0 -0
  217. /package/{react → dist/react}/index.js.map +0 -0
  218. /package/{scripts → dist/scripts}/GoogleAnalyticsLoader.js +0 -0
  219. /package/{scripts → dist/scripts}/GoogleAnalyticsLoader.js.map +0 -0
  220. /package/{scripts → dist/scripts}/index.js +0 -0
  221. /package/{scripts → dist/scripts}/index.js.map +0 -0
  222. /package/{testing → dist/testing}/index.js +0 -0
  223. /package/{testing → dist/testing}/index.js.map +0 -0
  224. /package/{testing → dist/testing}/initializeMockApp.js +0 -0
  225. /package/{testing → dist/testing}/initializeMockApp.js.map +0 -0
  226. /package/{testing → dist/testing}/mockMessages.js +0 -0
  227. /package/{testing → dist/testing}/mockMessages.js.map +0 -0
  228. /package/{utils.js → dist/utils.js} +0 -0
  229. /package/{utils.js.map → dist/utils.js.map} +0 -0
@@ -0,0 +1,364 @@
1
+ import axios from 'axios';
2
+ import PropTypes from 'prop-types';
3
+ import { logFrontendAuthError } from './utils';
4
+ import { camelCaseObject, ensureDefinedConfig } from '../utils';
5
+ import createJwtTokenProviderInterceptor from './interceptors/createJwtTokenProviderInterceptor';
6
+ import createCsrfTokenProviderInterceptor from './interceptors/createCsrfTokenProviderInterceptor';
7
+ import createProcessAxiosRequestErrorInterceptor from './interceptors/createProcessAxiosRequestErrorInterceptor';
8
+ import AxiosJwtTokenService from './AxiosJwtTokenService';
9
+ import AxiosCsrfTokenService from './AxiosCsrfTokenService';
10
+ import configureCache from './LocalForageCache';
11
+
12
+ const optionsPropTypes = {
13
+ config: PropTypes.shape({
14
+ BASE_URL: PropTypes.string.isRequired,
15
+ LMS_BASE_URL: PropTypes.string.isRequired,
16
+ LOGIN_URL: PropTypes.string.isRequired,
17
+ LOGOUT_URL: PropTypes.string.isRequired,
18
+ REFRESH_ACCESS_TOKEN_ENDPOINT: PropTypes.string.isRequired,
19
+ ACCESS_TOKEN_COOKIE_NAME: PropTypes.string.isRequired,
20
+ CSRF_TOKEN_API_PATH: PropTypes.string.isRequired,
21
+ }).isRequired,
22
+ loggingService: PropTypes.shape({
23
+ logError: PropTypes.func.isRequired,
24
+ logInfo: PropTypes.func.isRequired,
25
+ }).isRequired,
26
+ };
27
+
28
+ /**
29
+ * @implements {AuthService}
30
+ * @memberof module:Auth
31
+ */
32
+ class AxiosJwtAuthService {
33
+ /**
34
+ * @param {Object} options
35
+ * @param {Object} options.config
36
+ * @param {string} options.config.BASE_URL
37
+ * @param {string} options.config.LMS_BASE_URL
38
+ * @param {string} options.config.LOGIN_URL
39
+ * @param {string} options.config.LOGOUT_URL
40
+ * @param {string} options.config.REFRESH_ACCESS_TOKEN_ENDPOINT
41
+ * @param {string} options.config.ACCESS_TOKEN_COOKIE_NAME
42
+ * @param {string} options.config.CSRF_TOKEN_API_PATH
43
+ * @param {Object} options.loggingService requires logError and logInfo methods
44
+ */
45
+ constructor(options) {
46
+ this.authenticatedHttpClient = null;
47
+ this.httpClient = null;
48
+ this.cachedAuthenticatedHttpClient = null;
49
+ this.cachedHttpClient = null;
50
+ this.authenticatedUser = null;
51
+
52
+ ensureDefinedConfig(options, 'AuthService');
53
+ PropTypes.checkPropTypes(optionsPropTypes, options, 'options', 'AuthService');
54
+
55
+ this.config = options.config;
56
+ this.loggingService = options.loggingService;
57
+ this.jwtTokenService = new AxiosJwtTokenService(
58
+ this.loggingService,
59
+ this.config.ACCESS_TOKEN_COOKIE_NAME,
60
+ this.config.REFRESH_ACCESS_TOKEN_ENDPOINT,
61
+ );
62
+ this.csrfTokenService = new AxiosCsrfTokenService(this.config.CSRF_TOKEN_API_PATH);
63
+ this.authenticatedHttpClient = this.addAuthenticationToHttpClient(axios.create());
64
+ this.httpClient = axios.create();
65
+ configureCache()
66
+ .then((cachedAxiosClient) => {
67
+ this.cachedAuthenticatedHttpClient = this.addAuthenticationToHttpClient(cachedAxiosClient);
68
+ this.cachedHttpClient = cachedAxiosClient;
69
+ })
70
+ .catch((e) => {
71
+ // fallback to non-cached HTTP clients and log error
72
+ this.cachedAuthenticatedHttpClient = this.authenticatedHttpClient;
73
+ this.cachedHttpClient = this.httpClient;
74
+ logFrontendAuthError(this.loggingService, `configureCache failed with error: ${e.message}`);
75
+ }).finally(() => {
76
+ this.middleware = options.middleware;
77
+ this.applyMiddleware(options.middleware);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Applies middleware to the axios instances in this service.
83
+ *
84
+ * @param {Array} middleware Middleware to apply.
85
+ */
86
+ applyMiddleware(middleware = []) {
87
+ const clients = [
88
+ this.authenticatedHttpClient, this.httpClient,
89
+ this.cachedAuthenticatedHttpClient, this.cachedHttpClient,
90
+ ];
91
+ try {
92
+ (middleware).forEach((middlewareFn) => {
93
+ clients.forEach((client) => client && middlewareFn(client));
94
+ });
95
+ } catch (error) {
96
+ logFrontendAuthError(this.loggingService, error);
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Gets the authenticated HTTP client for the service. This is an axios instance.
103
+ *
104
+ * @param {Object} [options] Optional options for how the HTTP client should be configured.
105
+ * @param {boolean} [options.useCache] Whether to use front end caching for all requests made
106
+ * with the returned client.
107
+ *
108
+ * @returns {HttpClient} A configured axios http client which can be used for authenticated
109
+ * requests.
110
+ */
111
+ getAuthenticatedHttpClient(options = {}) {
112
+ if (options.useCache) {
113
+ return this.cachedAuthenticatedHttpClient;
114
+ }
115
+
116
+ return this.authenticatedHttpClient;
117
+ }
118
+
119
+ /**
120
+ * Gets the unauthenticated HTTP client for the service. This is an axios instance.
121
+ *
122
+ * @param {Object} [options] Optional options for how the HTTP client should be configured.
123
+ * @param {boolean} [options.useCache] Whether to use front end caching for all requests made
124
+ * with the returned client.
125
+ * @returns {HttpClient} A configured axios http client.
126
+ */
127
+ getHttpClient(options = {}) {
128
+ if (options.useCache) {
129
+ return this.cachedHttpClient;
130
+ }
131
+
132
+ return this.httpClient;
133
+ }
134
+
135
+ /**
136
+ * Used primarily for testing.
137
+ *
138
+ * @ignore
139
+ */
140
+ getJwtTokenService() {
141
+ return this.jwtTokenService;
142
+ }
143
+
144
+ /**
145
+ * Used primarily for testing.
146
+ *
147
+ * @ignore
148
+ */
149
+ getCsrfTokenService() {
150
+ return this.csrfTokenService;
151
+ }
152
+
153
+ /**
154
+ * Builds a URL to the login page with a post-login redirect URL attached as a query parameter.
155
+ *
156
+ * ```
157
+ * const url = getLoginRedirectUrl('http://localhost/mypage');
158
+ * console.log(url); // http://localhost/login?next=http%3A%2F%2Flocalhost%2Fmypage
159
+ * ```
160
+ *
161
+ * @param {string} redirectUrl The URL the user should be redirected to after logging in.
162
+ */
163
+ getLoginRedirectUrl(redirectUrl = this.config.BASE_URL) {
164
+ return `${this.config.LOGIN_URL}?next=${encodeURIComponent(redirectUrl)}`;
165
+ }
166
+
167
+ /**
168
+ * Redirects the user to the login page.
169
+ *
170
+ * @param {string} redirectUrl The URL the user should be redirected to after logging in.
171
+ */
172
+ redirectToLogin(redirectUrl = this.config.BASE_URL) {
173
+ global.location.assign(this.getLoginRedirectUrl(redirectUrl));
174
+ }
175
+
176
+ /**
177
+ * Builds a URL to the logout page with a post-logout redirect URL attached as a query parameter.
178
+ *
179
+ * ```
180
+ * const url = getLogoutRedirectUrl('http://localhost/mypage');
181
+ * console.log(url); // http://localhost/logout?next=http%3A%2F%2Flocalhost%2Fmypage
182
+ * ```
183
+ *
184
+ * @param {string} redirectUrl The URL the user should be redirected to after logging out.
185
+ */
186
+ getLogoutRedirectUrl(redirectUrl = this.config.BASE_URL) {
187
+ return `${this.config.LOGOUT_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
188
+ }
189
+
190
+ /**
191
+ * Redirects the user to the logout page.
192
+ *
193
+ * @param {string} redirectUrl The URL the user should be redirected to after logging out.
194
+ */
195
+ redirectToLogout(redirectUrl = this.config.BASE_URL) {
196
+ global.location.assign(this.getLogoutRedirectUrl(redirectUrl));
197
+ }
198
+
199
+ /**
200
+ * If it exists, returns the user data representing the currently authenticated user. If the
201
+ * user is anonymous, returns null.
202
+ *
203
+ * @returns {UserData|null}
204
+ */
205
+ getAuthenticatedUser() {
206
+ return this.authenticatedUser;
207
+ }
208
+
209
+ /**
210
+ * Sets the authenticated user to the provided value.
211
+ *
212
+ * @param {UserData} authUser
213
+ */
214
+ setAuthenticatedUser(authUser) {
215
+ this.authenticatedUser = authUser;
216
+ }
217
+
218
+ /**
219
+ * Reads the authenticated user's access token. Resolves to null if the user is
220
+ * unauthenticated.
221
+ *
222
+ * @returns {Promise<UserData>|Promise<null>} Resolves to the user's access token if they are
223
+ * logged in.
224
+ */
225
+ async fetchAuthenticatedUser(options = {}) {
226
+ const decodedAccessToken = await this.jwtTokenService.getJwtToken(options.forceRefresh || false);
227
+
228
+ if (decodedAccessToken !== null) {
229
+ this.setAuthenticatedUser({
230
+ email: decodedAccessToken.email,
231
+ userId: decodedAccessToken.user_id,
232
+ username: decodedAccessToken.preferred_username,
233
+ roles: decodedAccessToken.roles || [],
234
+ administrator: decodedAccessToken.administrator,
235
+ name: decodedAccessToken.name,
236
+ });
237
+ // Sets userId as a custom attribute that will be included with all subsequent log messages.
238
+ // Very helpful for debugging.
239
+ this.loggingService.setCustomAttribute('userId', decodedAccessToken.user_id);
240
+ } else {
241
+ this.setAuthenticatedUser(null);
242
+ // Intentionally not setting `userId` in the logging service here because it would be useful
243
+ // to know the previously logged in user for debugging refresh issues.
244
+ }
245
+
246
+ return this.getAuthenticatedUser();
247
+ }
248
+
249
+ /**
250
+ * Ensures a user is authenticated. It will redirect to login when not
251
+ * authenticated.
252
+ *
253
+ * @param {string} [redirectUrl=config.BASE_URL] to return user after login when not
254
+ * authenticated.
255
+ * @returns {Promise<UserData>}
256
+ */
257
+ async ensureAuthenticatedUser(redirectUrl = this.config.BASE_URL) {
258
+ await this.fetchAuthenticatedUser();
259
+
260
+ if (this.getAuthenticatedUser() === null) {
261
+ const isRedirectFromLoginPage = global.document.referrer
262
+ && global.document.referrer.startsWith(this.config.LOGIN_URL);
263
+
264
+ if (isRedirectFromLoginPage) {
265
+ const redirectLoopError = new Error('Redirect from login page. Rejecting to avoid infinite redirect loop.');
266
+ logFrontendAuthError(this.loggingService, redirectLoopError);
267
+ throw redirectLoopError;
268
+ }
269
+
270
+ // The user is not authenticated, send them to the login page.
271
+ this.redirectToLogin(redirectUrl);
272
+
273
+ const unauthorizedError = new Error('Failed to ensure the user is authenticated');
274
+ unauthorizedError.isRedirecting = true;
275
+ throw unauthorizedError;
276
+ }
277
+
278
+ return this.getAuthenticatedUser();
279
+ }
280
+
281
+ /**
282
+ * Fetches additional user account information for the authenticated user and merges it into the
283
+ * existing authenticatedUser object, available via getAuthenticatedUser().
284
+ *
285
+ * ```
286
+ * console.log(authenticatedUser); // Will be sparse and only contain basic information.
287
+ * await hydrateAuthenticatedUser()
288
+ * const authenticatedUser = getAuthenticatedUser();
289
+ * console.log(authenticatedUser); // Will contain additional user information
290
+ * ```
291
+ *
292
+ * @returns {Promise<null>}
293
+ */
294
+ async hydrateAuthenticatedUser() {
295
+ const user = this.getAuthenticatedUser();
296
+ if (user !== null) {
297
+ const response = await this.authenticatedHttpClient
298
+ .get(`${this.config.LMS_BASE_URL}/api/user/v1/accounts/${user.username}`);
299
+ this.setAuthenticatedUser({ ...user, ...camelCaseObject(response.data) });
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Adds authentication defaults and interceptors to an HTTP client instance.
305
+ *
306
+ * @param {HttpClient} newHttpClient
307
+ * @param {Object} config
308
+ * @param {string} [config.REFRESH_ACCESS_TOKEN_ENDPOINT]
309
+ * @param {string} [config.ACCESS_TOKEN_COOKIE_NAME]
310
+ * @param {string} [config.CSRF_TOKEN_API_PATH]
311
+ * @returns {HttpClient} A configured Axios HTTP client.
312
+ */
313
+ addAuthenticationToHttpClient(newHttpClient) {
314
+ const httpClient = Object.create(newHttpClient);
315
+ // Set withCredentials to true. Enables cross-site Access-Control requests
316
+ // to be made using cookies, authorization headers or TLS client
317
+ // certificates. More on MDN:
318
+ // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
319
+ httpClient.defaults.withCredentials = true;
320
+
321
+ // Axios interceptors
322
+
323
+ // The JWT access token interceptor attempts to refresh the user's jwt token
324
+ // before any request unless the isPublic flag is set on the request config.
325
+ const refreshAccessTokenInterceptor = createJwtTokenProviderInterceptor({
326
+ jwtTokenService: this.jwtTokenService,
327
+ shouldSkip: axiosRequestConfig => axiosRequestConfig.isPublic,
328
+ });
329
+ // The CSRF token intercepter fetches and caches a csrf token for any post,
330
+ // put, patch, or delete request. That token is then added to the request
331
+ // headers.
332
+ const attachCsrfTokenInterceptor = createCsrfTokenProviderInterceptor({
333
+ csrfTokenService: this.csrfTokenService,
334
+ CSRF_TOKEN_API_PATH: this.config.CSRF_TOKEN_API_PATH,
335
+ shouldSkip: (axiosRequestConfig) => {
336
+ const { method, isCsrfExempt } = axiosRequestConfig;
337
+ const CSRF_PROTECTED_METHODS = ['post', 'put', 'patch', 'delete'];
338
+ return isCsrfExempt || !CSRF_PROTECTED_METHODS.includes(method);
339
+ },
340
+ });
341
+
342
+ const processAxiosRequestErrorInterceptor = createProcessAxiosRequestErrorInterceptor({
343
+ loggingService: this.loggingService,
344
+ });
345
+
346
+ // Request interceptors: Axios runs the interceptors in reverse order from
347
+ // how they are listed. After fetching csrf tokens no longer require jwt
348
+ // authentication, it won't matter which happens first. This change is
349
+ // coming soon in edx-platform. Nov. 2019
350
+ httpClient.interceptors.request.use(attachCsrfTokenInterceptor);
351
+ httpClient.interceptors.request.use(refreshAccessTokenInterceptor);
352
+
353
+ // Response interceptor: moves axios response error data into the error
354
+ // object at error.customAttributes
355
+ httpClient.interceptors.response.use(
356
+ response => response,
357
+ processAxiosRequestErrorInterceptor,
358
+ );
359
+
360
+ return httpClient;
361
+ }
362
+ }
363
+
364
+ export default AxiosJwtAuthService;
@@ -0,0 +1,134 @@
1
+ import Cookies from 'universal-cookie';
2
+ import jwtDecode from 'jwt-decode';
3
+ import axios from 'axios';
4
+ import { logFrontendAuthError, processAxiosErrorAndThrow } from './utils';
5
+ import createRetryInterceptor from './interceptors/createRetryInterceptor';
6
+
7
+ export default class AxiosJwtTokenService {
8
+ static isTokenExpired(token) {
9
+ return !token || token.exp < Date.now() / 1000;
10
+ }
11
+
12
+ constructor(loggingService, tokenCookieName, tokenRefreshEndpoint) {
13
+ this.loggingService = loggingService;
14
+ this.tokenCookieName = tokenCookieName;
15
+ this.tokenRefreshEndpoint = tokenRefreshEndpoint;
16
+
17
+ this.httpClient = axios.create();
18
+ // Set withCredentials to true. Enables cross-site Access-Control requests
19
+ // to be made using cookies, authorization headers or TLS client
20
+ // certificates. More on MDN:
21
+ // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
22
+ this.httpClient.defaults.withCredentials = true;
23
+ // Add retries to this axios instance
24
+ this.httpClient.interceptors.response.use(
25
+ response => response,
26
+ createRetryInterceptor({ httpClient: this.httpClient }),
27
+ );
28
+
29
+ this.cookies = new Cookies();
30
+ this.refreshRequestPromises = {};
31
+ }
32
+
33
+ getHttpClient() {
34
+ return this.httpClient;
35
+ }
36
+
37
+ decodeJwtCookie() {
38
+ const cookieValue = this.cookies.get(this.tokenCookieName);
39
+
40
+ if (cookieValue) {
41
+ try {
42
+ return jwtDecode(cookieValue);
43
+ } catch (e) {
44
+ const error = Object.create(e);
45
+ error.message = 'Error decoding JWT token';
46
+ error.customAttributes = { cookieValue };
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ refresh() {
55
+ let responseServerEpochSeconds = 0;
56
+
57
+ if (this.refreshRequestPromises[this.tokenCookieName] === undefined) {
58
+ const makeRefreshRequest = async () => {
59
+ let axiosResponse;
60
+ try {
61
+ try {
62
+ axiosResponse = await this.httpClient.post(this.tokenRefreshEndpoint);
63
+ // eslint-disable-next-line max-len
64
+ if (axiosResponse.data && axiosResponse.data.response_epoch_seconds) {
65
+ responseServerEpochSeconds = axiosResponse.data.response_epoch_seconds;
66
+ }
67
+ } catch (error) {
68
+ processAxiosErrorAndThrow(error);
69
+ }
70
+ } catch (error) {
71
+ const userIsUnauthenticated = error.response && error.response.status === 401;
72
+ if (userIsUnauthenticated) {
73
+ // Clean up the cookie if it exists to eliminate any situation
74
+ // where the cookie is not expired but the jwt is expired.
75
+ this.cookies.remove(this.tokenCookieName);
76
+ const decodedJwtToken = null;
77
+ return decodedJwtToken;
78
+ }
79
+
80
+ // TODO: Network timeouts and other problems will end up in
81
+ // this block of code. We could add logic for retrying token
82
+ // refreshes if we wanted to.
83
+ throw error;
84
+ }
85
+
86
+ const browserEpochSeconds = Date.now() / 1000;
87
+ const browserDriftSeconds = responseServerEpochSeconds > 0
88
+ ? Math.abs(browserEpochSeconds - responseServerEpochSeconds)
89
+ : null;
90
+
91
+ const decodedJwtToken = this.decodeJwtCookie();
92
+
93
+ if (!decodedJwtToken) {
94
+ // This is an unexpected case. The refresh endpoint should set the
95
+ // cookie that is needed.
96
+ // For more details, see:
97
+ // docs/decisions/0005-token-null-after-successful-refresh.rst
98
+ const error = new Error('Access token is still null after successful refresh.');
99
+ error.customAttributes = { axiosResponse, browserDriftSeconds, browserEpochSeconds };
100
+ throw error;
101
+ }
102
+
103
+ return decodedJwtToken;
104
+ };
105
+
106
+ this.refreshRequestPromises[this.tokenCookieName] = makeRefreshRequest().finally(() => {
107
+ delete this.refreshRequestPromises[this.tokenCookieName];
108
+ });
109
+ }
110
+
111
+ return this.refreshRequestPromises[this.tokenCookieName];
112
+ }
113
+
114
+ async getJwtToken(forceRefresh = false) {
115
+ try {
116
+ const decodedJwtToken = this.decodeJwtCookie(this.tokenCookieName);
117
+ if (!AxiosJwtTokenService.isTokenExpired(decodedJwtToken) && !forceRefresh) {
118
+ return decodedJwtToken;
119
+ }
120
+ } catch (e) {
121
+ // Log unexpected error and continue with attempt to refresh it.
122
+ // TODO: Fix these. They're still using loggingService as a singleton.
123
+ logFrontendAuthError(this.loggingService, e);
124
+ }
125
+
126
+ try {
127
+ return await this.refresh();
128
+ } catch (e) {
129
+ // TODO: Fix these. They're still using loggingService as a singleton.
130
+ logFrontendAuthError(this.loggingService, e);
131
+ throw e;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,78 @@
1
+ /* eslint-disable no-underscore-dangle */
2
+ import localforage from 'localforage';
3
+ import memoryDriver from 'localforage-memoryStorageDriver';
4
+ import {
5
+ setupCache,
6
+ defaultKeyGenerator,
7
+ defaultHeaderInterpreter,
8
+ buildStorage,
9
+ } from 'axios-cache-interceptor';
10
+ import axios from 'axios';
11
+
12
+ /**
13
+ * Async function to configure localforage and setup the cache
14
+ *
15
+ * @returns {Promise} A promise that, when resolved, returns an axios instance configured to
16
+ * use localforage as a cache.
17
+ */
18
+ export default async function configureCache() {
19
+ // Register the imported `memoryDriver` to `localforage`
20
+ await localforage.defineDriver(memoryDriver);
21
+
22
+ // Create `localforage` instance
23
+ const forageStore = localforage.createInstance({
24
+ // List of drivers used
25
+ driver: [
26
+ localforage.INDEXEDDB,
27
+ localforage.LOCALSTORAGE,
28
+ memoryDriver._driver,
29
+ ],
30
+ name: 'edx-cache',
31
+ });
32
+
33
+ const forageStoreAdapter = buildStorage({
34
+ async find(key) {
35
+ const result = await forageStore.getItem(`axios-cache:${key}`);
36
+ return JSON.parse(result);
37
+ },
38
+
39
+ async set(key, value) {
40
+ await forageStore.setItem(`axios-cache:${key}`, JSON.stringify(value));
41
+ },
42
+
43
+ async remove(key) {
44
+ await forageStore.removeItem(`axios-cache:${key}`);
45
+ },
46
+ });
47
+
48
+ // only GET methods are cached by default
49
+ return setupCache(
50
+ // axios instance
51
+ axios.create(),
52
+ {
53
+ ttl: 5 * 60 * 1000, // default maxAge of 5 minutes
54
+ // The storage to save the cache data. There are more available by default.
55
+ //
56
+ // https://axios-cache-interceptor.js.org/#/pages/storages
57
+ storage: forageStoreAdapter,
58
+
59
+ // The mechanism to generate a unique key for each request.
60
+ //
61
+ // https://axios-cache-interceptor.js.org/#/pages/request-id
62
+ generateKey: defaultKeyGenerator,
63
+
64
+ // The mechanism to interpret headers (when cache.interpretHeader is true).
65
+ //
66
+ // https://axios-cache-interceptor.js.org/#/pages/global-configuration?id=headerinterpreter
67
+ headerInterpreter: defaultHeaderInterpreter,
68
+
69
+ // The function that will receive debug information.
70
+ // NOTE: For this to work, you need to enable development mode.
71
+ //
72
+ // https://axios-cache-interceptor.js.org/#/pages/development-mode
73
+ // https://axios-cache-interceptor.js.org/#/pages/global-configuration?id=debug
74
+ // eslint-disable-next-line no-console
75
+ debug: console.log,
76
+ },
77
+ );
78
+ }