diff --git a/packages/ember-simple-auth/package.json b/packages/ember-simple-auth/package.json index 55037b7b5..dd86b868b 100644 --- a/packages/ember-simple-auth/package.json +++ b/packages/ember-simple-auth/package.json @@ -62,7 +62,8 @@ "prettier": "3.3.3", "rollup": "4.25.0", "rollup-plugin-copy": "3.5.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "typescript-event-target": "^1.1.1" }, "publishConfig": { "registry": "https://registry.npmjs.org" diff --git a/packages/ember-simple-auth/src/authenticators/base.js b/packages/ember-simple-auth/src/authenticators/base.ts similarity index 88% rename from packages/ember-simple-auth/src/authenticators/base.js rename to packages/ember-simple-auth/src/authenticators/base.ts index 2f48412cd..d5db61022 100644 --- a/packages/ember-simple-auth/src/authenticators/base.js +++ b/packages/ember-simple-auth/src/authenticators/base.ts @@ -1,6 +1,12 @@ import EmberObject from '@ember/object'; +import { TypedEventTarget, type TypedEventListener } from 'typescript-event-target'; -class AuthenticatorEventTarget extends EventTarget {} +export interface AuthenticatorEvents { + sessionDataUpdated: CustomEvent; + sessionDataInvalidated: CustomEvent; +} + +class AuthenticatorEventTarget extends TypedEventTarget {} /** The base class for all authenticators. __This serves as a starting point for @@ -109,7 +115,7 @@ export default class EsaBaseAuthenticator extends EmberObject { @member @public */ - restore() { + restore(...args: any[]): Promise { return Promise.reject(); } @@ -138,7 +144,7 @@ export default class EsaBaseAuthenticator extends EmberObject { @member @public */ - authenticate() { + authenticate(...args: any[]): Promise { return Promise.reject(); } @@ -163,19 +169,28 @@ export default class EsaBaseAuthenticator extends EmberObject { @member @public */ - invalidate() { + invalidate(...args: any[]): Promise { return Promise.resolve(); } - on(event, cb) { + on( + event: Event, + cb: TypedEventListener + ) { this.authenticatorEvents.addEventListener(event, cb); } - off(event, cb) { + off( + event: Event, + cb: TypedEventListener + ) { this.authenticatorEvents.removeEventListener(event, cb); } - trigger(event, value) { + trigger( + event: Event, + value: AuthenticatorEvents[Event]['detail'] + ) { let customEvent; if (value) { customEvent = new CustomEvent(event, { detail: value }); @@ -183,6 +198,6 @@ export default class EsaBaseAuthenticator extends EmberObject { customEvent = new CustomEvent(event); } - this.authenticatorEvents.dispatchEvent(customEvent); + this.authenticatorEvents.dispatchTypedEvent(event, customEvent); } } diff --git a/packages/ember-simple-auth/src/authenticators/devise.js b/packages/ember-simple-auth/src/authenticators/devise.ts similarity index 94% rename from packages/ember-simple-auth/src/authenticators/devise.js rename to packages/ember-simple-auth/src/authenticators/devise.ts index 86d0d2421..3980b85e1 100644 --- a/packages/ember-simple-auth/src/authenticators/devise.js +++ b/packages/ember-simple-auth/src/authenticators/devise.ts @@ -5,6 +5,8 @@ import { waitFor } from '@ember/test-waiters'; const JSON_CONTENT_TYPE = 'application/json'; +export type NestedRecord = Record>; + /** Authenticator that works with the Ruby gem [devise](https://github.com/plataformatec/devise). @@ -79,7 +81,7 @@ export default class DeviseAuthenticator extends BaseAuthenticator { @return {Promise} A promise that when it resolves results in the session becoming or remaining authenticated @public */ - restore(data) { + restore(data: Record) { return this._validate(data) ? Promise.resolve(data) : Promise.reject(); } @@ -102,14 +104,14 @@ export default class DeviseAuthenticator extends BaseAuthenticator { @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 @public */ - authenticate(identification, password) { + authenticate(identification: string, password: string) { return new Promise((resolve, reject) => { const { resourceName, identificationAttributeName, tokenAttributeName } = this.getProperties( 'resourceName', 'identificationAttributeName', 'tokenAttributeName' ); - const data = {}; + let data: NestedRecord = {}; data[resourceName] = { password }; data[resourceName][identificationAttributeName] = identification; @@ -161,7 +163,7 @@ export default class DeviseAuthenticator extends BaseAuthenticator { @protected */ @waitFor - makeRequest(data, options = {}) { + makeRequest(data: NestedRecord, options: { url?: string } = {}) { let url = options.url || this.get('serverTokenEndpoint'); let requestOptions = {}; let body = JSON.stringify(data); @@ -178,7 +180,7 @@ export default class DeviseAuthenticator extends BaseAuthenticator { return fetch(url, requestOptions); } - _validate(data) { + _validate(data: Record) { const tokenAttributeName = this.get('tokenAttributeName'); const identificationAttributeName = this.get('identificationAttributeName'); const resourceName = this.get('resourceName'); diff --git a/packages/ember-simple-auth/src/authenticators/oauth2-implicit-grant.js b/packages/ember-simple-auth/src/authenticators/oauth2-implicit-grant.ts similarity index 84% rename from packages/ember-simple-auth/src/authenticators/oauth2-implicit-grant.js rename to packages/ember-simple-auth/src/authenticators/oauth2-implicit-grant.ts index 38ad550d8..998b82f9c 100644 --- a/packages/ember-simple-auth/src/authenticators/oauth2-implicit-grant.js +++ b/packages/ember-simple-auth/src/authenticators/oauth2-implicit-grant.ts @@ -1,6 +1,5 @@ /** @module ember-simple-auth/authenticators/oauth2-implicit-grant **/ -import { isEmpty } from '@ember/utils'; import BaseAuthenticator from './base'; /** Parses the location hash (as received from `window.location.hash`) into an @@ -15,20 +14,33 @@ import BaseAuthenticator from './base'; @return {Object} An obect with individual properties and values for the data parsed from the location hash @memberof module:ember-simple-auth/authenticators/oauth2-implicit-grant */ -export function parseResponse(locationHash) { - let params = {}; +export function parseResponse(locationHash: string): Record { + let params: Record = {}; const query = locationHash.substring(locationHash.indexOf('?')); const regex = /([^#?&=]+)=([^&]*)/g; let match; // decode all parameter pairs while ((match = regex.exec(query)) !== null) { - params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]); + const [_, key, value] = match; + if (key && value) { + params[decodeURIComponent(key)] = decodeURIComponent(value); + } } return params; } +export type ImplicitGrantData = { + response_type: string; + client_id: string; + redirect_uri: string; + scope: string; + state: string; + access_token: string; + error?: string; +}; + /** Authenticator that conforms to OAuth 2 ([RFC 6749](http://tools.ietf.org/html/rfc6749)), specifically the _"Implicit @@ -54,7 +66,7 @@ export default class OAuth2ImplicitGrantAuthenticator extends BaseAuthenticator @return {Promise} A promise that when it resolves results in the session becoming or remaining authenticated @public */ - restore(data) { + restore(data: ImplicitGrantData) { return new Promise((resolve, reject) => { if (!this._validateData(data)) { return reject('Could not restore session - "access_token" missing.'); @@ -79,7 +91,7 @@ export default class OAuth2ImplicitGrantAuthenticator extends BaseAuthenticator @return {Promise} A promise that when it resolves results in the session becoming authenticated @public */ - authenticate(hash) { + authenticate(hash: ImplicitGrantData) { return new Promise((resolve, reject) => { if (hash.error) { reject(hash.error); @@ -103,9 +115,8 @@ export default class OAuth2ImplicitGrantAuthenticator extends BaseAuthenticator return Promise.resolve(); } - _validateData(data) { + _validateData(data: ImplicitGrantData) { // see https://tools.ietf.org/html/rfc6749#section-4.2.2 - - return !isEmpty(data) && !isEmpty(data.access_token); + return data && data.access_token; } } diff --git a/packages/ember-simple-auth/src/authenticators/oauth2-password-grant.js b/packages/ember-simple-auth/src/authenticators/oauth2-password-grant.ts similarity index 78% rename from packages/ember-simple-auth/src/authenticators/oauth2-password-grant.js rename to packages/ember-simple-auth/src/authenticators/oauth2-password-grant.ts index 24f045a0b..5edb787b9 100644 --- a/packages/ember-simple-auth/src/authenticators/oauth2-password-grant.js +++ b/packages/ember-simple-auth/src/authenticators/oauth2-password-grant.ts @@ -1,12 +1,59 @@ -import { isEmpty } from '@ember/utils'; -import { run, later, cancel } from '@ember/runloop'; -import { A, makeArray } from '@ember/array'; +import { makeArray } from '@ember/array'; import { warn } from '@ember/debug'; import { getOwner } from '@ember/application'; import BaseAuthenticator from './base'; import isFastBoot from '../utils/is-fastboot'; import { waitFor } from '@ember/test-waiters'; import { isTesting } from '@embroider/macros'; +import type { Timer } from '@ember/runloop'; +import { run, later, cancel } from '@ember/runloop'; + +export type OAuthResponseSuccess = { + access_token: string; + token_type: string; + expires_in?: number; + expires_at?: number; + refresh_token?: string; + scope?: string; +}; + +export type OAuthPasswordRequestData = { + grant_type: string; + username: string; + password: string; + client_id?: string; + scope?: string; +}; + +export type OAuthInvalidateRequestData = { + token_type_hint: 'access_token' | 'refresh_token'; + token: string; + client_id?: string; + scope?: string; +}; + +export type OAuthRefreshRequestData = { + grant_type: 'refresh_token'; + refresh_token: string; + scope?: string; + client_id?: string; +}; + +export type MakeRequestData = + | OAuthPasswordRequestData + | OAuthInvalidateRequestData + | OAuthRefreshRequestData; + +export interface OAuth2Response extends Response { + /** + * @deprecated 'responseText' is deprecated. This is a legacy AJAX API. + */ + responseText: string; + /** + * @deprecated 'responseJSON' is deprecated. This is a legacy AJAX API. + */ + responseJSON: string; +} /** Authenticator that conforms to OAuth 2 @@ -46,7 +93,7 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @default null @public */ - clientId = null; + clientId: string | null = null; /** The endpoint on the server that authentication and token refresh requests @@ -58,7 +105,7 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @default '/token' @public */ - serverTokenEndpoint = '/token'; + serverTokenEndpoint: string = '/token'; /** The endpoint on the server that token revocation requests are sent to. Only @@ -75,7 +122,7 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @default null @public */ - serverTokenRevocationEndpoint = null; + serverTokenRevocationEndpoint: string | null = null; /** Sets whether the authenticator automatically refreshes access tokens if the @@ -124,7 +171,7 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator return (Math.floor(Math.random() * (max - min)) + min) * 1000; } - _refreshTokenTimeout = null; + _refreshTokenTimeout: Timer | undefined = undefined; /** Restores the session from a session data object; __will return a resolving @@ -144,16 +191,17 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @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 @public */ - restore(data) { + restore(data: OAuthResponseSuccess) { return new Promise((resolve, reject) => { const now = new Date().getTime(); const refreshAccessTokens = this.get('refreshAccessTokens'); - if (!isEmpty(data['expires_at']) && data['expires_at'] < now) { + if (data['expires_at'] && data['expires_at'] < now) { if (refreshAccessTokens) { - this._refreshAccessToken(data['expires_in'], data['refresh_token'], data['scope']).then( - resolve, - reject - ); + this._refreshAccessToken( + data['expires_in'], + data['refresh_token'] as string, + data['scope'] + ).then(resolve, reject); } else { reject(); } @@ -228,13 +276,17 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @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 @public */ - authenticate(identification, password, scope = [], headers = {}) { + authenticate(identification: string, password: string, scope = [], headers = {}) { return new Promise((resolve, reject) => { - const data = { grant_type: 'password', username: identification, password }; + const data: OAuthPasswordRequestData = { + grant_type: 'password', + username: identification, + password, + }; const serverTokenEndpoint = this.get('serverTokenEndpoint'); const scopesString = makeArray(scope).join(' '); - if (!isEmpty(scopesString)) { + if (scopesString.trim().length > 0) { data.scope = scopesString; } this.makeRequest(serverTokenEndpoint, data, headers).then( @@ -250,7 +302,7 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator expiresAt, response['refresh_token'] ); - if (!isEmpty(expiresAt)) { + if (expiresAt) { response = Object.assign(response, { expires_at: expiresAt }); } @@ -279,21 +331,21 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @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 @public */ - invalidate(data) { + invalidate(data: OAuthResponseSuccess) { const serverTokenRevocationEndpoint = this.get('serverTokenRevocationEndpoint'); - function success(resolve) { + const success = (resolve: (value?: unknown) => void) => { cancel(this._refreshTokenTimeout); delete this._refreshTokenTimeout; resolve(); - } + }; return new Promise(resolve => { - if (isEmpty(serverTokenRevocationEndpoint)) { - success.apply(this, [resolve]); + if (!serverTokenRevocationEndpoint) { + success(resolve); } else { - const requests = []; - A(['access_token', 'refresh_token']).forEach(tokenType => { + const requests: Promise[] = []; + (['access_token', 'refresh_token'] as const).forEach(tokenType => { const token = data[tokenType]; - if (!isEmpty(token)) { + if (token) { requests.push( this.makeRequest(serverTokenRevocationEndpoint, { token_type_hint: tokenType, @@ -322,18 +374,29 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator @protected */ @waitFor - makeRequest(url, data, headers = {}) { + makeRequest( + url: string, + data: MakeRequestData, + headers: Record = {} + ): Promise { headers['Content-Type'] = 'application/x-www-form-urlencoded'; const clientId = this.get('clientId'); - if (!isEmpty(clientId)) { - data['client_id'] = this.get('clientId'); + if (clientId) { + data.client_id = clientId; } const body = Object.keys(data) .map(key => { - return `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`; + const value = data[key as keyof MakeRequestData]; + + if (value) { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } else { + return null; + } }) + .filter(Boolean) .join('&'); const options = { @@ -349,13 +412,13 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator try { let json = JSON.parse(text); if (!response.ok) { - response.responseJSON = json; + (response as OAuth2Response).responseJSON = json; reject(response); } else { resolve(json); } } catch (SyntaxError) { - response.responseText = text; + (response as OAuth2Response).responseText = text; reject(response); } }); @@ -364,34 +427,41 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator }); } - _scheduleAccessTokenRefresh(expiresIn, expiresAt, refreshToken) { + _scheduleAccessTokenRefresh( + expiresIn: number | undefined, + expiresAt: number | null | undefined, + refreshToken: string | undefined + ) { const refreshAccessTokens = this.get('refreshAccessTokens') && !isFastBoot(getOwner(this)); if (refreshAccessTokens) { const now = new Date().getTime(); - if (isEmpty(expiresAt) && !isEmpty(expiresIn)) { + if (!expiresAt && expiresIn) { expiresAt = new Date(now + expiresIn * 1000).getTime(); } const offset = this.get('tokenRefreshOffset'); - if (!isEmpty(refreshToken) && !isEmpty(expiresAt) && expiresAt > now - offset) { + if (refreshToken && expiresAt && expiresAt > now - offset) { cancel(this._refreshTokenTimeout); delete this._refreshTokenTimeout; if (!isTesting()) { this._refreshTokenTimeout = later( - this, - this._refreshAccessToken, - expiresIn, - refreshToken, - expiresAt - now - offset + () => { + this._refreshAccessToken(expiresIn, refreshToken); + }, + (expiresAt as number) - now - offset ); } } } } - _refreshAccessToken(expiresIn, refreshToken, scope) { - const data = { grant_type: 'refresh_token', refresh_token: refreshToken }; + _refreshAccessToken(expiresIn: number | undefined, refreshToken: string, scope?: string) { + const data: OAuthRefreshRequestData = { + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: '', + }; const refreshAccessTokensWithScope = this.get('refreshAccessTokensWithScope'); - if (refreshAccessTokensWithScope && !isEmpty(scope)) { + if (refreshAccessTokensWithScope && scope) { data.scope = scope; } @@ -409,7 +479,7 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator expires_at: expiresAt, refresh_token: refreshToken, }); - if (refreshAccessTokensWithScope && !isEmpty(scope)) { + if (refreshAccessTokensWithScope && scope) { data.scope = scope; } this._scheduleAccessTokenRefresh(expiresIn, null, refreshToken); @@ -429,13 +499,13 @@ export default class OAuth2PasswordGrantAuthenticator extends BaseAuthenticator }); } - _absolutizeExpirationTime(expiresIn) { - if (!isEmpty(expiresIn)) { + _absolutizeExpirationTime(expiresIn: number | undefined) { + if (expiresIn) { return new Date(new Date().getTime() + expiresIn * 1000).getTime(); } } - _validate(data) { - return !isEmpty(data['access_token']); + _validate(data: OAuthResponseSuccess) { + return Boolean(data['access_token']); } } diff --git a/packages/ember-simple-auth/src/authenticators/test.js b/packages/ember-simple-auth/src/authenticators/test.ts similarity index 82% rename from packages/ember-simple-auth/src/authenticators/test.js rename to packages/ember-simple-auth/src/authenticators/test.ts index 20e310992..6854603e5 100644 --- a/packages/ember-simple-auth/src/authenticators/test.js +++ b/packages/ember-simple-auth/src/authenticators/test.ts @@ -1,11 +1,11 @@ import BaseAuthenticator from './base'; export default class TestAuthenticator extends BaseAuthenticator { - restore(data) { + restore(data: any) { return Promise.resolve(data); } - authenticate(data) { + authenticate(data: any) { return Promise.resolve(data); } diff --git a/packages/ember-simple-auth/src/authenticators/torii.js b/packages/ember-simple-auth/src/authenticators/torii.js index b36bef50d..334f75854 100644 --- a/packages/ember-simple-auth/src/authenticators/torii.js +++ b/packages/ember-simple-auth/src/authenticators/torii.js @@ -4,7 +4,7 @@ import BaseAuthenticator from './base'; deprecate('Ember Simple Auth: The Torii authenticator is deprecated.', false, { id: 'ember-simple-auth.authenticators.torii', - until: '7.0.0', + until: '8.0.0', for: 'ember-simple-auth', since: { enabled: '4.2.0', diff --git a/packages/test-esa/tests/unit/authenticators/test-test.js b/packages/test-esa/tests/unit/authenticators/test-test.js index 65a2e457e..634b0b3bd 100644 --- a/packages/test-esa/tests/unit/authenticators/test-test.js +++ b/packages/test-esa/tests/unit/authenticators/test-test.js @@ -5,7 +5,7 @@ module('TestAuthenticator', function (hooks) { let authenticator; hooks.beforeEach(function () { - authenticator = Test.create(); + authenticator = new Test(); }); module('#restore', function () { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48b6daa65..f742fc738 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.7.2 + typescript-event-target: + specifier: ^1.1.1 + version: 1.1.1 packages/test-app: dependencies: @@ -9329,6 +9332,9 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typescript-event-target@1.1.1: + resolution: {integrity: sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==} + typescript-memoize@1.1.1: resolution: {integrity: sha512-GQ90TcKpIH4XxYTI2F98yEQYZgjNMOGPpOgdjIBhaLaWji5HPWlRnZ4AeA1hfBxtY7bCGDJsqDDHk/KaHOl5bA==} @@ -13725,6 +13731,26 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/ember@4.0.11': + dependencies: + '@types/ember__application': 4.0.11(@babel/core@7.26.0) + '@types/ember__array': 4.0.10(@babel/core@7.26.0) + '@types/ember__component': 4.0.22(@babel/core@7.26.0) + '@types/ember__controller': 4.0.12(@babel/core@7.26.0) + '@types/ember__debug': 4.0.8(@babel/core@7.26.0) + '@types/ember__engine': 4.0.11(@babel/core@7.26.0) + '@types/ember__error': 4.0.6 + '@types/ember__object': 4.0.12(@babel/core@7.26.0) + '@types/ember__polyfills': 4.0.6 + '@types/ember__routing': 4.0.22(@babel/core@7.26.0) + '@types/ember__runloop': 4.0.10 + '@types/ember__service': 4.0.9(@babel/core@7.26.0) + '@types/ember__string': 3.0.15 + '@types/ember__template': 4.0.7 + '@types/ember__test': 4.0.6(@babel/core@7.26.0) + '@types/ember__utils': 4.0.7 + '@types/rsvp': 4.0.9 + '@types/ember@4.0.11(@babel/core@7.26.0)': dependencies: '@types/ember__application': 4.0.11(@babel/core@7.26.0) @@ -13751,7 +13777,7 @@ snapshots: '@types/ember__application@4.0.11(@babel/core@7.26.0)': dependencies: '@glimmer/component': 1.1.2(@babel/core@7.26.0) - '@types/ember': 4.0.11(@babel/core@7.26.0) + '@types/ember': 4.0.11 '@types/ember__engine': 4.0.11(@babel/core@7.26.0) '@types/ember__object': 4.0.12(@babel/core@7.26.0) '@types/ember__owner': 4.0.9 @@ -13823,6 +13849,10 @@ snapshots: - '@babel/core' - supports-color + '@types/ember__runloop@4.0.10': + dependencies: + '@types/ember': 4.0.11 + '@types/ember__runloop@4.0.10(@babel/core@7.26.0)': dependencies: '@types/ember': 4.0.11(@babel/core@7.26.0) @@ -13848,6 +13878,10 @@ snapshots: - '@babel/core' - supports-color + '@types/ember__utils@4.0.7': + dependencies: + '@types/ember': 4.0.11 + '@types/ember__utils@4.0.7(@babel/core@7.26.0)': dependencies: '@types/ember': 4.0.11(@babel/core@7.26.0) @@ -22529,6 +22563,8 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typescript-event-target@1.1.1: {} + typescript-memoize@1.1.1: {} typescript@3.9.10: {}