From e347d02f008e139482570faae13764bb0ca8a964 Mon Sep 17 00:00:00 2001 From: Tafel <35837839+tafelnl@users.noreply.github.com> Date: Fri, 3 May 2024 09:30:50 +0200 Subject: [PATCH] style: prettify a few files (#256) --- jest.config.js | 12 +- package.json | 126 +++++----- rollup.config.js | 8 +- src/definitions.ts | 293 +++++++++++----------- src/index.ts | 2 +- src/web-utils.test.ts | 572 +++++++++++++++++++++--------------------- src/web-utils.ts | 470 +++++++++++++++++----------------- src/web.ts | 420 ++++++++++++++++--------------- 8 files changed, 959 insertions(+), 944 deletions(-) diff --git a/jest.config.js b/jest.config.js index d5dc5206..f325c787 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,8 @@ module.exports = { - preset: 'ts-jest', - verbose: true, - testEnvironment: 'node', - globals: { - window: {} - } + preset: 'ts-jest', + verbose: true, + testEnvironment: 'node', + globals: { + window: {}, + }, }; diff --git a/package.json b/package.json index fa79f52b..ed0d561d 100644 --- a/package.json +++ b/package.json @@ -1,67 +1,67 @@ { - "name": "@byteowls/capacitor-oauth2", - "version": "6.0.0", - "description": "Capacitor OAuth 2 client plugin", - "author": "Michael Oberwasserlechner", - "homepage": "https://github.com/moberwasserlechner/capacitor-oauth2", - "license": "MIT", - "main": "dist/esm/index.js", - "module": "dist/esm/index.js", - "types": "dist/esm/index.d.ts", - "scripts": { - "build": "npm run clean && tsc", - "clean": "rimraf ./dist", - "watch": "tsc --watch", - "test": "jest", - "removePacked": "rimraf -g byteowls-capacitor-oauth2-*.tgz", - "publishLocally": "npm run removePacked && npm run build && npm pack", - "prepublishOnly": "npm run build" + "name": "@byteowls/capacitor-oauth2", + "version": "6.0.0", + "description": "Capacitor OAuth 2 client plugin", + "author": "Michael Oberwasserlechner", + "homepage": "https://github.com/moberwasserlechner/capacitor-oauth2", + "license": "MIT", + "main": "dist/esm/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "scripts": { + "build": "npm run clean && tsc", + "clean": "rimraf ./dist", + "watch": "tsc --watch", + "test": "jest", + "removePacked": "rimraf -g byteowls-capacitor-oauth2-*.tgz", + "publishLocally": "npm run removePacked && npm run build && npm pack", + "prepublishOnly": "npm run build" + }, + "files": [ + "android/src/main/", + "android/build.gradle", + "dist/", + "ios/ByteowlsCapacitorOauth2/Source", + "ByteowlsCapacitorOauth2.podspec" + ], + "keywords": [ + "capacitor", + "capacitor-plugin", + "oauth2", + "oauth2-client", + "social-login" + ], + "capacitor": { + "ios": { + "src": "ios" }, - "files": [ - "android/src/main/", - "android/build.gradle", - "dist/", - "ios/ByteowlsCapacitorOauth2/Source", - "ByteowlsCapacitorOauth2.podspec" - ], - "keywords": [ - "capacitor", - "capacitor-plugin", - "oauth2", - "oauth2-client", - "social-login" - ], - "capacitor": { - "ios": { - "src": "ios" - }, - "android": { - "src": "android" - } - }, - "repository": { - "type": "git", - "url": "https://github.com/moberwasserlechner/capacitor-oauth2" - }, - "bugs": { - "url": "https://github.com/moberwasserlechner/capacitor-oauth2/issues" - }, - "publishConfig": { - "access": "public" - }, - "peerDependencies": { - "@capacitor/core": "^6.0.0" - }, - "dependencies": {}, - "devDependencies": { - "@capacitor/android": "^6.0.0", - "@capacitor/core": "^6.0.0", - "@capacitor/ios": "^6.0.0", - "@types/jest": "29.5.4", - "jest": "29.6.4", - "ts-jest": "29.1.1", - "eslint": "8.48.0", - "rimraf": "5.0.1", - "typescript": "4.8.4" + "android": { + "src": "android" } + }, + "repository": { + "type": "git", + "url": "https://github.com/moberwasserlechner/capacitor-oauth2" + }, + "bugs": { + "url": "https://github.com/moberwasserlechner/capacitor-oauth2/issues" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@capacitor/core": "^6.0.0" + }, + "dependencies": {}, + "devDependencies": { + "@capacitor/android": "^6.0.0", + "@capacitor/core": "^6.0.0", + "@capacitor/ios": "^6.0.0", + "@types/jest": "29.5.4", + "jest": "29.6.4", + "ts-jest": "29.1.1", + "eslint": "8.48.0", + "rimraf": "5.0.1", + "typescript": "4.8.4" + } } diff --git a/rollup.config.js b/rollup.config.js index 906e8a15..4c59bf99 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -6,9 +6,7 @@ export default { file: 'dist/plugin.js', format: 'iife', name: 'capacitorPlugin', - sourcemap: true + sourcemap: true, }, - plugins: [ - nodeResolve() - ] -}; \ No newline at end of file + plugins: [nodeResolve()], +}; diff --git a/src/definitions.ts b/src/definitions.ts index dbe66a6d..f445a199 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,168 +1,167 @@ export interface OAuth2ClientPlugin { - /** - * Authenticate against a OAuth 2 provider. - * @param {OAuth2AuthenticateOptions} options - * @returns {Promise} the resource url response - */ - authenticate(options: OAuth2AuthenticateOptions): Promise; - /** - * Get a new access token based on the given refresh token. - * @param {OAuth2RefreshTokenOptions} options - * @returns {Promise} the token endpoint response - */ - refreshToken(options: OAuth2RefreshTokenOptions): Promise; - /** - * Logout from the authenticated OAuth 2 provider - * @param {OAuth2AuthenticateOptions} options Although not all options are needed. We simply reuse the options from authenticate - * @param {String} id_token Optional idToken, only for Android - * @returns {Promise} true if the logout was successful else false. - */ - logout(options: OAuth2AuthenticateOptions, id_token?: string): Promise; + /** + * Authenticate against a OAuth 2 provider. + * @param {OAuth2AuthenticateOptions} options + * @returns {Promise} the resource url response + */ + authenticate(options: OAuth2AuthenticateOptions): Promise; + /** + * Get a new access token based on the given refresh token. + * @param {OAuth2RefreshTokenOptions} options + * @returns {Promise} the token endpoint response + */ + refreshToken(options: OAuth2RefreshTokenOptions): Promise; + /** + * Logout from the authenticated OAuth 2 provider + * @param {OAuth2AuthenticateOptions} options Although not all options are needed. We simply reuse the options from authenticate + * @param {String} id_token Optional idToken, only for Android + * @returns {Promise} true if the logout was successful else false. + */ + logout(options: OAuth2AuthenticateOptions, id_token?: string): Promise; } export interface OAuth2RefreshTokenOptions { - /** - * The app id (client id) you get from the oauth provider like Google, Facebook,... - */ - appId: string; - /** - * Url for retrieving the access_token. - */ - accessTokenEndpoint: string; - /** - * The refresh token that will be used to obtain the new access token. - */ - refreshToken: string; - /** - * A space-delimited list of permissions that identify the resources that your application could access on the user's behalf. - */ - scope?: string; + /** + * The app id (client id) you get from the oauth provider like Google, Facebook,... + */ + appId: string; + /** + * Url for retrieving the access_token. + */ + accessTokenEndpoint: string; + /** + * The refresh token that will be used to obtain the new access token. + */ + refreshToken: string; + /** + * A space-delimited list of permissions that identify the resources that your application could access on the user's behalf. + */ + scope?: string; } export interface OAuth2AuthenticateBaseOptions { - /** - * The app id (client id) you get from the oauth provider like Google, Facebook,... - * - * required! - */ - appId?: string; - /** - * The base url for retrieving tokens depending on the response type from a OAuth 2 provider. e.g. https://accounts.google.com/o/oauth2/auth - * - * required! - */ - authorizationBaseUrl?: string; - /** - * Tells the authorization server which grant to execute. Be aware that a full code flow is not supported as clientCredentials are not included in requests. - * - * But you can retrieve the authorizationCode if you don't set a accessTokenEndpoint. - * - * required! - */ - responseType?: string; - /** - * Url to which the oauth provider redirects after authentication. - * - * required! - */ - redirectUrl?: string; - /** - * Url for retrieving the access_token by the authorization code flow. - */ - accessTokenEndpoint?: string; - /** - * Protected resource url. For authentication you only need the basic user details. - */ - resourceUrl?: string; - /** - * Enable PKCE if you need it. - */ - pkceEnabled?: boolean; - /** - * A space-delimited list of permissions that identify the resources that your application could access on the user's behalf. - * If you want to get a refresh token, you most likely will need the offline_access scope (only supported in Code Flow!) - */ - scope?: string; - /** - * A unique alpha numeric string used to prevent CSRF. If not set the plugin automatically generate a string - * and sends it as using state is recommended. - */ - state?: string; - /** - * Additional parameters for the created authorization url - */ - additionalParameters?: { [key: string]: string } - /** - * @since 3.0.0 - */ - logsEnabled?: boolean; - /** - * @since 3.1.0 ... not implemented yet! - */ - logoutUrl?: string; + /** + * The app id (client id) you get from the oauth provider like Google, Facebook,... + * + * required! + */ + appId?: string; + /** + * The base url for retrieving tokens depending on the response type from a OAuth 2 provider. e.g. https://accounts.google.com/o/oauth2/auth + * + * required! + */ + authorizationBaseUrl?: string; + /** + * Tells the authorization server which grant to execute. Be aware that a full code flow is not supported as clientCredentials are not included in requests. + * + * But you can retrieve the authorizationCode if you don't set a accessTokenEndpoint. + * + * required! + */ + responseType?: string; + /** + * Url to which the oauth provider redirects after authentication. + * + * required! + */ + redirectUrl?: string; + /** + * Url for retrieving the access_token by the authorization code flow. + */ + accessTokenEndpoint?: string; + /** + * Protected resource url. For authentication you only need the basic user details. + */ + resourceUrl?: string; + /** + * Enable PKCE if you need it. + */ + pkceEnabled?: boolean; + /** + * A space-delimited list of permissions that identify the resources that your application could access on the user's behalf. + * If you want to get a refresh token, you most likely will need the offline_access scope (only supported in Code Flow!) + */ + scope?: string; + /** + * A unique alpha numeric string used to prevent CSRF. If not set the plugin automatically generate a string + * and sends it as using state is recommended. + */ + state?: string; + /** + * Additional parameters for the created authorization url + */ + additionalParameters?: { [key: string]: string }; + /** + * @since 3.0.0 + */ + logsEnabled?: boolean; + /** + * @since 3.1.0 ... not implemented yet! + */ + logoutUrl?: string; - /** - * Additional headers for resource url request - * @since 3.0.0 - */ - additionalResourceHeaders?: { [key: string]: string } + /** + * Additional headers for resource url request + * @since 3.0.0 + */ + additionalResourceHeaders?: { [key: string]: string }; } export interface OAuth2AuthenticateOptions extends OAuth2AuthenticateBaseOptions { - - /** - * Custom options for the platform "web" - */ - web?: WebOption, - /** - * Custom options for the platform "android" - */ - android?: AndroidOptions, - /** - * Custom options for the platform "ios" - */ - ios?: IosOptions + /** + * Custom options for the platform "web" + */ + web?: WebOption; + /** + * Custom options for the platform "android" + */ + android?: AndroidOptions; + /** + * Custom options for the platform "ios" + */ + ios?: IosOptions; } export interface WebOption extends OAuth2AuthenticateBaseOptions { - /** - * Options for the window the plugin open for authentication. e.g. width=500,height=600,left=0,top=0 - */ - windowOptions?: string; - /** - * Options for the window target. Defaults to _blank - */ - windowTarget?: string; + /** + * Options for the window the plugin open for authentication. e.g. width=500,height=600,left=0,top=0 + */ + windowOptions?: string; + /** + * Options for the window target. Defaults to _blank + */ + windowTarget?: string; } export interface AndroidOptions extends OAuth2AuthenticateBaseOptions { - /** - * Some oauth provider especially Facebook forces us to use their SDK for apps. - * - * Provide a class name implementing the 'ByteowlsCapacitorOauth2.OAuth2CustomHandler' protocol. - */ - customHandlerClass?: string; - /** - * Alternative to handle the activity result. The `onNewIntent` method is only call if the App was killed while logging in. - */ - handleResultOnNewIntent?: boolean; - /** - * Default handling the activity result. - */ - handleResultOnActivityResult?: boolean; + /** + * Some oauth provider especially Facebook forces us to use their SDK for apps. + * + * Provide a class name implementing the 'ByteowlsCapacitorOauth2.OAuth2CustomHandler' protocol. + */ + customHandlerClass?: string; + /** + * Alternative to handle the activity result. The `onNewIntent` method is only call if the App was killed while logging in. + */ + handleResultOnNewIntent?: boolean; + /** + * Default handling the activity result. + */ + handleResultOnActivityResult?: boolean; } export interface IosOptions extends OAuth2AuthenticateBaseOptions { - /** - * If true the iOS 13+ feature Sign in with Apple (SiWA) try to build the scope from the standard "scope" parameter. - * - * If false scope is set to email and fullName. - */ - siwaUseScope?: boolean - /** - * Some oauth provider especially Facebook forces us to use their SDK for apps. - * - * Provide a class name implementing the 'ByteowlsCapacitorOauth2.OAuth2CustomHandler' protocol. - */ - customHandlerClass?: string; + /** + * If true the iOS 13+ feature Sign in with Apple (SiWA) try to build the scope from the standard "scope" parameter. + * + * If false scope is set to email and fullName. + */ + siwaUseScope?: boolean; + /** + * Some oauth provider especially Facebook forces us to use their SDK for apps. + * + * Provide a class name implementing the 'ByteowlsCapacitorOauth2.OAuth2CustomHandler' protocol. + */ + customHandlerClass?: string; } diff --git a/src/index.ts b/src/index.ts index e9af9c73..2938fc67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { registerPlugin } from '@capacitor/core'; import type { OAuth2ClientPlugin } from './definitions'; const OAuth2Client = registerPlugin('OAuth2Client', { - web: () => import('./web').then(m => new m.OAuth2ClientPluginWeb()), + web: () => import('./web').then((m) => new m.OAuth2ClientPluginWeb()), }); export * from './definitions'; diff --git a/src/web-utils.test.ts b/src/web-utils.test.ts index 18475531..2062ac5d 100644 --- a/src/web-utils.test.ts +++ b/src/web-utils.test.ts @@ -1,327 +1,327 @@ -import {OAuth2AuthenticateOptions} from "./definitions"; -import {CryptoUtils, WebUtils} from "./web-utils"; +import { OAuth2AuthenticateOptions } from './definitions'; +import { CryptoUtils, WebUtils } from './web-utils'; const mGetRandomValues = jest.fn().mockReturnValueOnce(new Uint32Array(10)); Object.defineProperty(window, 'crypto', { - value: {getRandomValues: mGetRandomValues}, + value: { getRandomValues: mGetRandomValues }, }); const googleOptions: OAuth2AuthenticateOptions = { - appId: "appId", - authorizationBaseUrl: "https://accounts.google.com/o/oauth2/auth", - accessTokenEndpoint: "https://www.googleapis.com/oauth2/v4/token", - scope: "email profile", - resourceUrl: "https://www.googleapis.com/userinfo/v2/me", - pkceEnabled: false, - web: { - accessTokenEndpoint: "", - redirectUrl: "https://oauth2.byteowls.com/authorize", - appId: "webAppId", - pkceEnabled: true - }, - android: { - responseType: "code", - }, - ios: { - responseType: "code", - } + appId: 'appId', + authorizationBaseUrl: 'https://accounts.google.com/o/oauth2/auth', + accessTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token', + scope: 'email profile', + resourceUrl: 'https://www.googleapis.com/userinfo/v2/me', + pkceEnabled: false, + web: { + accessTokenEndpoint: '', + redirectUrl: 'https://oauth2.byteowls.com/authorize', + appId: 'webAppId', + pkceEnabled: true, + }, + android: { + responseType: 'code', + }, + ios: { + responseType: 'code', + }, }; const oneDriveOptions: OAuth2AuthenticateOptions = { - appId: "appId", - authorizationBaseUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - accessTokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", - scope: "files.readwrite offline_access", - responseType: "code", + appId: 'appId', + authorizationBaseUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + accessTokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + scope: 'files.readwrite offline_access', + responseType: 'code', + additionalParameters: { + willbeoverwritten: 'foobar', + }, + web: { + redirectUrl: 'https://oauth2.byteowls.com/authorize', + pkceEnabled: false, additionalParameters: { - "willbeoverwritten": "foobar" + resource: 'resource_id', + emptyParam: null!, + ' ': 'test', + nonce: WebUtils.randomString(10), }, - web: { - redirectUrl: "https://oauth2.byteowls.com/authorize", - pkceEnabled: false, - additionalParameters: { - "resource": "resource_id", - "emptyParam": null!, - " ": "test", - "nonce": WebUtils.randomString(10) - } - }, - android: { - redirectUrl: "com.byteowls.oauth2://authorize" - }, - ios: { - redirectUrl: "com.byteowls.oauth2://authorize" - } + }, + android: { + redirectUrl: 'com.byteowls.oauth2://authorize', + }, + ios: { + redirectUrl: 'com.byteowls.oauth2://authorize', + }, }; const redirectUrlOptions: OAuth2AuthenticateOptions = { - appId: "appId", - authorizationBaseUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - responseType: "code", - redirectUrl: "https://mycompany.server.com/oauth", - scope: "files.readwrite offline_access", - additionalParameters: { - "willbeoverwritten": "foobar" - }, - web: {}, - android: { - redirectUrl: "com.byteowls.oauth2://authorize" - }, - ios: { - redirectUrl: "com.byteowls.oauth2://authorize" - } + appId: 'appId', + authorizationBaseUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + responseType: 'code', + redirectUrl: 'https://mycompany.server.com/oauth', + scope: 'files.readwrite offline_access', + additionalParameters: { + willbeoverwritten: 'foobar', + }, + web: {}, + android: { + redirectUrl: 'com.byteowls.oauth2://authorize', + }, + ios: { + redirectUrl: 'com.byteowls.oauth2://authorize', + }, }; describe('base options processing', () => { - - it('should build a nested appId', () => { - const appId = WebUtils.getAppId(googleOptions); - expect(appId).toEqual("webAppId"); - }); - - it('should build a overwritable string value', () => { - const appId = WebUtils.getOverwritableValue(googleOptions, "appId"); - expect(appId).toEqual("webAppId"); - }); - - it('should build a overwritable boolean value', () => { - const pkceEnabled = WebUtils.getOverwritableValue(googleOptions, "pkceEnabled"); - expect(pkceEnabled).toBeTruthy(); - }); - - it('should build a overwritable additional parameters map', () => { - const additionalParameters = WebUtils.getOverwritableValue<{ [key: string]: string }>(oneDriveOptions, "additionalParameters"); - expect(additionalParameters).not.toBeUndefined(); - expect(additionalParameters["resource"]).toEqual("resource_id"); - }); - - it('must not contain overwritten additional parameters', () => { - const additionalParameters = WebUtils.getOverwritableValue<{ [key: string]: string }>(oneDriveOptions, "additionalParameters"); - expect(additionalParameters["willbeoverwritten"]).toBeUndefined(); - }); - - it('must have a base redirect url', () => { - const redirectUrl = WebUtils.getOverwritableValue(redirectUrlOptions, "redirectUrl"); - expect(redirectUrl).toBeDefined(); - }); - - it('must be overwritten by empty string from web section', () => { - const accessTokenEndpoint = WebUtils.getOverwritableValue(googleOptions, "accessTokenEndpoint"); - expect(accessTokenEndpoint).toStrictEqual(""); - }); - - it('must not be overwritten if no key exists in web section', () => { - const accessTokenEndpoint = WebUtils.getOverwritableValue(googleOptions, "scope"); - expect(accessTokenEndpoint).toStrictEqual("email profile"); - }); + it('should build a nested appId', () => { + const appId = WebUtils.getAppId(googleOptions); + expect(appId).toEqual('webAppId'); + }); + + it('should build a overwritable string value', () => { + const appId = WebUtils.getOverwritableValue(googleOptions, 'appId'); + expect(appId).toEqual('webAppId'); + }); + + it('should build a overwritable boolean value', () => { + const pkceEnabled = WebUtils.getOverwritableValue(googleOptions, 'pkceEnabled'); + expect(pkceEnabled).toBeTruthy(); + }); + + it('should build a overwritable additional parameters map', () => { + const additionalParameters = WebUtils.getOverwritableValue<{ [key: string]: string }>( + oneDriveOptions, + 'additionalParameters', + ); + expect(additionalParameters).not.toBeUndefined(); + expect(additionalParameters['resource']).toEqual('resource_id'); + }); + + it('must not contain overwritten additional parameters', () => { + const additionalParameters = WebUtils.getOverwritableValue<{ [key: string]: string }>( + oneDriveOptions, + 'additionalParameters', + ); + expect(additionalParameters['willbeoverwritten']).toBeUndefined(); + }); + + it('must have a base redirect url', () => { + const redirectUrl = WebUtils.getOverwritableValue(redirectUrlOptions, 'redirectUrl'); + expect(redirectUrl).toBeDefined(); + }); + + it('must be overwritten by empty string from web section', () => { + const accessTokenEndpoint = WebUtils.getOverwritableValue(googleOptions, 'accessTokenEndpoint'); + expect(accessTokenEndpoint).toStrictEqual(''); + }); + + it('must not be overwritten if no key exists in web section', () => { + const accessTokenEndpoint = WebUtils.getOverwritableValue(googleOptions, 'scope'); + expect(accessTokenEndpoint).toStrictEqual('email profile'); + }); }); describe('web options', () => { - it('should build web options', async () => { - WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => { - expect(webOptions).not.toBeNull(); - }); + it('should build web options', async () => { + WebUtils.buildWebOptions(oneDriveOptions).then((webOptions) => { + expect(webOptions).not.toBeNull(); }); + }); - it('should not have a code verifier', async () => { - WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => { - expect(webOptions.pkceCodeVerifier).toBeUndefined(); - }); + it('should not have a code verifier', async () => { + WebUtils.buildWebOptions(oneDriveOptions).then((webOptions) => { + expect(webOptions.pkceCodeVerifier).toBeUndefined(); }); + }); - it('must not contain empty additional parameter', async () => { - WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => { - expect(webOptions.additionalParameters[" "]).toBeUndefined(); - expect(webOptions.additionalParameters["emptyParam"]).toBeUndefined(); - }); + it('must not contain empty additional parameter', async () => { + WebUtils.buildWebOptions(oneDriveOptions).then((webOptions) => { + expect(webOptions.additionalParameters[' ']).toBeUndefined(); + expect(webOptions.additionalParameters['emptyParam']).toBeUndefined(); }); - + }); }); -describe("Url param extraction", () => { - - it('should return undefined on null url', () => { - const paramObj = WebUtils.getUrlParams(null!); - expect(paramObj).toBeUndefined(); - }); - - it('should return undefined on empty url', () => { - const paramObj = WebUtils.getUrlParams(""); - expect(paramObj).toBeUndefined(); - }); - - it('should return undefined on url with spaces', () => { - const paramObj = WebUtils.getUrlParams(" "); - expect(paramObj).toBeUndefined(); - }); - - it('should return undefined if no params in url', () => { - const paramObj = WebUtils.getUrlParams("https://app.example.com/"); - expect(paramObj).toBeUndefined(); - }); - - it('should return undefined if no params in url search', () => { - const paramObj = WebUtils.getUrlParams("https://app.example.com?"); - expect(paramObj).toBeUndefined(); - }); - - it('should return undefined if no params in url hash', () => { - const paramObj = WebUtils.getUrlParams("https://app.example.com#"); - expect(paramObj).toBeUndefined(); - }); - - it('should remove invalid combinations one param', () => { - const paramObj = WebUtils.getUrlParams("https://app.example.com?=test"); - expect(paramObj).toBeUndefined(); - }); - - it('should remove invalid combinations multiple param', () => { - const paramObj = WebUtils.getUrlParams("https://app.example.com?=test&key1=param1"); - expect(paramObj).toEqual({key1: "param1"}); - }); - - it('should extract work with a single param', () => { - const paramObj = WebUtils.getUrlParams("https://app.example.com?access_token=testtoken"); - expect(paramObj!["access_token"]).toStrictEqual("testtoken"); - }); - - it('should extract a uuid state param', () => { - const state = WebUtils.randomString(); - const paramObj = WebUtils.getUrlParams(`https://app.example.com?state=${state}&access_token=testtoken`); - expect(paramObj!["state"]).toStrictEqual(state); - }); - - it('should use query flag and ignore hash flag', () => { - const random = WebUtils.randomString(); - const foo = WebUtils.randomString(); - const paramObj = WebUtils.getUrlParams(`https://app.example.com?random=${random}&foo=${foo}#ignored`); - expect(paramObj!["random"]).toStrictEqual(random); - expect(paramObj!["foo"]).toStrictEqual(foo); - }); - - it('should use query flag with another question mark in a param', () => { - const random = WebUtils.randomString(); - const foo = WebUtils.randomString(); - const paramObj = WebUtils.getUrlParams(`https://app.example.com?random=${random}&foo=${foo}?questionmark`); - expect(paramObj!["random"]).toStrictEqual(random); - expect(paramObj!["foo"]).toStrictEqual(`${foo}?questionmark`); - }); - - it('should use hash flag and ignore query flag', () => { - const random = WebUtils.randomString(); - const foo = WebUtils.randomString(); - const paramObj = WebUtils.getUrlParams(`https://app.example.com#random=${random}&foo=${foo}?ignored`); - expect(paramObj!["random"]).toStrictEqual(random); - expect(paramObj!["foo"]).toStrictEqual(`${foo}?ignored`); - }); - - it('should use hash flag with another hash in a param', () => { - const random = WebUtils.randomString(); - const foo = WebUtils.randomString(); - const paramObj = WebUtils.getUrlParams(`https://app.example.com#random=${random}&foo=${foo}#hash`); - expect(paramObj!["random"]).toStrictEqual(random); - expect(paramObj!["foo"]).toStrictEqual(`${foo}#hash`); - }); - - it('should extract hash params correctly', () => { - const random = WebUtils.randomString(20); - const url = `http://localhost:4200/#state=${random}&access_token=ya29.a0ARrdaM-sdfsfsdfsdfsdfs-YGFHwg_lM6dePPaT_TunbpsdfsdfsdfsEG6vTVLsLJDDW +describe('Url param extraction', () => { + it('should return undefined on null url', () => { + const paramObj = WebUtils.getUrlParams(null!); + expect(paramObj).toBeUndefined(); + }); + + it('should return undefined on empty url', () => { + const paramObj = WebUtils.getUrlParams(''); + expect(paramObj).toBeUndefined(); + }); + + it('should return undefined on url with spaces', () => { + const paramObj = WebUtils.getUrlParams(' '); + expect(paramObj).toBeUndefined(); + }); + + it('should return undefined if no params in url', () => { + const paramObj = WebUtils.getUrlParams('https://app.example.com/'); + expect(paramObj).toBeUndefined(); + }); + + it('should return undefined if no params in url search', () => { + const paramObj = WebUtils.getUrlParams('https://app.example.com?'); + expect(paramObj).toBeUndefined(); + }); + + it('should return undefined if no params in url hash', () => { + const paramObj = WebUtils.getUrlParams('https://app.example.com#'); + expect(paramObj).toBeUndefined(); + }); + + it('should remove invalid combinations one param', () => { + const paramObj = WebUtils.getUrlParams('https://app.example.com?=test'); + expect(paramObj).toBeUndefined(); + }); + + it('should remove invalid combinations multiple param', () => { + const paramObj = WebUtils.getUrlParams('https://app.example.com?=test&key1=param1'); + expect(paramObj).toEqual({ key1: 'param1' }); + }); + + it('should extract work with a single param', () => { + const paramObj = WebUtils.getUrlParams('https://app.example.com?access_token=testtoken'); + expect(paramObj!['access_token']).toStrictEqual('testtoken'); + }); + + it('should extract a uuid state param', () => { + const state = WebUtils.randomString(); + const paramObj = WebUtils.getUrlParams(`https://app.example.com?state=${state}&access_token=testtoken`); + expect(paramObj!['state']).toStrictEqual(state); + }); + + it('should use query flag and ignore hash flag', () => { + const random = WebUtils.randomString(); + const foo = WebUtils.randomString(); + const paramObj = WebUtils.getUrlParams(`https://app.example.com?random=${random}&foo=${foo}#ignored`); + expect(paramObj!['random']).toStrictEqual(random); + expect(paramObj!['foo']).toStrictEqual(foo); + }); + + it('should use query flag with another question mark in a param', () => { + const random = WebUtils.randomString(); + const foo = WebUtils.randomString(); + const paramObj = WebUtils.getUrlParams(`https://app.example.com?random=${random}&foo=${foo}?questionmark`); + expect(paramObj!['random']).toStrictEqual(random); + expect(paramObj!['foo']).toStrictEqual(`${foo}?questionmark`); + }); + + it('should use hash flag and ignore query flag', () => { + const random = WebUtils.randomString(); + const foo = WebUtils.randomString(); + const paramObj = WebUtils.getUrlParams(`https://app.example.com#random=${random}&foo=${foo}?ignored`); + expect(paramObj!['random']).toStrictEqual(random); + expect(paramObj!['foo']).toStrictEqual(`${foo}?ignored`); + }); + + it('should use hash flag with another hash in a param', () => { + const random = WebUtils.randomString(); + const foo = WebUtils.randomString(); + const paramObj = WebUtils.getUrlParams(`https://app.example.com#random=${random}&foo=${foo}#hash`); + expect(paramObj!['random']).toStrictEqual(random); + expect(paramObj!['foo']).toStrictEqual(`${foo}#hash`); + }); + + it('should extract hash params correctly', () => { + const random = WebUtils.randomString(20); + const url = `http://localhost:4200/#state=${random}&access_token=ya29.a0ARrdaM-sdfsfsdfsdfsdfs-YGFHwg_lM6dePPaT_TunbpsdfsdfsdfsEG6vTVLsLJDDW tv5m1Q8_g3hXraaoELYGsjl53&token_type=Bearer&expires_in=3599&scope=email%20profile%20openid%20 https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile&authuser=0&prompt=none`; - const paramObj = WebUtils.getUrlParams(url); - expect(paramObj!["access_token"]).toBeDefined(); - expect(paramObj!["token_type"]).toStrictEqual("Bearer"); - expect(paramObj!["prompt"]).toBeDefined(); - expect(paramObj!["state"]).toStrictEqual(random); - }); - - it('should extract hash params if search param indicator present', () => { - const token = "sldfskdjflsdf12302"; - const url = `http://localhost:3000/login?#access_token=${token}`; - const paramObj = WebUtils.getUrlParams(url); - expect(paramObj!["access_token"]).toStrictEqual(token); - }) + const paramObj = WebUtils.getUrlParams(url); + expect(paramObj!['access_token']).toBeDefined(); + expect(paramObj!['token_type']).toStrictEqual('Bearer'); + expect(paramObj!['prompt']).toBeDefined(); + expect(paramObj!['state']).toStrictEqual(random); + }); + + it('should extract hash params if search param indicator present', () => { + const token = 'sldfskdjflsdf12302'; + const url = `http://localhost:3000/login?#access_token=${token}`; + const paramObj = WebUtils.getUrlParams(url); + expect(paramObj!['access_token']).toStrictEqual(token); + }); }); -describe("Random string gen", () => { - it('should generate a 10 letter string', () => { - const expected = 10; - const random = WebUtils.randomString(expected); - expect(random.length).toStrictEqual(expected); - }); - - it('should generate a 43 letter string as this is the minimum for PKCE', () => { - const expected = 43; - const random = WebUtils.randomString(expected); - expect(random.length).toStrictEqual(expected); - }); +describe('Random string gen', () => { + it('should generate a 10 letter string', () => { + const expected = 10; + const random = WebUtils.randomString(expected); + expect(random.length).toStrictEqual(expected); + }); + + it('should generate a 43 letter string as this is the minimum for PKCE', () => { + const expected = 43; + const random = WebUtils.randomString(expected); + expect(random.length).toStrictEqual(expected); + }); }); -describe("Authorization url building", () => { - it('should contain a nonce param', async () => { - WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => { - const authorizationUrl = WebUtils.getAuthorizationUrl(webOptions); - expect(authorizationUrl).toContain("nonce"); - }); +describe('Authorization url building', () => { + it('should contain a nonce param', async () => { + WebUtils.buildWebOptions(oneDriveOptions).then((webOptions) => { + const authorizationUrl = WebUtils.getAuthorizationUrl(webOptions); + expect(authorizationUrl).toContain('nonce'); }); + }); }); -describe("Crypto utils", () => { - - it('base 64 simple', () => { - let arr: Uint8Array = CryptoUtils.toUint8Array("tester"); - let expected = CryptoUtils.toBase64(arr); - expect(expected).toEqual("dGVzdGVy"); - }); - - it('base 64 special char', () => { - let arr: Uint8Array = CryptoUtils.toUint8Array("testerposfieppw2874929"); - let expected = CryptoUtils.toBase64(arr); - expect(expected).toEqual("dGVzdGVycG9zZmllcHB3Mjg3NDkyOQ=="); - }); - - it('base 64 with space', () => { - let arr: Uint8Array = CryptoUtils.toUint8Array("base64 encoder"); - let expected = CryptoUtils.toBase64(arr); - expect(expected).toEqual("YmFzZTY0IGVuY29kZXI="); - }); - - it('base64url safe all base64 special chars included', () => { - let expected = CryptoUtils.toBase64Url("YmFz+TY0IG/uY29kZXI="); - expect(expected).toEqual("YmFz-TY0IG_uY29kZXI"); - }); +describe('Crypto utils', () => { + it('base 64 simple', () => { + let arr: Uint8Array = CryptoUtils.toUint8Array('tester'); + let expected = CryptoUtils.toBase64(arr); + expect(expected).toEqual('dGVzdGVy'); + }); + + it('base 64 special char', () => { + let arr: Uint8Array = CryptoUtils.toUint8Array('testerposfieppw2874929'); + let expected = CryptoUtils.toBase64(arr); + expect(expected).toEqual('dGVzdGVycG9zZmllcHB3Mjg3NDkyOQ=='); + }); + + it('base 64 with space', () => { + let arr: Uint8Array = CryptoUtils.toUint8Array('base64 encoder'); + let expected = CryptoUtils.toBase64(arr); + expect(expected).toEqual('YmFzZTY0IGVuY29kZXI='); + }); + + it('base64url safe all base64 special chars included', () => { + let expected = CryptoUtils.toBase64Url('YmFz+TY0IG/uY29kZXI='); + expect(expected).toEqual('YmFz-TY0IG_uY29kZXI'); + }); }); -describe("additional resource headers", () => { - const headerKey = "Access-Control-Allow-Origin"; - - const options: OAuth2AuthenticateOptions = { - appId: "appId", - authorizationBaseUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - accessTokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", - scope: "files.readwrite offline_access", - responseType: "code", - additionalResourceHeaders: { - "Access-Control-Allow-Origin": "will-be-overwritten", - }, - web: { - redirectUrl: "https://oauth2.byteowls.com/authorize", - pkceEnabled: false, - additionalResourceHeaders: { - "Access-Control-Allow-Origin": "*", - } - } - }; - - it('should be defined', async () => { - const webOptions = await WebUtils.buildWebOptions(options); - expect(webOptions.additionalResourceHeaders[headerKey]).toBeDefined(); - }); +describe('additional resource headers', () => { + const headerKey = 'Access-Control-Allow-Origin'; + + const options: OAuth2AuthenticateOptions = { + appId: 'appId', + authorizationBaseUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + accessTokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + scope: 'files.readwrite offline_access', + responseType: 'code', + additionalResourceHeaders: { + 'Access-Control-Allow-Origin': 'will-be-overwritten', + }, + web: { + redirectUrl: 'https://oauth2.byteowls.com/authorize', + pkceEnabled: false, + additionalResourceHeaders: { + 'Access-Control-Allow-Origin': '*', + }, + }, + }; - it('should equal *', async () => { - const webOptions = await WebUtils.buildWebOptions(options); - expect(webOptions.additionalResourceHeaders[headerKey]).toEqual("*"); - }); + it('should be defined', async () => { + const webOptions = await WebUtils.buildWebOptions(options); + expect(webOptions.additionalResourceHeaders[headerKey]).toBeDefined(); + }); + it('should equal *', async () => { + const webOptions = await WebUtils.buildWebOptions(options); + expect(webOptions.additionalResourceHeaders[headerKey]).toEqual('*'); + }); }); - diff --git a/src/web-utils.ts b/src/web-utils.ts index 12e9633f..089ad3c4 100644 --- a/src/web-utils.ts +++ b/src/web-utils.ts @@ -1,267 +1,271 @@ -import { OAuth2AuthenticateOptions } from "./definitions"; +import { OAuth2AuthenticateOptions } from './definitions'; // import sha256 from "fast-sha256"; - export class WebUtils { - /** - * Public only for testing - */ - static getAppId(options: OAuth2AuthenticateOptions): string { - return this.getOverwritableValue(options, "appId"); + /** + * Public only for testing + */ + static getAppId(options: OAuth2AuthenticateOptions): string { + return this.getOverwritableValue(options, 'appId'); + } + + static getOverwritableValue(options: OAuth2AuthenticateOptions | any, key: string): T { + let base = options[key]; + if (options.web && key in options.web) { + base = options.web[key]; } - - static getOverwritableValue(options: OAuth2AuthenticateOptions | any, key: string): T { - let base = options[key]; - if (options.web && key in options.web) { - base = options.web[key]; - } - return base; + return base; + } + + /** + * Public only for testing + */ + static getAuthorizationUrl(options: WebOptions): string { + let url = options.authorizationBaseUrl + '?client_id=' + options.appId; + url += '&response_type=' + options.responseType; + + if (options.redirectUrl) { + url += '&redirect_uri=' + options.redirectUrl; } - - /** - * Public only for testing - */ - static getAuthorizationUrl(options: WebOptions): string { - let url = options.authorizationBaseUrl + "?client_id=" + options.appId; - url += "&response_type=" + options.responseType; - - if (options.redirectUrl) { - url += "&redirect_uri=" + options.redirectUrl; - } - if (options.scope) { - url += "&scope=" + options.scope; - } - url += "&state=" + options.state; - - if (options.additionalParameters) { - for (const key in options.additionalParameters) { - url += "&" + key + "=" + options.additionalParameters[key]; - } - } - - if (options.pkceCodeChallenge) { - url += "&code_challenge=" + options.pkceCodeChallenge; - url += "&code_challenge_method=" + options.pkceCodeChallengeMethod; - } - return encodeURI(url); + if (options.scope) { + url += '&scope=' + options.scope; } + url += '&state=' + options.state; - static getTokenEndpointData(options: WebOptions, code: string): string { - let body = ''; - body += encodeURIComponent('grant_type') + '=' + encodeURIComponent('authorization_code') + '&'; - body += encodeURIComponent('client_id') + '=' + encodeURIComponent(options.appId) + '&'; - body += encodeURIComponent('redirect_uri') + '=' + encodeURIComponent(options.redirectUrl) + '&'; - body += encodeURIComponent('code') + '=' + encodeURIComponent(code) + '&'; - body += encodeURIComponent('code_verifier') + '=' + encodeURIComponent(options.pkceCodeVerifier); - return body; + if (options.additionalParameters) { + for (const key in options.additionalParameters) { + url += '&' + key + '=' + options.additionalParameters[key]; + } } - /** - * Public only for testing - */ - static getUrlParams(url: string): { [x: string]: string; } | undefined { - const urlString = `${url ?? ''}`.trim(); - - if (urlString.length === 0) { - return; - } - - const parsedUrl = new URL(urlString); - if (!parsedUrl.search && !parsedUrl.hash) { - return; - } - - let urlParamStr; - if (parsedUrl.search) { - urlParamStr = parsedUrl.search.substr(1); - } else { - urlParamStr = parsedUrl.hash.substr(1); - } - - const keyValuePairs: string[] = urlParamStr.split(`&`); - // @ts-ignore - return keyValuePairs.reduce((accumulator, currentValue) => { - const [key, val] = currentValue.split(`=`); - if (key && key.length > 0) { - return { - ...accumulator, - [key]: decodeURIComponent(val) - } - } - }, {}); + if (options.pkceCodeChallenge) { + url += '&code_challenge=' + options.pkceCodeChallenge; + url += '&code_challenge_method=' + options.pkceCodeChallengeMethod; + } + return encodeURI(url); + } + + static getTokenEndpointData(options: WebOptions, code: string): string { + let body = ''; + body += encodeURIComponent('grant_type') + '=' + encodeURIComponent('authorization_code') + '&'; + body += encodeURIComponent('client_id') + '=' + encodeURIComponent(options.appId) + '&'; + body += encodeURIComponent('redirect_uri') + '=' + encodeURIComponent(options.redirectUrl) + '&'; + body += encodeURIComponent('code') + '=' + encodeURIComponent(code) + '&'; + body += encodeURIComponent('code_verifier') + '=' + encodeURIComponent(options.pkceCodeVerifier); + return body; + } + + /** + * Public only for testing + */ + static getUrlParams(url: string): { [x: string]: string } | undefined { + const urlString = `${url ?? ''}`.trim(); + + if (urlString.length === 0) { + return; } - static randomString(length: number = 10) { - const haystack = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let randomStr; - if (window.crypto) { - let numberArray: Uint32Array = new Uint32Array(length); - window.crypto.getRandomValues(numberArray); - numberArray = numberArray.map(x => haystack.charCodeAt(x % haystack.length)); - - let stringArray: string[] = []; - numberArray.forEach(x => { - stringArray.push(haystack.charAt(x % haystack.length)); - }) - randomStr = stringArray.join(""); - } else { - randomStr = ""; - for (let i = 0; i < length; i++) { - randomStr += haystack.charAt(Math.floor(Math.random() * haystack.length)); - } - } - return randomStr; + const parsedUrl = new URL(urlString); + if (!parsedUrl.search && !parsedUrl.hash) { + return; } - static async buildWebOptions(configOptions: OAuth2AuthenticateOptions): Promise { - const webOptions = new WebOptions(); - webOptions.appId = this.getAppId(configOptions); - webOptions.authorizationBaseUrl = this.getOverwritableValue(configOptions, "authorizationBaseUrl"); - webOptions.responseType = this.getOverwritableValue(configOptions, "responseType"); - if (!webOptions.responseType) { - webOptions.responseType = "token"; - } - webOptions.redirectUrl = this.getOverwritableValue(configOptions, "redirectUrl"); - // controlling parameters - webOptions.resourceUrl = this.getOverwritableValue(configOptions, "resourceUrl"); - webOptions.accessTokenEndpoint = this.getOverwritableValue(configOptions, "accessTokenEndpoint"); + let urlParamStr; + if (parsedUrl.search) { + urlParamStr = parsedUrl.search.substr(1); + } else { + urlParamStr = parsedUrl.hash.substr(1); + } - webOptions.pkceEnabled = this.getOverwritableValue(configOptions, "pkceEnabled"); - if (webOptions.pkceEnabled) { - webOptions.pkceCodeVerifier = this.randomString(64); - if (CryptoUtils.HAS_SUBTLE_CRYPTO) { - await CryptoUtils.deriveChallenge(webOptions.pkceCodeVerifier).then(c => { - webOptions.pkceCodeChallenge = c; - webOptions.pkceCodeChallengeMethod = "S256"; - }); - } else { - webOptions.pkceCodeChallenge = webOptions.pkceCodeVerifier; - webOptions.pkceCodeChallengeMethod = "plain"; - } - } - webOptions.scope = this.getOverwritableValue(configOptions, "scope"); - webOptions.state = this.getOverwritableValue(configOptions, "state"); - if (!webOptions.state || webOptions.state.length === 0) { - webOptions.state = this.randomString(20); - } - let parametersMapHelper = this.getOverwritableValue<{ [key: string]: string }>(configOptions, "additionalParameters"); - if (parametersMapHelper) { - webOptions.additionalParameters = {}; - for (const key in parametersMapHelper) { - if (key && key.trim().length > 0) { - let value = parametersMapHelper[key]; - if (value && value.trim().length > 0) { - webOptions.additionalParameters[key] = value; - } - } - } - } - let headersMapHelper = this.getOverwritableValue<{ [key: string]: string }>(configOptions, "additionalResourceHeaders"); - if (headersMapHelper) { - webOptions.additionalResourceHeaders = {}; - for (const key in headersMapHelper) { - if (key && key.trim().length > 0) { - let value = headersMapHelper[key]; - if (value && value.trim().length > 0) { - webOptions.additionalResourceHeaders[key] = value; - } - } - } + const keyValuePairs: string[] = urlParamStr.split(`&`); + // @ts-ignore + return keyValuePairs.reduce((accumulator, currentValue) => { + const [key, val] = currentValue.split(`=`); + if (key && key.length > 0) { + return { + ...accumulator, + [key]: decodeURIComponent(val), + }; + } + }, {}); + } + + static randomString(length: number = 10) { + const haystack = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let randomStr; + if (window.crypto) { + let numberArray: Uint32Array = new Uint32Array(length); + window.crypto.getRandomValues(numberArray); + numberArray = numberArray.map((x) => haystack.charCodeAt(x % haystack.length)); + + let stringArray: string[] = []; + numberArray.forEach((x) => { + stringArray.push(haystack.charAt(x % haystack.length)); + }); + randomStr = stringArray.join(''); + } else { + randomStr = ''; + for (let i = 0; i < length; i++) { + randomStr += haystack.charAt(Math.floor(Math.random() * haystack.length)); + } + } + return randomStr; + } + + static async buildWebOptions(configOptions: OAuth2AuthenticateOptions): Promise { + const webOptions = new WebOptions(); + webOptions.appId = this.getAppId(configOptions); + webOptions.authorizationBaseUrl = this.getOverwritableValue(configOptions, 'authorizationBaseUrl'); + webOptions.responseType = this.getOverwritableValue(configOptions, 'responseType'); + if (!webOptions.responseType) { + webOptions.responseType = 'token'; + } + webOptions.redirectUrl = this.getOverwritableValue(configOptions, 'redirectUrl'); + // controlling parameters + webOptions.resourceUrl = this.getOverwritableValue(configOptions, 'resourceUrl'); + webOptions.accessTokenEndpoint = this.getOverwritableValue(configOptions, 'accessTokenEndpoint'); + + webOptions.pkceEnabled = this.getOverwritableValue(configOptions, 'pkceEnabled'); + if (webOptions.pkceEnabled) { + webOptions.pkceCodeVerifier = this.randomString(64); + if (CryptoUtils.HAS_SUBTLE_CRYPTO) { + await CryptoUtils.deriveChallenge(webOptions.pkceCodeVerifier).then((c) => { + webOptions.pkceCodeChallenge = c; + webOptions.pkceCodeChallengeMethod = 'S256'; + }); + } else { + webOptions.pkceCodeChallenge = webOptions.pkceCodeVerifier; + webOptions.pkceCodeChallengeMethod = 'plain'; + } + } + webOptions.scope = this.getOverwritableValue(configOptions, 'scope'); + webOptions.state = this.getOverwritableValue(configOptions, 'state'); + if (!webOptions.state || webOptions.state.length === 0) { + webOptions.state = this.randomString(20); + } + let parametersMapHelper = this.getOverwritableValue<{ [key: string]: string }>( + configOptions, + 'additionalParameters', + ); + if (parametersMapHelper) { + webOptions.additionalParameters = {}; + for (const key in parametersMapHelper) { + if (key && key.trim().length > 0) { + let value = parametersMapHelper[key]; + if (value && value.trim().length > 0) { + webOptions.additionalParameters[key] = value; + } } - webOptions.logsEnabled = this.getOverwritableValue(configOptions, "logsEnabled"); - - return webOptions; + } } - - static buildWindowOptions(configOptions: OAuth2AuthenticateOptions) { - const windowOptions = new WebOptions(); - if (configOptions.web) { - if (configOptions.web.windowOptions) { - windowOptions.windowOptions = configOptions.web.windowOptions; - } - if (configOptions.web.windowTarget) { - windowOptions.windowTarget = configOptions.web.windowTarget; - } + let headersMapHelper = this.getOverwritableValue<{ [key: string]: string }>( + configOptions, + 'additionalResourceHeaders', + ); + if (headersMapHelper) { + webOptions.additionalResourceHeaders = {}; + for (const key in headersMapHelper) { + if (key && key.trim().length > 0) { + let value = headersMapHelper[key]; + if (value && value.trim().length > 0) { + webOptions.additionalResourceHeaders[key] = value; + } } - return windowOptions; + } } + webOptions.logsEnabled = this.getOverwritableValue(configOptions, 'logsEnabled'); + + return webOptions; + } + + static buildWindowOptions(configOptions: OAuth2AuthenticateOptions) { + const windowOptions = new WebOptions(); + if (configOptions.web) { + if (configOptions.web.windowOptions) { + windowOptions.windowOptions = configOptions.web.windowOptions; + } + if (configOptions.web.windowTarget) { + windowOptions.windowTarget = configOptions.web.windowTarget; + } + } + return windowOptions; + } } export class CryptoUtils { - static BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - static HAS_SUBTLE_CRYPTO: boolean = typeof window !== 'undefined' && !!(window.crypto as any) && !!(window.crypto.subtle as any); + static BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + static HAS_SUBTLE_CRYPTO: boolean = + typeof window !== 'undefined' && !!(window.crypto as any) && !!(window.crypto.subtle as any); - static toUint8Array(str: string): Uint8Array { - const buf = new ArrayBuffer(str.length); - const bufView = new Uint8Array(buf); + static toUint8Array(str: string): Uint8Array { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); - for (let i = 0; i < str.length; i++) { - bufView[i] = str.charCodeAt(i); - } - return bufView; + for (let i = 0; i < str.length; i++) { + bufView[i] = str.charCodeAt(i); } - - static toBase64Url(base64: string): string { - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return bufView; + } + + static toBase64Url(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + static toBase64(bytes: Uint8Array): string { + let len = bytes.length; + let base64 = ''; + for (let i = 0; i < len; i += 3) { + base64 += this.BASE64_CHARS[bytes[i] >> 2]; + base64 += this.BASE64_CHARS[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + base64 += this.BASE64_CHARS[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + base64 += this.BASE64_CHARS[bytes[i + 2] & 63]; } - static toBase64(bytes: Uint8Array): string { - let len = bytes.length; - let base64 = ""; - for (let i = 0; i < len; i += 3) { - base64 += this.BASE64_CHARS[bytes[i] >> 2]; - base64 += this.BASE64_CHARS[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; - base64 += this.BASE64_CHARS[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; - base64 += this.BASE64_CHARS[bytes[i + 2] & 63]; - } - - if ((len % 3) === 2) { - base64 = base64.substring(0, base64.length - 1) + "="; - } else if (len % 3 === 1) { - base64 = base64.substring(0, base64.length - 2) + "=="; - } - return base64; + if (len % 3 === 2) { + base64 = base64.substring(0, base64.length - 1) + '='; + } else if (len % 3 === 1) { + base64 = base64.substring(0, base64.length - 2) + '=='; } + return base64; + } - static deriveChallenge(codeVerifier: string): Promise { - if (codeVerifier.length < 43 || codeVerifier.length > 128) { - return Promise.reject(new Error('ERR_PKCE_CODE_VERIFIER_INVALID_LENGTH')); - } - if (!CryptoUtils.HAS_SUBTLE_CRYPTO) { - return Promise.reject(new Error('ERR_PKCE_CRYPTO_NOTSUPPORTED')); - } - - return new Promise((resolve, reject) => { - crypto.subtle.digest('SHA-256', this.toUint8Array(codeVerifier)).then( - arrayBuffer => { - return resolve(this.toBase64Url(this.toBase64(new Uint8Array(arrayBuffer)))); - }, - error => reject(error) - ); - }); + static deriveChallenge(codeVerifier: string): Promise { + if (codeVerifier.length < 43 || codeVerifier.length > 128) { + return Promise.reject(new Error('ERR_PKCE_CODE_VERIFIER_INVALID_LENGTH')); + } + if (!CryptoUtils.HAS_SUBTLE_CRYPTO) { + return Promise.reject(new Error('ERR_PKCE_CRYPTO_NOTSUPPORTED')); } + return new Promise((resolve, reject) => { + crypto.subtle.digest('SHA-256', this.toUint8Array(codeVerifier)).then( + (arrayBuffer) => { + return resolve(this.toBase64Url(this.toBase64(new Uint8Array(arrayBuffer)))); + }, + (error) => reject(error), + ); + }); + } } export class WebOptions { - appId: string; - authorizationBaseUrl: string; - accessTokenEndpoint: string; - resourceUrl: string; - responseType: string; - scope: string; - state: string; - redirectUrl: string; - logsEnabled: boolean; - windowOptions: string; - windowTarget: string = "_blank"; - - pkceEnabled: boolean; - pkceCodeVerifier: string; - pkceCodeChallenge: string; - pkceCodeChallengeMethod: string; - - additionalParameters: { [key: string]: string }; - additionalResourceHeaders: { [key: string]: string }; + appId: string; + authorizationBaseUrl: string; + accessTokenEndpoint: string; + resourceUrl: string; + responseType: string; + scope: string; + state: string; + redirectUrl: string; + logsEnabled: boolean; + windowOptions: string; + windowTarget: string = '_blank'; + + pkceEnabled: boolean; + pkceCodeVerifier: string; + pkceCodeChallenge: string; + pkceCodeChallengeMethod: string; + + additionalParameters: { [key: string]: string }; + additionalResourceHeaders: { [key: string]: string }; } - diff --git a/src/web.ts b/src/web.ts index ee22799a..bcf7c8bb 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,230 +1,244 @@ -import {WebPlugin} from '@capacitor/core'; -import type {OAuth2AuthenticateOptions, OAuth2ClientPlugin, OAuth2RefreshTokenOptions} from "./definitions"; -import {WebOptions, WebUtils} from "./web-utils"; +import { WebPlugin } from '@capacitor/core'; +import type { OAuth2AuthenticateOptions, OAuth2ClientPlugin, OAuth2RefreshTokenOptions } from './definitions'; +import { WebOptions, WebUtils } from './web-utils'; export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlugin { + private webOptions: WebOptions; + private windowHandle: Window | null; + private intervalId: number; + private loopCount = 2000; + private intervalLength = 100; + private windowClosedByPlugin: boolean; - private webOptions: WebOptions; - private windowHandle: Window | null; - private intervalId: number; - private loopCount = 2000; - private intervalLength = 100; - private windowClosedByPlugin: boolean; + /** + * Get a new access token using an existing refresh token. + */ + async refreshToken(_options: OAuth2RefreshTokenOptions): Promise { + return new Promise((_resolve, reject) => { + reject(new Error('Functionality not implemented for PWAs yet')); + }); + } - /** - * Get a new access token using an existing refresh token. - */ - async refreshToken(_options: OAuth2RefreshTokenOptions): Promise { - return new Promise((_resolve, reject) => { - reject(new Error("Functionality not implemented for PWAs yet")); - }); - } + async authenticate(options: OAuth2AuthenticateOptions): Promise { + const windowOptions = WebUtils.buildWindowOptions(options); - async authenticate(options: OAuth2AuthenticateOptions): Promise { - const windowOptions = WebUtils.buildWindowOptions(options); + // we open the window first to avoid popups being blocked because of + // the asynchronous buildWebOptions call + this.windowHandle = window.open('', windowOptions.windowTarget, windowOptions.windowOptions); - // we open the window first to avoid popups being blocked because of - // the asynchronous buildWebOptions call - this.windowHandle = window.open( - '', - windowOptions.windowTarget, - windowOptions.windowOptions - ); + this.webOptions = await WebUtils.buildWebOptions(options); + return new Promise((resolve, reject) => { + // validate + if (!this.webOptions.appId || this.webOptions.appId.length == 0) { + reject(new Error('ERR_PARAM_NO_APP_ID')); + } else if (!this.webOptions.authorizationBaseUrl || this.webOptions.authorizationBaseUrl.length == 0) { + reject(new Error('ERR_PARAM_NO_AUTHORIZATION_BASE_URL')); + } else if (!this.webOptions.redirectUrl || this.webOptions.redirectUrl.length == 0) { + reject(new Error('ERR_PARAM_NO_REDIRECT_URL')); + } else if (!this.webOptions.responseType || this.webOptions.responseType.length == 0) { + reject(new Error('ERR_PARAM_NO_RESPONSE_TYPE')); + } else { + // init internal control params + let loopCount = this.loopCount; + this.windowClosedByPlugin = false; + // open window + const authorizationUrl = WebUtils.getAuthorizationUrl(this.webOptions); + if (this.webOptions.logsEnabled) { + this.doLog('Authorization url: ' + authorizationUrl); + } + if (this.windowHandle) { + this.windowHandle.location.href = authorizationUrl; + } + // wait for redirect and resolve the + this.intervalId = window.setInterval(() => { + if (loopCount-- < 0) { + this.closeWindow(); + } else if (this.windowHandle?.closed && !this.windowClosedByPlugin) { + window.clearInterval(this.intervalId); + reject(new Error('USER_CANCELLED')); + } else { + let href: string = undefined!; + try { + href = this.windowHandle?.location.href!; + } catch (ignore) { + // ignore DOMException: Blocked a frame with origin "http://localhost:4200" from accessing a cross-origin frame. + } - this.webOptions = await WebUtils.buildWebOptions(options); - return new Promise((resolve, reject) => { - // validate - if (!this.webOptions.appId || this.webOptions.appId.length == 0) { - reject(new Error("ERR_PARAM_NO_APP_ID")); - } else if (!this.webOptions.authorizationBaseUrl || this.webOptions.authorizationBaseUrl.length == 0) { - reject(new Error("ERR_PARAM_NO_AUTHORIZATION_BASE_URL")); - } else if (!this.webOptions.redirectUrl || this.webOptions.redirectUrl.length == 0) { - reject(new Error("ERR_PARAM_NO_REDIRECT_URL")); - } else if (!this.webOptions.responseType || this.webOptions.responseType.length == 0) { - reject(new Error("ERR_PARAM_NO_RESPONSE_TYPE")); - } else { - // init internal control params - let loopCount = this.loopCount; - this.windowClosedByPlugin = false; - // open window - const authorizationUrl = WebUtils.getAuthorizationUrl(this.webOptions); + if (href != null && href.indexOf(this.webOptions.redirectUrl) >= 0) { + if (this.webOptions.logsEnabled) { + this.doLog('Url from Provider: ' + href); + } + let authorizationRedirectUrlParamObj = WebUtils.getUrlParams(href); + if (authorizationRedirectUrlParamObj) { if (this.webOptions.logsEnabled) { - this.doLog("Authorization url: " + authorizationUrl); - } - if (this.windowHandle) { - this.windowHandle.location.href = authorizationUrl; + this.doLog('Authorization response:', authorizationRedirectUrlParamObj); } - // wait for redirect and resolve the - this.intervalId = window.setInterval(() => { - if (loopCount-- < 0) { - this.closeWindow(); - } else if (this.windowHandle?.closed && !this.windowClosedByPlugin) { - window.clearInterval(this.intervalId); - reject(new Error("USER_CANCELLED")); - } else { - let href: string = undefined!; - try { - href = this.windowHandle?.location.href!; - } catch (ignore) { - // ignore DOMException: Blocked a frame with origin "http://localhost:4200" from accessing a cross-origin frame. - } - - if (href != null && href.indexOf(this.webOptions.redirectUrl) >= 0) { - if (this.webOptions.logsEnabled) { - this.doLog("Url from Provider: " + href); - } - let authorizationRedirectUrlParamObj = WebUtils.getUrlParams(href); - if (authorizationRedirectUrlParamObj) { - if (this.webOptions.logsEnabled) { - this.doLog("Authorization response:", authorizationRedirectUrlParamObj); - } - window.clearInterval(this.intervalId); - // check state - if (authorizationRedirectUrlParamObj.state === this.webOptions.state) { - if (this.webOptions.accessTokenEndpoint) { - const self = this; - let authorizationCode = authorizationRedirectUrlParamObj.code; - if (authorizationCode) { - const tokenRequest = new XMLHttpRequest(); - tokenRequest.onload = function () { - if (this.status === 200) { - let accessTokenResponse = JSON.parse(this.response); - if (self.webOptions.logsEnabled) { - self.doLog("Access token response:", accessTokenResponse); - } - self.requestResource(accessTokenResponse.access_token, resolve, reject, authorizationRedirectUrlParamObj, accessTokenResponse); - } - }; - tokenRequest.onerror = function () { - // always log error because of CORS hint - self.doLog("ERR_GENERAL: See client logs. It might be CORS. Status text: " + this.statusText); - reject(new Error("ERR_GENERAL")); - }; - tokenRequest.open("POST", this.webOptions.accessTokenEndpoint, true); - tokenRequest.setRequestHeader('accept', 'application/json'); - tokenRequest.setRequestHeader('cache-control', 'no-cache'); - tokenRequest.setRequestHeader('content-type', 'application/x-www-form-urlencoded'); - tokenRequest.send(WebUtils.getTokenEndpointData(this.webOptions, authorizationCode)); - } else { - reject(new Error("ERR_NO_AUTHORIZATION_CODE")); - } - this.closeWindow(); - } else { - // if no accessTokenEndpoint exists request the resource - this.requestResource(authorizationRedirectUrlParamObj.access_token, resolve, reject, authorizationRedirectUrlParamObj); - } - } else { - if (this.webOptions.logsEnabled) { - this.doLog("State from web options: " + this.webOptions.state); - this.doLog("State returned from provider: " + authorizationRedirectUrlParamObj.state); - } - reject(new Error("ERR_STATES_NOT_MATCH")); - this.closeWindow(); - } - } - // this is no error no else clause required + window.clearInterval(this.intervalId); + // check state + if (authorizationRedirectUrlParamObj.state === this.webOptions.state) { + if (this.webOptions.accessTokenEndpoint) { + const self = this; + let authorizationCode = authorizationRedirectUrlParamObj.code; + if (authorizationCode) { + const tokenRequest = new XMLHttpRequest(); + tokenRequest.onload = function () { + if (this.status === 200) { + let accessTokenResponse = JSON.parse(this.response); + if (self.webOptions.logsEnabled) { + self.doLog('Access token response:', accessTokenResponse); + } + self.requestResource( + accessTokenResponse.access_token, + resolve, + reject, + authorizationRedirectUrlParamObj, + accessTokenResponse, + ); } + }; + tokenRequest.onerror = function () { + // always log error because of CORS hint + self.doLog('ERR_GENERAL: See client logs. It might be CORS. Status text: ' + this.statusText); + reject(new Error('ERR_GENERAL')); + }; + tokenRequest.open('POST', this.webOptions.accessTokenEndpoint, true); + tokenRequest.setRequestHeader('accept', 'application/json'); + tokenRequest.setRequestHeader('cache-control', 'no-cache'); + tokenRequest.setRequestHeader('content-type', 'application/x-www-form-urlencoded'); + tokenRequest.send(WebUtils.getTokenEndpointData(this.webOptions, authorizationCode)); + } else { + reject(new Error('ERR_NO_AUTHORIZATION_CODE')); } - }, this.intervalLength); + this.closeWindow(); + } else { + // if no accessTokenEndpoint exists request the resource + this.requestResource( + authorizationRedirectUrlParamObj.access_token, + resolve, + reject, + authorizationRedirectUrlParamObj, + ); + } + } else { + if (this.webOptions.logsEnabled) { + this.doLog('State from web options: ' + this.webOptions.state); + this.doLog('State returned from provider: ' + authorizationRedirectUrlParamObj.state); + } + reject(new Error('ERR_STATES_NOT_MATCH')); + this.closeWindow(); + } + } + // this is no error no else clause required } - }); - } + } + }, this.intervalLength); + } + }); + } - private readonly MSG_RETURNED_TO_JS = "Returned to JS:"; + private readonly MSG_RETURNED_TO_JS = 'Returned to JS:'; - private requestResource(accessToken: string, resolve: any, reject: (reason?: any) => void, authorizationResponse: any, accessTokenResponse: any = null) { - if (this.webOptions.resourceUrl) { - const logsEnabled = this.webOptions.logsEnabled; + private requestResource( + accessToken: string, + resolve: any, + reject: (reason?: any) => void, + authorizationResponse: any, + accessTokenResponse: any = null, + ) { + if (this.webOptions.resourceUrl) { + const logsEnabled = this.webOptions.logsEnabled; + if (logsEnabled) { + this.doLog('Resource url: ' + this.webOptions.resourceUrl); + } + if (accessToken) { + if (logsEnabled) { + this.doLog('Access token:', accessToken); + } + const self = this; + const request = new XMLHttpRequest(); + request.onload = function () { + if (this.status === 200) { + let resp = JSON.parse(this.response); if (logsEnabled) { - this.doLog("Resource url: " + this.webOptions.resourceUrl); + self.doLog('Resource response:', resp); } - if (accessToken) { - if (logsEnabled) { - this.doLog("Access token:", accessToken); - } - const self = this; - const request = new XMLHttpRequest(); - request.onload = function () { - if (this.status === 200) { - let resp = JSON.parse(this.response); - if (logsEnabled) { - self.doLog("Resource response:", resp); - } - if (resp) { - self.assignResponses(resp, accessToken, authorizationResponse, accessTokenResponse); - } - if (logsEnabled) { - self.doLog(self.MSG_RETURNED_TO_JS, resp); - } - resolve(resp); - } else { - reject(new Error(this.statusText)); - } - self.closeWindow(); - }; - request.onerror = function () { - if (logsEnabled) { - self.doLog("ERR_GENERAL: " + this.statusText); - } - reject(new Error("ERR_GENERAL")); - self.closeWindow(); - }; - request.open("GET", this.webOptions.resourceUrl, true); - request.setRequestHeader('Authorization', `Bearer ${accessToken}`); - if (this.webOptions.additionalResourceHeaders) { - for (const key in this.webOptions.additionalResourceHeaders) { - request.setRequestHeader(key, this.webOptions.additionalResourceHeaders[key]); - } - } - request.send(); - } else { - if (logsEnabled) { - this.doLog("No accessToken was provided although you configured a resourceUrl. Remove the resourceUrl from the config."); - } - reject(new Error("ERR_NO_ACCESS_TOKEN")); - this.closeWindow(); + if (resp) { + self.assignResponses(resp, accessToken, authorizationResponse, accessTokenResponse); } - } else { - // if no resource url exists just return the accessToken response - const resp = {}; - this.assignResponses(resp, accessToken, authorizationResponse, accessTokenResponse); - if (this.webOptions.logsEnabled) { - this.doLog(this.MSG_RETURNED_TO_JS, resp); + if (logsEnabled) { + self.doLog(self.MSG_RETURNED_TO_JS, resp); } resolve(resp); - this.closeWindow(); - } - } - - assignResponses(resp: any, accessToken: string, authorizationResponse: any, accessTokenResponse: any = null): void { - // #154 - if (authorizationResponse) { - resp["authorization_response"] = authorizationResponse; + } else { + reject(new Error(this.statusText)); + } + self.closeWindow(); + }; + request.onerror = function () { + if (logsEnabled) { + self.doLog('ERR_GENERAL: ' + this.statusText); + } + reject(new Error('ERR_GENERAL')); + self.closeWindow(); + }; + request.open('GET', this.webOptions.resourceUrl, true); + request.setRequestHeader('Authorization', `Bearer ${accessToken}`); + if (this.webOptions.additionalResourceHeaders) { + for (const key in this.webOptions.additionalResourceHeaders) { + request.setRequestHeader(key, this.webOptions.additionalResourceHeaders[key]); + } } - if (accessTokenResponse) { - resp["access_token_response"] = accessTokenResponse; + request.send(); + } else { + if (logsEnabled) { + this.doLog( + 'No accessToken was provided although you configured a resourceUrl. Remove the resourceUrl from the config.', + ); } - resp["access_token"] = accessToken; + reject(new Error('ERR_NO_ACCESS_TOKEN')); + this.closeWindow(); + } + } else { + // if no resource url exists just return the accessToken response + const resp = {}; + this.assignResponses(resp, accessToken, authorizationResponse, accessTokenResponse); + if (this.webOptions.logsEnabled) { + this.doLog(this.MSG_RETURNED_TO_JS, resp); + } + resolve(resp); + this.closeWindow(); } + } - async logout(options: OAuth2AuthenticateOptions): Promise { - return new Promise((resolve, _reject) => { - localStorage.removeItem(WebUtils.getAppId(options)); - resolve(true); - }); + assignResponses(resp: any, accessToken: string, authorizationResponse: any, accessTokenResponse: any = null): void { + // #154 + if (authorizationResponse) { + resp['authorization_response'] = authorizationResponse; } - - private closeWindow() { - window.clearInterval(this.intervalId); - // #164 if the provider's login page is opened in the same tab or window it must not be closed - // if (this.webOptions.windowTarget !== "_self") { - // this.windowHandle?.close(); - // } - this.windowHandle?.close(); - this.windowClosedByPlugin = true; + if (accessTokenResponse) { + resp['access_token_response'] = accessTokenResponse; } + resp['access_token'] = accessToken; + } - private doLog(msg: string, obj: any = null) { - console.log("I/Capacitor/OAuth2ClientPlugin: " + msg, obj); - } + async logout(options: OAuth2AuthenticateOptions): Promise { + return new Promise((resolve, _reject) => { + localStorage.removeItem(WebUtils.getAppId(options)); + resolve(true); + }); + } + + private closeWindow() { + window.clearInterval(this.intervalId); + // #164 if the provider's login page is opened in the same tab or window it must not be closed + // if (this.webOptions.windowTarget !== "_self") { + // this.windowHandle?.close(); + // } + this.windowHandle?.close(); + this.windowClosedByPlugin = true; + } + + private doLog(msg: string, obj: any = null) { + console.log('I/Capacitor/OAuth2ClientPlugin: ' + msg, obj); + } }