Source

authenticators/oauth2-password-grant.ts

  1. import { makeArray } from '@ember/array';
  2. import { warn } from '@ember/debug';
  3. import { getOwner } from '@ember/application';
  4. import BaseAuthenticator from './base';
  5. import isFastBoot from '../utils/is-fastboot';
  6. import { waitFor } from '@ember/test-waiters';
  7. import { isTesting } from '@embroider/macros';
  8. import type { Timer } from '@ember/runloop';
  9. import { run, later, cancel } from '@ember/runloop';
  10. export type OAuthResponseSuccess = {
  11. access_token: string;
  12. token_type: string;
  13. expires_in?: number;
  14. expires_at?: number;
  15. refresh_token?: string;
  16. scope?: string;
  17. };
  18. export type OAuthPasswordRequestData = {
  19. grant_type: string;
  20. username: string;
  21. password: string;
  22. client_id?: string;
  23. scope?: string;
  24. };
  25. export type OAuthInvalidateRequestData = {
  26. token_type_hint: 'access_token' | 'refresh_token';
  27. token: string;
  28. client_id?: string;
  29. scope?: string;
  30. };
  31. export type OAuthRefreshRequestData = {
  32. grant_type: 'refresh_token';
  33. refresh_token: string;
  34. scope?: string;
  35. client_id?: string;
  36. };
  37. export type MakeRequestData =
  38. | OAuthPasswordRequestData
  39. | OAuthInvalidateRequestData
  40. | OAuthRefreshRequestData;
  41. export interface OAuth2Response extends Response {
  42. /**
  43. * @deprecated 'responseText' is deprecated. This is a legacy AJAX API.
  44. */
  45. responseText: string;
  46. /**
  47. * @deprecated 'responseJSON' is deprecated. This is a legacy AJAX API.
  48. */
  49. responseJSON: string;
  50. }
  51. /**
  52. Authenticator that conforms to OAuth 2
  53. ([RFC 6749](http://tools.ietf.org/html/rfc6749)), specifically the _"Resource
  54. Owner Password Credentials Grant Type"_.
  55. This authenticator also automatically refreshes access tokens (see
  56. [RFC 6749, section 6](http://tools.ietf.org/html/rfc6749#section-6)) if the
  57. server supports it.
  58. @class OAuth2PasswordGrantAuthenticator
  59. @extends BaseAuthenticator
  60. @public
  61. */
  62. export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator {
  63. /**
  64. Triggered when the authenticator refreshed the access token (see
  65. [RFC 6749, section 6](http://tools.ietf.org/html/rfc6749#section-6)).
  66. @memberof OAuth2PasswordGrantAuthenticator
  67. @event sessionDataUpdated
  68. @param {Object} data The updated session data
  69. @public
  70. */
  71. /**
  72. The client_id to be sent to the authentication server (see
  73. https://tools.ietf.org/html/rfc6749#appendix-A.1).
  74. This should only be
  75. used for statistics or logging etc. as it cannot actually be trusted since
  76. it could have been manipulated on the client!
  77. @memberof OAuth2PasswordGrantAuthenticator
  78. @property clientId
  79. @type String
  80. @default null
  81. @public
  82. */
  83. clientId: string | null = null;
  84. /**
  85. The endpoint on the server that authentication and token refresh requests
  86. are sent to.
  87. @memberof OAuth2PasswordGrantAuthenticator
  88. @property serverTokenEndpoint
  89. @type String
  90. @default '/token'
  91. @public
  92. */
  93. serverTokenEndpoint: string = '/token';
  94. /**
  95. The endpoint on the server that token revocation requests are sent to. Only
  96. set this if the server actually supports token revocation. If this is
  97. `null`, the authenticator will not revoke tokens on session invalidation.
  98. If token revocation is enabled but fails, session invalidation will be
  99. intercepted and the session will remain authenticated (see
  100. {@linkplain OAuth2PasswordGrantAuthenticator.invalidate}.
  101. @memberof OAuth2PasswordGrantAuthenticator
  102. @property serverTokenRevocationEndpoint
  103. @type String
  104. @default null
  105. @public
  106. */
  107. serverTokenRevocationEndpoint: string | null = null;
  108. /**
  109. Sets whether the authenticator automatically refreshes access tokens if the
  110. server supports it.
  111. @memberof OAuth2PasswordGrantAuthenticator
  112. @property refreshAccessTokens
  113. @type Boolean
  114. @default true
  115. @public
  116. */
  117. refreshAccessTokens = true;
  118. /**
  119. Sets whether the authenticator use the scope when refreshing access tokens
  120. if the server supports it.
  121. @memberof OAuth2PasswordGrantAuthenticator
  122. @property refreshAccessTokensWithScope
  123. @type Boolean
  124. @default false
  125. @public
  126. */
  127. refreshAccessTokensWithScope = false;
  128. /**
  129. The offset time in milliseconds to refresh the access token. This must
  130. return a random number. This randomization is needed because in case of
  131. multiple tabs, we need to prevent the tabs from sending refresh token
  132. request at the same exact moment.
  133. When overriding this property, make sure to mark the overridden property
  134. as volatile so it will actually have a different value each time it is
  135. accessed.
  136. @memberof OAuth2PasswordGrantAuthenticator
  137. @property tokenRefreshOffset
  138. @type Integer
  139. @default a random number between 5 and 10
  140. @public
  141. */
  142. get tokenRefreshOffset() {
  143. const min = 5;
  144. const max = 10;
  145. return (Math.floor(Math.random() * (max - min)) + min) * 1000;
  146. }
  147. _refreshTokenTimeout: Timer | undefined = undefined;
  148. /**
  149. Restores the session from a session data object; __will return a resolving
  150. promise when there is a non-empty `access_token` in the session data__ and
  151. a rejecting promise otherwise.
  152. If the server issues
  153. [expiring access tokens](https://tools.ietf.org/html/rfc6749#section-5.1)
  154. and there is an expired access token in the session data along with a
  155. refresh token, the authenticator will try to refresh the access token and
  156. return a promise that resolves with the new access token if the refresh was
  157. successful. If there is no refresh token or the token refresh is not
  158. successful, a rejecting promise will be returned.
  159. @memberof OAuth2PasswordGrantAuthenticator
  160. @method restore
  161. @param {Object} data The data to restore the session from
  162. @return {Promise} A promise that when it resolves results in the session becoming or remaining authenticated. If restoration fails, the promise will reject with the server response (in case the access token had expired and was refreshed using a refresh token); however, the authenticator reads that response already so if you need to read it again you need to clone the response object first
  163. @public
  164. */
  165. restore(data: OAuthResponseSuccess) {
  166. return new Promise((resolve, reject) => {
  167. const now = new Date().getTime();
  168. const refreshAccessTokens = this.get('refreshAccessTokens');
  169. if (data['expires_at'] && data['expires_at'] < now) {
  170. if (refreshAccessTokens) {
  171. this._refreshAccessToken(
  172. data['expires_in'],
  173. data['refresh_token'] as string,
  174. data['scope']
  175. ).then(resolve, reject);
  176. } else {
  177. reject();
  178. }
  179. } else {
  180. if (!this._validate(data)) {
  181. reject();
  182. } else {
  183. this._scheduleAccessTokenRefresh(
  184. data['expires_in'],
  185. data['expires_at'],
  186. data['refresh_token']
  187. );
  188. resolve(data);
  189. }
  190. }
  191. });
  192. }
  193. /**
  194. Authenticates the session with the specified `identification`, `password`
  195. and optional `scope`; issues a `POST` request to the
  196. {@linkplain OAuth2PasswordGrantAuthenticator.serverTokenEndpoint}
  197. and receives the access token in response (see
  198. {@link https://tools.ietf.org/html/rfc6749#section-4.3}).
  199. If the credentials are valid (and the optionally requested scope is
  200. granted) and thus authentication succeeds, a promise that resolves with the
  201. server's response is returned, otherwise a promise that rejects with the
  202. error as returned by the server is returned.
  203. If the
  204. [server supports it]{@link https://tools.ietf.org/html/rfc6749#section-5.1}, this
  205. method also schedules refresh requests for the access token before it
  206. expires.
  207. The server responses are expected to look as defined in the spec (see
  208. http://tools.ietf.org/html/rfc6749#section-5). The response to a successful
  209. authentication request should be:
  210. ```json
  211. HTTP/1.1 200 OK
  212. Content-Type: application/json;charset=UTF-8
  213. {
  214. "access_token":"2YotnFZFEjr1zCsicMWpAA",
  215. "token_type":"bearer",
  216. "expires_in":3600, // optional
  217. "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA" // optional
  218. }
  219. ```
  220. The response for a failing authentication request should be:
  221. ```json
  222. HTTP/1.1 400 Bad Request
  223. Content-Type: application/json;charset=UTF-8
  224. {
  225. "error":"invalid_grant"
  226. }
  227. ```
  228. A full list of error codes can be found
  229. [here](https://tools.ietf.org/html/rfc6749#section-5.2).
  230. @memberof OAuth2PasswordGrantAuthenticator
  231. @method authenticate
  232. @param {String} identification The resource owner username
  233. @param {String} password The resource owner password
  234. @param {String|Array} scope The scope of the access request (see [RFC 6749, section 3.3](http://tools.ietf.org/html/rfc6749#section-3.3))
  235. @param {Object} headers Optional headers that particular backends may require (for example sending 2FA challenge responses)
  236. @return {Promise} A promise that when it resolves results in the session becoming authenticated. If authentication fails, the promise will reject with the server response; however, the authenticator reads that response already so if you need to read it again you need to clone the response object first
  237. @public
  238. */
  239. authenticate(identification: string, password: string, scope = [], headers = {}) {
  240. return new Promise((resolve, reject) => {
  241. const data: OAuthPasswordRequestData = {
  242. grant_type: 'password',
  243. username: identification,
  244. password,
  245. };
  246. const serverTokenEndpoint = this.get('serverTokenEndpoint');
  247. const scopesString = makeArray(scope).join(' ');
  248. if (scopesString.trim().length > 0) {
  249. data.scope = scopesString;
  250. }
  251. this.makeRequest(serverTokenEndpoint, data, headers).then(
  252. response => {
  253. run(() => {
  254. if (!this._validate(response)) {
  255. reject('access_token is missing in server response');
  256. }
  257. const expiresAt = this._absolutizeExpirationTime(response['expires_in']);
  258. this._scheduleAccessTokenRefresh(
  259. response['expires_in'],
  260. expiresAt,
  261. response['refresh_token']
  262. );
  263. if (expiresAt) {
  264. response = Object.assign(response, { expires_at: expiresAt });
  265. }
  266. resolve(response);
  267. });
  268. },
  269. response => {
  270. run(null, reject, response);
  271. }
  272. );
  273. });
  274. }
  275. /**
  276. If token revocation is enabled, this will revoke the access token (and the
  277. refresh token if present). If token revocation succeeds, this method
  278. returns a resolving promise, otherwise it will return a rejecting promise,
  279. thus intercepting session invalidation.
  280. If token revocation is not enabled this method simply returns a resolving
  281. promise.
  282. @memberof OAuth2PasswordGrantAuthenticator
  283. @method invalidate
  284. @param {Object} data The current authenticated session data
  285. @return {Promise} A promise that when it resolves results in the session being invalidated. If invalidation fails, the promise will reject with the server response (in case token revocation is used); however, the authenticator reads that response already so if you need to read it again you need to clone the response object first
  286. @public
  287. */
  288. invalidate(data: OAuthResponseSuccess) {
  289. const serverTokenRevocationEndpoint = this.get('serverTokenRevocationEndpoint');
  290. const success = (resolve: (value?: unknown) => void) => {
  291. cancel(this._refreshTokenTimeout);
  292. delete this._refreshTokenTimeout;
  293. resolve();
  294. };
  295. return new Promise(resolve => {
  296. if (!serverTokenRevocationEndpoint) {
  297. success(resolve);
  298. } else {
  299. const requests: Promise<OAuthResponseSuccess>[] = [];
  300. (['access_token', 'refresh_token'] as const).forEach(tokenType => {
  301. const token = data[tokenType];
  302. if (token) {
  303. requests.push(
  304. this.makeRequest(serverTokenRevocationEndpoint, {
  305. token_type_hint: tokenType,
  306. token,
  307. })
  308. );
  309. }
  310. });
  311. const succeed = () => {
  312. success.apply(this, [resolve]);
  313. };
  314. Promise.all(requests).then(succeed, succeed);
  315. }
  316. });
  317. }
  318. /**
  319. Makes a request to the OAuth 2.0 server.
  320. @memberof OAuth2PasswordGrantAuthenticator
  321. @method makeRequest
  322. @param {String} url The request URL
  323. @param {Object} data The request data
  324. @param {Object} headers Additional headers to send in request
  325. @return {Promise} A promise that resolves with the response object
  326. @protected
  327. */
  328. @waitFor
  329. makeRequest(
  330. url: string,
  331. data: MakeRequestData,
  332. headers: Record<string, string> = {}
  333. ): Promise<OAuthResponseSuccess> {
  334. headers['Content-Type'] = 'application/x-www-form-urlencoded';
  335. const clientId = this.get('clientId');
  336. if (clientId) {
  337. data.client_id = clientId;
  338. }
  339. const body = Object.keys(data)
  340. .map(key => {
  341. const value = data[key as keyof MakeRequestData];
  342. if (value) {
  343. return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
  344. } else {
  345. return null;
  346. }
  347. })
  348. .filter(Boolean)
  349. .join('&');
  350. const options = {
  351. body,
  352. headers,
  353. method: 'POST',
  354. };
  355. return new Promise((resolve, reject) => {
  356. fetch(url, options)
  357. .then(response => {
  358. response.text().then(text => {
  359. try {
  360. let json = JSON.parse(text);
  361. if (!response.ok) {
  362. (response as OAuth2Response).responseJSON = json;
  363. reject(response);
  364. } else {
  365. resolve(json);
  366. }
  367. } catch (SyntaxError) {
  368. (response as OAuth2Response).responseText = text;
  369. reject(response);
  370. }
  371. });
  372. })
  373. .catch(reject);
  374. });
  375. }
  376. _scheduleAccessTokenRefresh(
  377. expiresIn: number | undefined,
  378. expiresAt: number | null | undefined,
  379. refreshToken: string | undefined
  380. ) {
  381. const refreshAccessTokens = this.get('refreshAccessTokens') && !isFastBoot(getOwner(this));
  382. if (refreshAccessTokens) {
  383. const now = new Date().getTime();
  384. if (!expiresAt && expiresIn) {
  385. expiresAt = new Date(now + expiresIn * 1000).getTime();
  386. }
  387. const offset = this.get('tokenRefreshOffset');
  388. if (refreshToken && expiresAt && expiresAt > now - offset) {
  389. cancel(this._refreshTokenTimeout);
  390. delete this._refreshTokenTimeout;
  391. if (!isTesting()) {
  392. this._refreshTokenTimeout = later(
  393. () => {
  394. this._refreshAccessToken(expiresIn, refreshToken);
  395. },
  396. (expiresAt as number) - now - offset
  397. );
  398. }
  399. }
  400. }
  401. }
  402. _refreshAccessToken(expiresIn: number | undefined, refreshToken: string, scope?: string) {
  403. const data: OAuthRefreshRequestData = {
  404. grant_type: 'refresh_token',
  405. refresh_token: refreshToken,
  406. scope: '',
  407. };
  408. const refreshAccessTokensWithScope = this.get('refreshAccessTokensWithScope');
  409. if (refreshAccessTokensWithScope && scope) {
  410. data.scope = scope;
  411. }
  412. const serverTokenEndpoint = this.get('serverTokenEndpoint');
  413. return new Promise((resolve, reject) => {
  414. this.makeRequest(serverTokenEndpoint, data).then(
  415. response => {
  416. run(() => {
  417. expiresIn = response['expires_in'] || expiresIn;
  418. refreshToken = response['refresh_token'] || refreshToken;
  419. scope = response['scope'] || scope;
  420. const expiresAt = this._absolutizeExpirationTime(expiresIn);
  421. const data = Object.assign(response, {
  422. expires_in: expiresIn,
  423. expires_at: expiresAt,
  424. refresh_token: refreshToken,
  425. });
  426. if (refreshAccessTokensWithScope && scope) {
  427. data.scope = scope;
  428. }
  429. this._scheduleAccessTokenRefresh(expiresIn, null, refreshToken);
  430. this.trigger('sessionDataUpdated', data);
  431. resolve(data);
  432. });
  433. },
  434. response => {
  435. warn(
  436. `Access token could not be refreshed - server responded with ${response.responseJSON}.`,
  437. false,
  438. { id: 'ember-simple-auth.failedOAuth2TokenRefresh' }
  439. );
  440. reject();
  441. }
  442. );
  443. });
  444. }
  445. _absolutizeExpirationTime(expiresIn: number | undefined) {
  446. if (expiresIn) {
  447. return new Date(new Date().getTime() + expiresIn * 1000).getTime();
  448. }
  449. }
  450. _validate(data: OAuthResponseSuccess) {
  451. return Boolean(data['access_token']);
  452. }
  453. }