diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..e11b45c43c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,178 @@ +# AI Context File for MSAL Custom Authentication Project + +## 📦 Description + +MSAL Custom Authentication is a TypeScript-based library that extends Microsoft's Authentication Library (MSAL) to enable customized authentication experiences in modern web applications. Key features include: + +- **Flexible Authentication Flows:** + + - Sign-in with username/password or passwordless options + - Sign-up with customizable user attributes + - Password reset and account recovery + - Account management and token handling + +- **UI Framework Agnostic:** + + - Built as a headless authentication library + - Complete control over UI implementation + - Separation of authentication logic from presentation + - Seamless integration with any UI framework (React, Angular, Vue, vanilla JavaScript) + +- **Developer-Friendly:** + - Strongly typed APIs for enhanced development experience + - Comprehensive error handling and logging + - Built-in state management for auth flows + - Clear separation of concerns for better maintainability + +## 🧱 Technology Stack + +- **Language & Runtime:** + - TypeScript 5.7+ + - Node.js 10+ +- **Core Dependencies:** + - @azure/msal-browser - Core MSAL browser library +- **Build System:** + - Rollup.js - Module bundling + - TypeScript compiler + - Babel - JavaScript compilation +- **Testing & Quality:** + - Jest - Testing framework with JSDOM environment + - ESLint - Code linting + - Prettier - Code formatting +- **Documentation:** + - TypeDoc - API documentation + - API Extractor - API report generation + +## 🗂️ Structure + +``` +lib/msal-custom-auth/ + ├── src/ → Core library implementation + │ ├── configuration/ → Configuration related files + │ ├── controller/ → Controller implementation + │ ├── core/ → Core functionality and errors + │ │ ├── auth_flow/ → Base authentication flow classes + │ │ ├── error/ → Error type definitions + │ │ ├── interaction_client/ → Base interaction clients + │ │ ├── network_client/ → HTTP and API client implementations + │ │ │ ├── custom_auth_api/ → Custom auth API clients + │ │ │ └── http_client/ → HTTP client implementation + │ │ ├── telemetry/ → Telemetry implementations + │ │ └── utils/ → Utility functions + │ ├── get_account/ → Account retrieval functionality + │ ├── operating_context/ → Operating context implementation + │ ├── reset_password/ → Password reset flow + │ ├── sign_in/ → Sign-in flow implementation + │ │ ├── auth_flow/ → Sign-in authentication flow + │ │ │ ├── error_type/ → Sign-in specific errors + │ │ │ ├── result/ → Sign-in operation results + │ │ │ └── state/ → Sign-in flow states + │ │ └── interaction_client/ → Sign-in interaction handling + │ ├── sign_up/ → Sign-up flow implementation + │ │ ├── auth_flow/ → Sign-up authentication flow + │ │ │ ├── error_type/ → Sign-up specific errors + │ │ │ ├── result/ → Sign-up operation results + │ │ │ └── state/ → Sign-up flow states + │ │ └── interaction_client/ → Sign-up interaction handling + │ ├── index.ts → Library entry point + │ ├── CustomAuthPublicClientApplication.ts → Main application class + │ ├── CustomAuthActionInputs.ts → Action input type definitions + │ ├── CustomAuthConstants.ts → Constants definitions + │ ├── ICustomAuthPublicClientApplication.ts → Interface definitions + │ ├── packageMetadata.ts → Package version information + │ └── UserAccountAttributes.ts → User account attributes + ├── test/ → Unit tests + ├── package.json → Project dependencies and scripts + ├── rollup.config.js → Build configuration + ├── jest.config.cjs → Testing configuration + └── typedoc.json → Documentation generation config +``` + +## 🔁 Patterns & Conventions + +### 📌 General + +- Always define classes and significant logic in separate TypeScript files under `src/core/`. +- Public-facing APIs should be exposed through `src/index.ts`. +- Avoid embedding complex logic directly within entry points or utility scripts. + +### 📌 Async Method Handling + +- All methods interacting with network or authentication flows must be marked `async`. +- Properly await promises and handle exceptions internally using try-catch. + +```typescript +async signInUser(credentials): Promise { + try { + const result = await this.performAuthentication(credentials); + return result; + } catch (error) { + logger.error("Sign-in error:", error); + throw error; + } +} +``` + +### 📌 Error Handling + +- Use centralized logging for all caught exceptions (`logger.error()` for errors). +- Return standardized error objects to maintain consistency across API responses. + +### 📌 Testing + +- Unit tests must be located under `/test` directory, following `.spec.ts` naming convention. +- Maintain high test coverage, especially for core authentication flows. + +## 🗝️ Key Files + +- `src/index.ts`: Exports all public classes, interfaces, types, and constants. +- `src/CustomAuthPublicClientApplication.ts`: Main application class implementing custom authentication flows. +- `src/ICustomAuthPublicClientApplication.ts`: Interface definition for the main application. +- `src/CustomAuthActionInputs.ts`: Type definitions for authentication flow inputs. +- `src/CustomAuthConstants.ts`: Shared constants used throughout the library. +- `src/UserAccountAttributes.ts`: User account attribute management. +- `src/configuration/`: Configuration and initialization related files. +- `src/controller/`: Authentication flow controllers. +- `src/core/`: Core error handling and utilities. +- `src/get_account/`: Account retrieval and management. +- `src/sign_in/`, `src/sign_up/`, `src/reset_password/`: Authentication flow implementations. +- `package.json`: Manage dependencies and npm scripts. +- `rollup.config.js`: Defines build steps. +- `jest.config.cjs`: Testing framework setup. + +## 🎯 Goals for Copilot / AI Tools + +When modifying or extending authentication functionality: + +- Keep main application logic in `CustomAuthPublicClientApplication.ts` focused on high-level flow coordination. +- Implement specific authentication flows in their dedicated directories (`sign_in/`, `sign_up/`, `reset_password/`). +- Place shared utilities and error handling in `src/core/`. +- Implement flow controllers in `src/controller/` to manage authentication state and operations. +- Define new action inputs in `CustomAuthActionInputs.ts`. +- Add any new constants to `CustomAuthConstants.ts`. +- Export all public APIs through `src/index.ts`. +- Write corresponding tests under `/test` folder immediately. +- Ensure proper error handling using the error types defined in `src/core/error/`. +- Follow established async patterns for all network and authentication operations. +- Use type-safe interfaces and maintain strict type checking throughout the codebase. + +## 📚 Reference Files + +- **Entry Point & Exports:** `src/index.ts` +- **Main Application:** `src/CustomAuthPublicClientApplication.ts`, `src/ICustomAuthPublicClientApplication.ts` +- **Authentication Flows:** + - `src/sign_in/`: Sign-in implementation + - `src/sign_up/`: Sign-up implementation + - `src/reset_password/`: Password reset implementation + - `src/get_account/`: Account management +- **Core Implementation:** + - `src/controller/`: Authentication flow controllers + - `src/core/`: Error handling and utilities + - `src/configuration/`: Configuration management + - `src/operating_context/`: Operating context implementation +- **Type Definitions & Constants:** + - `src/CustomAuthActionInputs.ts`: Action input types + - `src/UserAccountAttributes.ts`: User account attributes + - `src/CustomAuthConstants.ts`: Shared constants + +> This context file is optimized for guidance of AI-driven coding tools like GitHub Copilot or ChatGPT, ensuring consistent implementation aligned with project standards. diff --git a/.github/issue_template_bot.json b/.github/issue_template_bot.json index 94f1ccd317..25705ecaef 100644 --- a/.github/issue_template_bot.json +++ b/.github/issue_template_bot.json @@ -17,6 +17,11 @@ "@azure/msal-node" ] }, + "msal-custom-auth": { + "searchStrings": [ + "@azure/msal-custom-auth" + ] + }, "adal-node": { "searchStrings": [ "adal-node" @@ -93,4 +98,4 @@ "incompleteTemplateMessage": "Please update the original issue and make sure to fill out the entire issue template so we can better assist you.", "noTemplateMessage": "Please open a new issue and use one of the provided issue templates. Thanks!", "noTemplateClose": true -} \ No newline at end of file +} diff --git a/.github/labeler.yml b/.github/labeler.yml index e1345ae2a2..24f06a4dae 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -10,6 +10,8 @@ msal-browser: - lib/msal-browser/**/* msal-react: - lib/msal-react/**/* +msal-custom-auth: + - lib/msal-custom-auth/**/* samples: - samples/**/* documentation: diff --git a/.gitignore b/.gitignore index 152b238714..0ff8bf21c1 100644 --- a/.gitignore +++ b/.gitignore @@ -286,4 +286,5 @@ temp-cache.json junit.xml # ApiExtractor -temp/ \ No newline at end of file +temp/ +samples/msal-custom-auth-samples/react-sample-nextjs/.next/ diff --git a/.pipelines/1p-build.yml b/.pipelines/1p-build.yml index 324ea883f2..eebae82c0b 100644 --- a/.pipelines/1p-build.yml +++ b/.pipelines/1p-build.yml @@ -22,7 +22,7 @@ resources: - repository: 1P type: git name: IDDP/msal-javascript-1p - ref: master + ref: custom-auth/pipeline # TODO: change back to master after pipeline is working extends: template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates @@ -60,6 +60,11 @@ extends: libName: msal-react path: "lib/" npmInstallTimeout: ${{ parameters.npmInstallTimeout }} + - template: .pipelines/templates/ci-template.yml@1P + parameters: + libName: msal-custom-auth + path: "lib/" + npmInstallTimeout: ${{ parameters.npmInstallTimeout }} - template: .pipelines/templates/ci-template.yml@1P parameters: libName: msal-angular diff --git a/.pipelines/3p-e2e.yml b/.pipelines/3p-e2e.yml index bd47fcb9ed..c6138ef22f 100644 --- a/.pipelines/3p-e2e.yml +++ b/.pipelines/3p-e2e.yml @@ -8,8 +8,8 @@ parameters: type: string default: "windows" values: - - "windows" - - "linux" + - "windows" + - "linux" - name: "runBrowserTests" displayName: "Run Browser Tests" type: boolean @@ -26,6 +26,10 @@ parameters: displayName: "Run Angular Tests" type: boolean default: true + - name: "runCustomAuthTests" + displayName: "Run Custom Auth Tests" + type: boolean + default: true - name: "npmInstallTimeout" displayName: "NPM Install Timeout (Tests)" type: number @@ -50,7 +54,7 @@ resources: - repository: 1P type: git name: IDDP/msal-javascript-1p - ref: master + ref: custom-auth/pipeline # TODO: change back to master after pipeline is working extends: template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates parameters: @@ -62,75 +66,89 @@ extends: - stage: e2e_test displayName: "3P E2E Tests" jobs: - - ${{ if eq(parameters.runBrowserTests, true) }}: - - template: .pipelines/templates/e2e-tests.yml@1P - parameters: - jobName: "validate_msal_browser" - targetLib: "msal-browser" - poolType: ${{ parameters.poolType }} - stage: "CI" - sourceRepo: ${{ variables.sourceRepo }} - sourceBranch: ${{ variables.sourceBranch }} - workspace: "samples/msal-browser-samples" - samples: - - "client-capabilities" - - "onPageLoad" - - "pop" - - "customizable-e2e-test" - debug: ${{ parameters.debug }} - npmInstallTimeout: ${{ parameters.npmInstallTimeout }} - - ${{ if eq(parameters.runNodeTests, true) }}: - - template: .pipelines/templates/e2e-tests.yml@1P - parameters: - jobName: "validate_msal_node" - targetLib: "msal-node" - poolType: ${{ parameters.poolType }} - stage: "CI" - sourceRepo: ${{ variables.sourceRepo }} - sourceBranch: ${{ variables.sourceBranch }} - workspace: "samples/msal-node-samples" - nodeVersions: [16, 18, 20, 22] - samples: - - "auth-code" - - "auth-code-cli-app" - - "client-credentials" - - "client-credentials-with-cert-from-key-vault" - - "device-code" - - "silent-flow" - - "b2c-user-flows" - # - "on-behalf-of" - debug: ${{ parameters.debug }} - npmInstallTimeout: ${{ parameters.npmInstallTimeout }} - - ${{ if eq(parameters.runReactTests, true) }}: - - template: .pipelines/templates/e2e-tests.yml@1P - parameters: - jobName: "validate_msal_react" - targetLib: "msal-react" - poolType: ${{ parameters.poolType }} - stage: "CI" - sourceRepo: ${{ variables.sourceRepo }} - sourceBranch: ${{ variables.sourceBranch }} - workspace: "samples/msal-react-samples" - samples: - - "nextjs-sample" - - "react-router-sample" - - "typescript-sample" - - "b2c-sample" - debug: ${{ parameters.debug }} - npmInstallTimeout: ${{ parameters.npmInstallTimeout }} - - ${{ if eq(parameters.runAngularTests, true) }}: - - template: .pipelines/templates/e2e-tests.yml@1P - parameters: - jobName: "validate_msal_angular" - targetLib: "msal-angular" - poolType: ${{ parameters.poolType }} - stage: "CI" - sourceRepo: ${{ variables.sourceRepo }} - sourceBranch: ${{ variables.sourceBranch }} - workspace: "samples/msal-angular-samples" - samples: - - "angular-b2c-sample" - - "angular-modules-sample" - - "angular-standalone-sample" - debug: ${{ parameters.debug }} - npmInstallTimeout: ${{ parameters.npmInstallTimeout }} + - ${{ if eq(parameters.runBrowserTests, true) }}: + - template: .pipelines/templates/e2e-tests.yml@1P + parameters: + jobName: "validate_msal_browser" + targetLib: "msal-browser" + poolType: ${{ parameters.poolType }} + stage: "CI" + sourceRepo: ${{ variables.sourceRepo }} + sourceBranch: ${{ variables.sourceBranch }} + workspace: "samples/msal-browser-samples" + samples: + - "client-capabilities" + - "onPageLoad" + - "pop" + - "customizable-e2e-test" + debug: ${{ parameters.debug }} + npmInstallTimeout: ${{ parameters.npmInstallTimeout }} + - ${{ if eq(parameters.runNodeTests, true) }}: + - template: .pipelines/templates/e2e-tests.yml@1P + parameters: + jobName: "validate_msal_node" + targetLib: "msal-node" + poolType: ${{ parameters.poolType }} + stage: "CI" + sourceRepo: ${{ variables.sourceRepo }} + sourceBranch: ${{ variables.sourceBranch }} + workspace: "samples/msal-node-samples" + nodeVersions: [16, 18, 20, 22] + samples: + - "auth-code" + - "auth-code-cli-app" + - "client-credentials" + - "client-credentials-with-cert-from-key-vault" + - "device-code" + - "silent-flow" + - "b2c-user-flows" + # - "on-behalf-of" + debug: ${{ parameters.debug }} + npmInstallTimeout: ${{ parameters.npmInstallTimeout }} + - ${{ if eq(parameters.runReactTests, true) }}: + - template: .pipelines/templates/e2e-tests.yml@1P + parameters: + jobName: "validate_msal_react" + targetLib: "msal-react" + poolType: ${{ parameters.poolType }} + stage: "CI" + sourceRepo: ${{ variables.sourceRepo }} + sourceBranch: ${{ variables.sourceBranch }} + workspace: "samples/msal-react-samples" + samples: + - "nextjs-sample" + - "react-router-sample" + - "typescript-sample" + - "b2c-sample" + debug: ${{ parameters.debug }} + npmInstallTimeout: ${{ parameters.npmInstallTimeout }} + - ${{ if eq(parameters.runAngularTests, true) }}: + - template: .pipelines/templates/e2e-tests.yml@1P + parameters: + jobName: "validate_msal_angular" + targetLib: "msal-angular" + poolType: ${{ parameters.poolType }} + stage: "CI" + sourceRepo: ${{ variables.sourceRepo }} + sourceBranch: ${{ variables.sourceBranch }} + workspace: "samples/msal-angular-samples" + samples: + - "angular-b2c-sample" + - "angular-modules-sample" + - "angular-standalone-sample" + debug: ${{ parameters.debug }} + npmInstallTimeout: ${{ parameters.npmInstallTimeout }} + - ${{ if eq(parameters.runCustomAuthTests, true) }}: + - template: .pipelines/templates/e2e-tests.yml@1P + parameters: + jobName: "validate_msal_custom_auth" + targetLib: "msal-custom-auth" + poolType: ${{ parameters.poolType }} + stage: "CI" + sourceRepo: ${{ variables.sourceRepo }} + sourceBranch: ${{ variables.sourceBranch }} + workspace: "samples/msal-custom-auth-samples" + samples: + - "sample-sample" + debug: ${{ parameters.debug }} + npmInstallTimeout: ${{ parameters.npmInstallTimeout }} diff --git a/.vscode/settings.json b/.vscode/settings.json index a38c551b5b..fbe7f58071 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,6 +41,10 @@ { "name": "msal-node-extensions", "rootPath": "extensions/msal-node-extensions" + }, + { + "name": "msal-custom-auth", + "rootPath": "lib/msal-custom-auth" } ] } diff --git a/CODEOWNERS b/CODEOWNERS index fb25fccaca..e868a00936 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -20,6 +20,10 @@ /lib/msal-react/ @tnorling @jo-arroyo @peterzenz /samples/msal-react-samples/ @tnorling @jo-arroyo @peterzenz +# MSAL Custom Auth +/lib/msal-custom-auth/ @yongdiw @shenj @albanx +/samples/msal-custom-auth-samples/ @yongdiw @shenj @albanx + # Build /build/ @sameerag @tnorling @hectormmg @peterzenz /release-scripts/ @sameerag @tnorling @hectormmg @peterzenz diff --git a/change/@azure-msal-browser-c26b89bf-2543-4528-8b24-904bc6ca5742.json b/change/@azure-msal-browser-c26b89bf-2543-4528-8b24-904bc6ca5742.json new file mode 100644 index 0000000000..4d41628eb8 --- /dev/null +++ b/change/@azure-msal-browser-c26b89bf-2543-4528-8b24-904bc6ca5742.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Expose components used by the msal-custom-auth", + "packageName": "@azure/msal-browser", + "email": "shen.jian@live.com", + "dependentChangeType": "patch" +} diff --git a/change/@azure-msal-custom-auth-89c01ce0-74fa-4ea3-9f21-f04d46e1ba34.json b/change/@azure-msal-custom-auth-89c01ce0-74fa-4ea3-9f21-f04d46e1ba34.json new file mode 100644 index 0000000000..9a014efd87 --- /dev/null +++ b/change/@azure-msal-custom-auth-89c01ce0-74fa-4ea3-9f21-f04d46e1ba34.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Create a new package named msal-custom-auth. This package will be used for the exteral IDs about sign-in, sign-up, password-reset and retrieve accounts and tokens from cache.", + "packageName": "@azure/msal-custom-auth", + "email": "shen.jian@live.com", + "dependentChangeType": "none" +} diff --git a/contributing.md b/contributing.md index 7177dd2d64..94ae2b9ec3 100644 --- a/contributing.md +++ b/contributing.md @@ -121,6 +121,10 @@ Once installed, you have the option to configure the jest environment for furthe "name": "msal-react", "rootPath": "lib/msal-react" }, + { + "name": "msal-custom-auth", + "rootPath": "lib/msal-custom-auth" + }, { "name": "msal-node-extensions", "rootPath": "extensions/msal-node-extensions" diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index b65efb6c53..7f19e79cbf 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -4,21 +4,33 @@ ```ts +import { AADServerParamKeys } from '@azure/msal-common/browser'; +import { AccessTokenEntity } from '@azure/msal-common/browser'; import { AccountEntity } from '@azure/msal-common/browser'; import { AccountFilter } from '@azure/msal-common/browser'; import { AccountInfo } from '@azure/msal-common/browser'; import { ApplicationTelemetry } from '@azure/msal-common/browser'; +import { AppMetadataEntity } from '@azure/msal-common/browser'; import { AuthenticationHeaderParser } from '@azure/msal-common/browser'; import { AuthenticationResult as AuthenticationResult_2 } from '@azure/msal-common/browser'; import { AuthenticationScheme } from '@azure/msal-common/browser'; import { AuthError } from '@azure/msal-common/browser'; import { AuthErrorCodes } from '@azure/msal-common/browser'; import { AuthErrorMessage } from '@azure/msal-common/browser'; +import { Authority } from '@azure/msal-common/browser'; +import { AuthorityMetadataEntity } from '@azure/msal-common/browser'; +import { AuthorityOptions } from '@azure/msal-common/browser'; +import { AuthorizationCodeClient } from '@azure/msal-common/browser'; +import { AuthorizeResponse } from '@azure/msal-common/browser'; import { AzureCloudInstance } from '@azure/msal-common/browser'; import { AzureCloudOptions } from '@azure/msal-common/browser'; +import { BaseAuthRequest } from '@azure/msal-common/browser'; +import { CacheManager } from '@azure/msal-common/browser'; +import { CacheRecord } from '@azure/msal-common/browser'; import { ClientAuthError } from '@azure/msal-common/browser'; import { ClientAuthErrorCodes } from '@azure/msal-common/browser'; import { ClientAuthErrorMessage } from '@azure/msal-common/browser'; +import { ClientConfiguration } from '@azure/msal-common/browser'; import { ClientConfigurationError } from '@azure/msal-common/browser'; import { ClientConfigurationErrorCodes } from '@azure/msal-common/browser'; import { ClientConfigurationErrorMessage } from '@azure/msal-common/browser'; @@ -26,8 +38,12 @@ import { CommonAuthorizationCodeRequest } from '@azure/msal-common/browser'; import { CommonAuthorizationUrlRequest } from '@azure/msal-common/browser'; import { CommonEndSessionRequest } from '@azure/msal-common/browser'; import { CommonSilentFlowRequest } from '@azure/msal-common/browser'; +import { Constants } from '@azure/msal-common/browser'; +import { CredentialType } from '@azure/msal-common/browser'; import { ExternalTokenResponse } from '@azure/msal-common/browser'; +import { ICrypto } from '@azure/msal-common/browser'; import { IdTokenClaims } from '@azure/msal-common/browser'; +import { IdTokenEntity } from '@azure/msal-common/browser'; import { ILoggerCallback } from '@azure/msal-common/browser'; import { INetworkModule } from '@azure/msal-common/browser'; import { InProgressPerformanceEvent } from '@azure/msal-common/browser'; @@ -50,19 +66,33 @@ import { PerformanceCallbackFunction } from '@azure/msal-common/browser'; import { PerformanceClient } from '@azure/msal-common/browser'; import { PerformanceEvent } from '@azure/msal-common/browser'; import { PerformanceEvents } from '@azure/msal-common/browser'; +import { PkceCodes } from '@azure/msal-common/browser'; import { PromptValue } from '@azure/msal-common/browser'; import { ProtocolMode } from '@azure/msal-common/browser'; +import { RefreshTokenClient } from '@azure/msal-common/browser'; +import { RefreshTokenEntity } from '@azure/msal-common/browser'; +import { ResponseHandler } from '@azure/msal-common/browser'; import { ServerError } from '@azure/msal-common/browser'; import { ServerResponseType } from '@azure/msal-common/browser'; +import { ServerTelemetryEntity } from '@azure/msal-common/browser'; +import { ServerTelemetryManager } from '@azure/msal-common/browser'; import { SignedHttpRequestParameters } from '@azure/msal-common/browser'; +import { SilentFlowClient } from '@azure/msal-common/browser'; +import { StaticAuthorityOptions } from '@azure/msal-common/browser'; +import { StoreInCache } from '@azure/msal-common/browser'; import { StringDict } from '@azure/msal-common/browser'; import { StringUtils } from '@azure/msal-common/browser'; import { StubPerformanceClient } from '@azure/msal-common/browser'; import { SubMeasurement } from '@azure/msal-common/browser'; import { SystemOptions } from '@azure/msal-common/browser'; import { TenantProfile } from '@azure/msal-common/browser'; +import { ThrottlingEntity } from '@azure/msal-common/browser'; +import { TokenClaims } from '@azure/msal-common/browser'; +import { TokenKeys } from '@azure/msal-common/browser'; import { UrlString } from '@azure/msal-common/browser'; +export { AADServerParamKeys } + export { AccountEntity } export { AccountInfo } @@ -113,6 +143,10 @@ export { AuthErrorCodes } export { AuthErrorMessage } +export { Authority } + +export { AuthorityOptions } + // Warning: (ae-missing-release-tag) "AuthorizationCodeRequest" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -138,6 +172,35 @@ export { AzureCloudInstance } export { AzureCloudOptions } +// Warning: (ae-missing-release-tag) "BaseOperatingContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export abstract class BaseOperatingContext { + constructor(config: Configuration); + // (undocumented) + protected available: boolean; + // (undocumented) + protected browserEnvironment: boolean; + // Warning: (ae-incompatible-release-tags) The symbol "config" is marked as @public, but its signature references "BrowserConfiguration" which is marked as @internal + // + // (undocumented) + protected config: BrowserConfiguration; + // Warning: (ae-incompatible-release-tags) The symbol "getConfig" is marked as @public, but its signature references "BrowserConfiguration" which is marked as @internal + getConfig(): BrowserConfiguration; + abstract getId(): string; + getLogger(): Logger; + abstract getModuleName(): string; + abstract initialize(): Promise; + // (undocumented) + isAvailable(): boolean; + // (undocumented) + isBrowserEnvironment(): boolean; + // (undocumented) + protected logger: Logger; + // (undocumented) + protected static loggerCallback(level: LogLevel, message: string): void; +} + // Warning: (ae-missing-release-tag) "blockAcquireTokenInPopups" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -461,6 +524,142 @@ export const BrowserCacheLocation: { // @public (undocumented) export type BrowserCacheLocation = (typeof BrowserCacheLocation)[keyof typeof BrowserCacheLocation]; +// Warning: (ae-missing-release-tag) "BrowserCacheManager" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class BrowserCacheManager extends CacheManager { + constructor(clientId: string, cacheConfig: Required, cryptoImpl: ICrypto, logger: Logger, performanceClient: IPerformanceClient, eventHandler: EventHandler, staticAuthorityOptions?: StaticAuthorityOptions); + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + addAccountKeyToMap(key: string): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + addTokenKey(key: string, type: CredentialType): void; + // (undocumented) + protected browserStorage: IWindowStorage; + // (undocumented) + cacheAuthorizeRequest(authCodeRequest: CommonAuthorizationUrlRequest, codeVerifier?: string): void; + // (undocumented) + protected cacheConfig: Required; + clear(): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + clearTokensAndKeysWithClaims(performanceClient: IPerformanceClient, correlationId: string): Promise; + // Warning: (ae-forgotten-export) The symbol "CookieStorage" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected cookieStorage: CookieStorage; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + generateCacheKey(key: string): string; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAccessTokenCredential(accessTokenKey: string): AccessTokenEntity | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAccount(accountKey: string): AccountEntity | null; + getAccountKeys(): Array; + getActiveAccount(): AccountInfo | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAppMetadata(appMetadataKey: string): AppMetadataEntity | null; + // (undocumented) + getAuthorityMetadata(key: string): AuthorityMetadataEntity | null; + // (undocumented) + getAuthorityMetadataKeys(): Array; + // Warning: (ae-forgotten-export) The symbol "NativeTokenRequest" needs to be exported by the entry point index.d.ts + getCachedNativeRequest(): NativeTokenRequest | null; + getCachedRequest(): [CommonAuthorizationUrlRequest, string]; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getIdTokenCredential(idTokenKey: string): IdTokenEntity | null; + // (undocumented) + getInteractionInProgress(): string | null; + getKeys(): string[]; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getRefreshTokenCredential(refreshTokenKey: string): RefreshTokenEntity | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getServerTelemetry(serverTelemetryKey: string): ServerTelemetryEntity | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getTemporaryCache(cacheKey: string, generateKey?: boolean): string | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getThrottlingCache(throttlingCacheKey: string): ThrottlingEntity | null; + getTokenKeys(): TokenKeys; + getWrapperMetadata(): [string, string]; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + hydrateCache(result: AuthenticationResult, request: SilentRequest | SsoSilentRequest | RedirectRequest | PopupRequest): Promise; + // (undocumented) + initialize(correlationId: string): Promise; + // (undocumented) + protected internalStorage: MemoryStorage; + // (undocumented) + isInteractionInProgress(matchClientId?: boolean): boolean; + // (undocumented) + protected logger: Logger; + // (undocumented) + protected performanceClient: IPerformanceClient; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeAccessToken(key: string): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeAccount(key: string): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeAccountContext(account: AccountEntity): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeAccountKeyFromMap(key: string): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeIdToken(key: string): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeItem(key: string): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeRefreshToken(key: string): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeTemporaryItem(key: string): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeTokenKey(key: string, type: CredentialType): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + resetRequestCache(): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, storeInCache?: StoreInCache): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setAccount(account: AccountEntity, correlationId: string): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setActiveAccount(account: AccountInfo | null): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setAppMetadata(appMetadata: AppMetadataEntity): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // + // (undocumented) + setAuthorityMetadata(key: string, entity: AuthorityMetadataEntity): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setIdTokenCredential(idToken: IdTokenEntity, correlationId: string): Promise; + // (undocumented) + setInteractionInProgress(inProgress: boolean): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setServerTelemetry(serverTelemetryKey: string, serverTelemetry: ServerTelemetryEntity): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setTemporaryCache(cacheKey: string, value: string, generateKey?: boolean): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setThrottlingCache(throttlingCacheKey: string, throttlingCache: ThrottlingEntity): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setWrapperMetadata(wrapperSKU: string, wrapperVersion: string): void; + // (undocumented) + protected temporaryCacheStorage: IWindowStorage; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + protected validateAndParseJson(jsonValue: string): object | null; +} + // Warning: (ae-internal-missing-underscore) The name "BrowserConfiguration" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) @@ -653,12 +852,16 @@ export { ClientAuthErrorCodes } export { ClientAuthErrorMessage } +export { ClientConfiguration } + export { ClientConfigurationError } export { ClientConfigurationErrorCodes } export { ClientConfigurationErrorMessage } +export { CommonSilentFlowRequest } + // Warning: (ae-missing-release-tag) "Configuration" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -669,6 +872,8 @@ export type Configuration = { telemetry?: BrowserTelemetryOptions; }; +export { Constants } + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (ae-missing-release-tag) "createGuid" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -964,6 +1169,8 @@ export interface IController { ssoSilent(request: SsoSilentRequest): Promise; } +export { ICrypto } + export { IdTokenClaims } // Warning: (ae-missing-release-tag) "iframeClosedPrematurely" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1606,15 +1813,21 @@ export type RedirectRequest = Partial boolean | void; }; +export { RefreshTokenClient } + // Warning: (ae-missing-release-tag) "replaceHash" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public function replaceHash(url: string): void; +export { ResponseHandler } + export { ServerError } export { ServerResponseType } +export { ServerTelemetryManager } + // Warning: (ae-missing-release-tag) "SessionStorage" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1659,6 +1872,8 @@ export type SignedHttpRequestOptions = { loggerOptions: LoggerOptions; }; +export { SilentFlowClient } + // Warning: (ae-missing-release-tag) "silentLogoutUnsupported" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1694,6 +1909,216 @@ const spaCodeAndNativeAccountIdPresent = "spa_code_and_nativeAccountId_present"; // @public export type SsoSilentRequest = Partial>; +// Warning: (ae-missing-release-tag) "StandardController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class StandardController implements IController { + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@constructor" is not defined in this configuration + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + constructor(operatingContext: StandardOperatingContext); + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + acquireTokenByCode(request: AuthorizationCodeRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + acquireTokenByRefreshToken(commonRequest: CommonSilentFlowRequest, cacheLookupPolicy: CacheLookupPolicy): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + protected acquireTokenBySilentIframe(request: CommonSilentFlowRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + protected acquireTokenFromCache(commonRequest: CommonSilentFlowRequest, cacheLookupPolicy: CacheLookupPolicy): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + acquireTokenNative(request: PopupRequest | SilentRequest | SsoSilentRequest, apiId: ApiId, accountId?: string, cacheLookupPolicy?: CacheLookupPolicy): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + acquireTokenPopup(request: PopupRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + acquireTokenRedirect(request: RedirectRequest): Promise; + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a parameter name + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@azure/msal-browser" does not have an export "AuthResponse" + acquireTokenSilent(request: SilentRequest): Promise; + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a parameter name + // Warning: (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a parameter name + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + // Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@azure/msal-browser" does not have an export "AuthResponse" + protected acquireTokenSilentAsync(request: SilentRequest & { + correlationId: string; + }, account: AccountInfo): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + addEventCallback(callback: EventCallbackFunction, eventTypes?: Array): string | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + addPerformanceCallback(callback: PerformanceCallbackFunction): string; + // (undocumented) + protected readonly browserCrypto: ICrypto; + // (undocumented) + protected readonly browserStorage: BrowserCacheManager; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + canUsePlatformBroker(request: RedirectRequest | PopupRequest | SsoSilentRequest, accountId?: string): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + clearCache(logoutRequest?: ClearCacheRequest): Promise; + // Warning: (ae-incompatible-release-tags) The symbol "config" is marked as @public, but its signature references "BrowserConfiguration" which is marked as @internal + // + // (undocumented) + protected readonly config: BrowserConfiguration; + // (undocumented) + static createController(operatingContext: BaseOperatingContext, request?: InitializeApplicationRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (ae-forgotten-export) The symbol "PopupClient" needs to be exported by the entry point index.d.ts + createPopupClient(correlationId?: string): PopupClient; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (ae-forgotten-export) The symbol "RedirectClient" needs to be exported by the entry point index.d.ts + protected createRedirectClient(correlationId?: string): RedirectClient; + // Warning: (ae-forgotten-export) The symbol "SilentAuthCodeClient" needs to be exported by the entry point index.d.ts + protected createSilentAuthCodeClient(correlationId?: string): SilentAuthCodeClient; + // Warning: (ae-forgotten-export) The symbol "SilentCacheClient" needs to be exported by the entry point index.d.ts + protected createSilentCacheClient(correlationId?: string): SilentCacheClient; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (ae-forgotten-export) The symbol "SilentIframeClient" needs to be exported by the entry point index.d.ts + createSilentIframeClient(correlationId?: string): SilentIframeClient; + // Warning: (ae-forgotten-export) The symbol "SilentRefreshClient" needs to be exported by the entry point index.d.ts + protected createSilentRefreshClient(correlationId?: string): SilentRefreshClient; + // @deprecated + disableAccountStorageEvents(): void; + // @deprecated + enableAccountStorageEvents(): void; + // (undocumented) + protected readonly eventHandler: EventHandler; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAccount(accountFilter: AccountFilter): AccountInfo | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAccountByHomeId(homeAccountId: string): AccountInfo | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAccountByLocalId(localAccountId: string): AccountInfo | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getAccountByUsername(username: string): AccountInfo | null; + getActiveAccount(): AccountInfo | null; + getAllAccounts(accountFilter?: AccountFilter): AccountInfo[]; + // Warning: (ae-incompatible-release-tags) The symbol "getConfiguration" is marked as @public, but its signature references "BrowserConfiguration" which is marked as @internal + getConfiguration(): BrowserConfiguration; + getLogger(): Logger; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + getNativeAccountId(request: RedirectRequest | PopupRequest | SsoSilentRequest): string; + getPerformanceClient(): IPerformanceClient; + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@protected" is not defined in this configuration + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-optional-name) The @param should not include a JSDoc-style optional name; it must not be enclosed in '[ ]' brackets. + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + protected getRequestCorrelationId(request?: Partial): string; + getTokenCache(): ITokenCache; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + handleRedirectPromise(hash?: string): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + hydrateCache(result: AuthenticationResult, request: SilentRequest | SsoSilentRequest | RedirectRequest | PopupRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + initialize(request?: InitializeApplicationRequest): Promise; + // (undocumented) + protected initialized: boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + initializeWrapperLibrary(sku: WrapperSKU, version: string): void; + isBrowserEnv(): boolean; + // (undocumented) + protected isBrowserEnvironment: boolean; + // (undocumented) + protected logger: Logger; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + loginPopup(request?: PopupRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + loginRedirect(request?: RedirectRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative + // + // @deprecated + logout(logoutRequest?: EndSessionRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + logoutPopup(logoutRequest?: EndSessionPopupRequest): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + logoutRedirect(logoutRequest?: EndSessionRequest): Promise; + // Warning: (ae-forgotten-export) The symbol "NativeMessageHandler" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected nativeExtensionProvider: NativeMessageHandler | undefined; + // (undocumented) + protected readonly nativeInternalStorage: BrowserCacheManager; + // (undocumented) + protected navigationClient: INavigationClient; + // (undocumented) + protected readonly networkClient: INetworkModule; + // Warning: (ae-forgotten-export) The symbol "StandardOperatingContext" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected readonly operatingContext: StandardOperatingContext; + // (undocumented) + protected readonly performanceClient: IPerformanceClient; + // (undocumented) + protected readonly redirectResponse: Map>; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + removeEventCallback(callbackId: string): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag + // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" + removePerformanceCallback(callbackId: string): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setActiveAccount(account: AccountInfo | null): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setLogger(logger: Logger): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + setNavigationClient(navigationClient: INavigationClient): void; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + ssoSilent(request: SsoSilentRequest): Promise; +} + +// Warning: (ae-forgotten-export) The symbol "BaseInteractionClient" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "StandardInteractionClient" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export abstract class StandardInteractionClient extends BaseInteractionClient { + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + protected createAuthCodeClient(params: { + serverTelemetryManager: ServerTelemetryManager; + requestAuthority?: string; + requestAzureCloudOptions?: AzureCloudOptions; + requestExtraQueryParameters?: StringDict; + account?: AccountInfo; + }): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + protected getClientConfiguration(params: { + serverTelemetryManager: ServerTelemetryManager; + requestAuthority?: string; + requestAzureCloudOptions?: AzureCloudOptions; + requestExtraQueryParameters?: StringDict; + account?: AccountInfo; + }): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + protected getLogoutHintFromIdTokenClaims(account: AccountInfo): string | null; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + protected initializeAuthorizationRequest(request: RedirectRequest | PopupRequest | SsoSilentRequest, interactionType: InteractionType): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + protected initializeLogoutRequest(logoutRequest?: EndSessionRequest): CommonEndSessionRequest; +} + // Warning: (ae-missing-release-tag) "stateInteractionTypeMismatch" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1720,6 +2145,8 @@ export { StubPerformanceClient } export { TenantProfile } +export { TokenClaims } + // Warning: (ae-missing-release-tag) "unableToAcquireTokenFromNativePlatform" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1786,6 +2213,15 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/cache/LocalStorage.ts:354:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:385:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/config/Configuration.ts:247:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts +// src/controllers/StandardController.ts:433:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:1171:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2007:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2008:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2009:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2245:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2246:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2328:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/controllers/StandardController.ts:2344:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:113:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:139:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" diff --git a/lib/msal-browser/src/index.ts b/lib/msal-browser/src/index.ts index db8b912914..79128da507 100644 --- a/lib/msal-browser/src/index.ts +++ b/lib/msal-browser/src/index.ts @@ -18,6 +18,7 @@ export { } from "./app/PublicClientApplication.js"; export { PublicClientNext } from "./app/PublicClientNext.js"; export { IController } from "./controllers/IController.js"; +export { StandardController } from "./controllers/StandardController.js"; export { Configuration, BrowserAuthOptions, @@ -68,9 +69,13 @@ export { AuthenticationResult } from "./response/AuthenticationResult.js"; export { ClearCacheRequest } from "./request/ClearCacheRequest.js"; export { InitializeApplicationRequest } from "./request/InitializeApplicationRequest.js"; +// Operating Context +export { BaseOperatingContext } from "./operatingcontext/BaseOperatingContext.js"; + // Cache export { LoadTokenOptions } from "./cache/TokenCache.js"; export { ITokenCache } from "./cache/ITokenCache.js"; +export { BrowserCacheManager } from "./cache/BrowserCacheManager.js"; // Storage export { MemoryStorage } from "./cache/MemoryStorage.js"; @@ -78,6 +83,9 @@ export { LocalStorage } from "./cache/LocalStorage.js"; export { SessionStorage } from "./cache/SessionStorage.js"; export { IWindowStorage } from "./cache/IWindowStorage.js"; +// Interaction Client +export { StandardInteractionClient } from "./interaction_client/StandardInteractionClient.js"; + // Events export { EventMessage, @@ -112,6 +120,11 @@ export { AccountInfo, AccountEntity, IdTokenClaims, + // Client + SilentFlowClient, + RefreshTokenClient, + // Configuration + ClientConfiguration, // Error AuthError, AuthErrorCodes, @@ -140,6 +153,9 @@ export { PromptValue, // Server Response ExternalTokenResponse, + ResponseHandler, + // Request + CommonSilentFlowRequest, // Utils StringUtils, UrlString, @@ -152,11 +168,20 @@ export { PerformanceCallbackFunction, PerformanceEvent, PerformanceEvents, + // Crypto + ICrypto, // Telemetry InProgressPerformanceEvent, TenantProfile, IPerformanceClient, StubPerformanceClient, + TokenClaims, + Constants, + AADServerParamKeys, + ServerTelemetryManager, + // Authority + Authority, + AuthorityOptions, } from "@azure/msal-common/browser"; export { version } from "./packageMetadata.js"; diff --git a/lib/msal-custom-auth/.editorconfig b/lib/msal-custom-auth/.editorconfig new file mode 100644 index 0000000000..d17d9cf2e6 --- /dev/null +++ b/lib/msal-custom-auth/.editorconfig @@ -0,0 +1,20 @@ +# Editor configuration, see http://editorconfig.org +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 + +[*.json] +indent_size = 2 + +[*.md] +max_line_length = 120 +trim_trailing_whitespace = false + +[*.html] +max_line_length = 200 +trim_trailing_whitespace = false diff --git a/lib/msal-custom-auth/.eslintrc.json b/lib/msal-custom-auth/.eslintrc.json new file mode 100644 index 0000000000..314a3df01b --- /dev/null +++ b/lib/msal-custom-auth/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "eslint-config-msal" +} \ No newline at end of file diff --git a/lib/msal-custom-auth/.gitignore b/lib/msal-custom-auth/.gitignore new file mode 100644 index 0000000000..943e22e352 --- /dev/null +++ b/lib/msal-custom-auth/.gitignore @@ -0,0 +1,12 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +*.log +.DS_Store +/node_modules +.cache +/dist +/lib +/samples/lib + +# Type docs +ref/ diff --git a/lib/msal-custom-auth/CHANGELOG.json b/lib/msal-custom-auth/CHANGELOG.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/lib/msal-custom-auth/CHANGELOG.json @@ -0,0 +1 @@ +{} diff --git a/lib/msal-custom-auth/CHANGELOG.md b/lib/msal-custom-auth/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/msal-custom-auth/FAQ.md b/lib/msal-custom-auth/FAQ.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/msal-custom-auth/LICENSE b/lib/msal-custom-auth/LICENSE new file mode 100644 index 0000000000..5cf7c8db62 --- /dev/null +++ b/lib/msal-custom-auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/lib/msal-custom-auth/README.md b/lib/msal-custom-auth/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/msal-custom-auth/api-extractor.json b/lib/msal-custom-auth/api-extractor.json new file mode 100644 index 0000000000..3b086f1c6f --- /dev/null +++ b/lib/msal-custom-auth/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor-base.json" +} diff --git a/lib/msal-custom-auth/jest.config.cjs b/lib/msal-custom-auth/jest.config.cjs new file mode 100644 index 0000000000..77b1b6292f --- /dev/null +++ b/lib/msal-custom-auth/jest.config.cjs @@ -0,0 +1,8 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +const jestConfig = require("../../shared-configs/jest-config/jest.config.cjs"); + +module.exports = jestConfig; diff --git a/lib/msal-custom-auth/package.json b/lib/msal-custom-auth/package.json new file mode 100644 index 0000000000..9693bf86c7 --- /dev/null +++ b/lib/msal-custom-auth/package.json @@ -0,0 +1,98 @@ +{ + "name": "@azure/msal-custom-auth", + "version": "0.0.1", + "author": { + "name": "Microsoft", + "email": "nugetaad@microsoft.com", + "url": "https://www.microsoft.com" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/AzureAD/microsoft-authentication-library-for-js.git" + }, + "description": "Microsoft Authentication Library for Native Authentication", + "type": "module", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "main": "lib/msal-custom-auth.cjs", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./lib/types/index.d.ts", + "default": "./lib/msal-custom-auth.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "lib", + "src" + ], + "engines": { + "node": ">=10" + }, + "beachball": { + "disallowedChangeTypes": [ + "major" + ] + }, + "directories": { + "test": "test" + }, + "scripts": { + "apiExtractor": "api-extractor run", + "build:all": "cd ../.. && npm install --workspace=@azure/msal-common --workspace=@azure/msal-browser --workspace=@azure/msal-custom-auth && npm run build --workspace=@azure/msal-common --workspace=@azure/msal-browser --workspace=@azure/msal-custom-auth", + "build:modules:watch": "rollup -cw --bundleConfigAsCjs", + "build:modules": "rollup -c --strictDeprecations --bundleConfigAsCjs", + "build": "rollup -c --strictDeprecations --bundleConfigAsCjs", + "clean:coverage": "rimraf ../../.nyc_output/*", + "clean": "rimraf dist lib", + "format:check": "prettier --ignore-path .gitignore --check src test", + "format:fix": "prettier --ignore-path .gitignore --write src test", + "lint:fix": "npm run lint -- --fix", + "lint": "eslint src --ext .ts", + "prepack": "npm run build:all", + "test:coverage:only": "npm run clean:coverage && npm run test:coverage", + "test:coverage": "jest --coverage", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@azure/msal-browser": "^4.5.0" + }, + "devDependencies": { + "@azure/storage-blob": "^12.26.0", + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/preset-env": "^7.26.0", + "@babel/preset-typescript": "^7.26.0", + "@microsoft/api-extractor": "^7.48.1", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.2", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.2", + "dotenv": "^16.4.7", + "eslint-config-msal": "file:../../shared-configs/eslint-config-msal", + "fake-indexeddb": "^6.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.4.2", + "rimraf": "^5.0.10", + "rollup": "^4.29.1", + "rollup-msal": "file:../../shared-configs/rollup-msal", + "shx": "^0.3.4", + "ssri": "^12.0.0", + "ts-jest": "^29.2.5", + "ts-jest-resolver": "^2.0.1", + "tslib": "^2.8.1", + "typescript": "^5.7.2" + } +} diff --git a/lib/msal-custom-auth/rollup.config.js b/lib/msal-custom-auth/rollup.config.js new file mode 100644 index 0000000000..1a2d10c8bf --- /dev/null +++ b/lib/msal-custom-auth/rollup.config.js @@ -0,0 +1,134 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import terser from "@rollup/plugin-terser"; +import pkg from "./package.json"; +import { createPackageJson } from "rollup-msal"; + +const libraryHeader = `/*! ${pkg.name} v${pkg.version} ${new Date().toISOString().split("T")[0]} */`; +const useStrictHeader = "'use strict';"; +const fileHeader = `${libraryHeader}\n${useStrictHeader}`; + +export default [ + { + // for es build + input: "src/index.ts", + output: { + dir: "dist", + preserveModules: true, + preserveModulesRoot: "src", + format: "es", + entryFileNames: "[name].mjs", + banner: fileHeader, + sourcemap: true, + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false + }, + external: [ + ...Object.keys(pkg.dependencies || {}) + ], + plugins: [ + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.build.json" + }) + ] + }, + { + input: "src/index.ts", + output: { + dir: "lib", + format: "cjs", + entryFileNames: "msal-custom-auth.cjs", + banner: fileHeader, + sourcemap: true, + inlineDynamicImports: true, + }, + external: [ + ...Object.keys(pkg.dependencies || {}), + ], + plugins: [ + nodeResolve({ + browser: true, + resolveOnly: ["@azure/msal-browser", "tslib"], + }), + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.build.json", + compilerOptions: { outDir: "lib/types" }, + sourceMap: true + }), + createPackageJson({ libPath: __dirname}) + ] + }, + { + input: "src/index.ts", + output: [ + { + dir: "lib", + format: "umd", + name: "msal-custom-auth", + banner: fileHeader, + inlineDynamicImports: true, + sourcemap: true, + entryFileNames: "msal-custom-auth.js", + globals: { + '@azure/msal-common/browser': 'browser', + } + }, + ], + plugins: [ + nodeResolve({ + browser: true, + resolveOnly: ["@azure/msal-browser", "tslib"], + }), + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.build.json", + sourceMap: true, + compilerOptions: { outDir: "lib/types", declaration: false, declarationMap: false }, + }), + ], + }, + { + // Minified version of msal + input: "src/index.ts", + output: [ + { + dir: "lib", + format: "umd", + name: "msal-custom-auth", + entryFileNames: "msal-custom-auth.min.js", + banner: useStrictHeader, + inlineDynamicImports: true, + sourcemap: false, + globals: { + '@azure/msal-common/browser': 'browser', + } + }, + ], + plugins: [ + nodeResolve({ + browser: true, + resolveOnly: ["@azure/msal-browser", "tslib"], + }), + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.build.json", + sourceMap: false, + compilerOptions: { outDir: "lib/types", declaration: false, declarationMap: false }, + }), + terser({ + output: { + preamble: libraryHeader, + }, + }), + ], + }, +]; diff --git a/lib/msal-custom-auth/src/CustomAuthActionInputs.ts b/lib/msal-custom-auth/src/CustomAuthActionInputs.ts new file mode 100644 index 0000000000..e90c5ff1f4 --- /dev/null +++ b/lib/msal-custom-auth/src/CustomAuthActionInputs.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAccountAttributes } from "./UserAccountAttributes.js"; + +export type CustomAuthActionInputs = { + correlationId?: string; +}; + +export type AccountRetrievalInputs = CustomAuthActionInputs; + +export type SignInInputs = CustomAuthActionInputs & { + username: string; + password?: string; + scopes?: Array; +}; + +export type SignUpInputs = CustomAuthActionInputs & { + username: string; + password?: string; + attributes?: UserAccountAttributes; +}; + +export type ResetPasswordInputs = CustomAuthActionInputs & { + username: string; +}; + +export type AccessTokenRetrievalInputs = { + forceRefresh: boolean; + scopes?: Array; +}; + +export type SignInWithContinuationTokenInputs = { + scopes?: Array; +}; diff --git a/lib/msal-custom-auth/src/CustomAuthConstants.ts b/lib/msal-custom-auth/src/CustomAuthConstants.ts new file mode 100644 index 0000000000..ca7e2c785c --- /dev/null +++ b/lib/msal-custom-auth/src/CustomAuthConstants.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Constants } from "@azure/msal-browser"; +import { version } from "./packageMetadata.js"; + +export const GrantType = { + PASSWORD: "password", + OOB: "oob", + CONTINUATION_TOKEN: "continuation_token", + REDIRECT: "redirect", + ATTRIBUTES: "attributes", +} as const; + +export const ChallengeType = { + PASSWORD: "password", + OOB: "oob", + REDIRECT: "redirect", +} as const; + +export const DefaultScopes = [Constants.OPENID_SCOPE, Constants.PROFILE_SCOPE, Constants.OFFLINE_ACCESS_SCOPE] as const; + +export const HttpHeaderKeys = { + CONTENT_TYPE: "Content-Type", + X_MS_REQUEST_ID: "x-ms-request-id", +} as const; + +export const DefaultPackageInfo = { + SKU: "msal.custom.auth", + VERSION: version, + OS: "", + CPU: "", +} as const; + +export const ResetPasswordPollStatus = { + IN_PROGRESS: "in_progress", + SUCCEEDED: "succeeded", + FAILED: "failed", + NOT_STARTED: "not_started", +} as const; + +export const DefaultCustomAuthApiCodeLength = 8; +export const DefaultCustomAuthApiCodeResendIntervalInSec = 300; // seconds +export const PasswordResetPollingTimeoutInMs = 300000; // milliseconds diff --git a/lib/msal-custom-auth/src/CustomAuthPublicClientApplication.ts b/lib/msal-custom-auth/src/CustomAuthPublicClientApplication.ts new file mode 100644 index 0000000000..77c84b6d5f --- /dev/null +++ b/lib/msal-custom-auth/src/CustomAuthPublicClientApplication.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { PublicClientApplication } from "@azure/msal-browser"; +import { GetAccountResult } from "./get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "./sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "./sign_up/auth_flow/result/SignUpResult.js"; +import { ICustomAuthStandardController } from "./controller/ICustomAuthStandardController.js"; +import { CustomAuthStandardController } from "./controller/CustomAuthStandardController.js"; +import { ICustomAuthPublicClientApplication } from "./ICustomAuthPublicClientApplication.js"; +import { AccountRetrievalInputs, SignInInputs, SignUpInputs, ResetPasswordInputs } from "./CustomAuthActionInputs.js"; +import { CustomAuthConfiguration } from "./configuration/CustomAuthConfiguration.js"; +import { CustomAuthOperatingContext } from "./operating_context/CustomAuthOperatingContext.js"; +import { ResetPasswordStartResult } from "./reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { + InvalidAuthority, + InvalidChallengeType, + InvalidConfigurationError, + MissingConfiguration, +} from "./core/error/InvalidConfigurationError.js"; +import { ChallengeType } from "./CustomAuthConstants.js"; + +export class CustomAuthPublicClientApplication + extends PublicClientApplication + implements ICustomAuthPublicClientApplication +{ + private readonly customAuthController: ICustomAuthStandardController; + + /** + * Creates a new instance of a PublicClientApplication with the given configuration and controller to start Native authentication flows. + * @param {CustomAuthConfiguration} config - A configuration object for the PublicClientApplication instance + * @param {ICustomAuthStandardController} controller - A controller object for the PublicClientApplication instance + * @returns {Promise} - A promise that resolves to a CustomAuthPublicClientApplication instance + */ + static async create( + config: CustomAuthConfiguration, + controller?: ICustomAuthStandardController, + ): Promise { + CustomAuthPublicClientApplication.validateConfig(config); + + let customAuthController = controller; + + if (!customAuthController) { + customAuthController = new CustomAuthStandardController(new CustomAuthOperatingContext(config)); + + await customAuthController.initialize(); + } + + const app = new CustomAuthPublicClientApplication(config, customAuthController); + + return app; + } + + private constructor(config: CustomAuthConfiguration, controller: ICustomAuthStandardController) { + super(config, controller); + + this.customAuthController = controller; + } + + /** + * Gets the current account from the browser cache. + * @param {AccountRetrievalInputs} accountRetrievalInputs?:AccountRetrievalInputs + * @returns {GetAccountResult} - The result of the get account operation + */ + getCurrentAccount(accountRetrievalInputs?: AccountRetrievalInputs): GetAccountResult { + return this.customAuthController.getCurrentAccount(accountRetrievalInputs); + } + + /** + * Initiates the sign-in flow. + * This method results in sign-in completion, or extra actions (password, code, etc.) required to complete the sign-in. + * Create result with error details if any exception thrown. + * @param {SignInInputs} signInInputs - Inputs for the sign-in flow + * @returns {Promise} - A promise that resolves to SignInResult + */ + signIn(signInInputs: SignInInputs): Promise { + return this.customAuthController.signIn(signInInputs); + } + + /** + * Initiates the sign-up flow. + * This method results in sign-up completion, or extra actions (password, code, etc.) required to complete the sign-up. + * Create result with error details if any exception thrown. + * @param {SignUpInputs} signUpInputs + * @returns {Promise} - A promise that resolves to SignUpResult + */ + signUp(signUpInputs: SignUpInputs): Promise { + return this.customAuthController.signUp(signUpInputs); + } + + /** + * Initiates the reset password flow. + * This method results in triggering extra action (submit code) to complete the reset password. + * Create result with error details if any exception thrown. + * @param {ResetPasswordInputs} resetPasswordInputs - Inputs for the reset password flow + * @returns {Promise} - A promise that resolves to ResetPasswordStartResult + */ + resetPassword(resetPasswordInputs: ResetPasswordInputs): Promise { + return this.customAuthController.resetPassword(resetPasswordInputs); + } + + /** + * Validates the configuration to ensure it is a valid CustomAuthConfiguration object. + * @param {CustomAuthConfiguration} config - The configuration object for the PublicClientApplication. + * @returns {void} + */ + private static validateConfig(config: CustomAuthConfiguration): void { + // Ensure the configuration object has a valid CIAM authority URL. + if (!config) { + throw new InvalidConfigurationError(MissingConfiguration, "The configuration is missing."); + } + + if (!config.auth?.authority) { + throw new InvalidConfigurationError( + InvalidAuthority, + `The authority URL '${config.auth?.authority}' is not set.`, + ); + } + + const challengeTypes = config.customAuth.challengeTypes; + + if (!!challengeTypes && challengeTypes.length > 0) { + challengeTypes.forEach((challengeType) => { + const lowerCaseChallengeType = challengeType.toLowerCase(); + if ( + lowerCaseChallengeType !== ChallengeType.PASSWORD && + lowerCaseChallengeType !== ChallengeType.OOB && + lowerCaseChallengeType !== ChallengeType.REDIRECT + ) { + throw new InvalidConfigurationError( + InvalidChallengeType, + `Challenge type ${challengeType} in the configuration are not valid. Supported challenge types are ${Object.values(ChallengeType)}`, + ); + } + }); + } + } +} diff --git a/lib/msal-custom-auth/src/ICustomAuthPublicClientApplication.ts b/lib/msal-custom-auth/src/ICustomAuthPublicClientApplication.ts new file mode 100644 index 0000000000..e994c8556c --- /dev/null +++ b/lib/msal-custom-auth/src/ICustomAuthPublicClientApplication.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GetAccountResult } from "./get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "./sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "./sign_up/auth_flow/result/SignUpResult.js"; +import { AccountRetrievalInputs, ResetPasswordInputs, SignInInputs, SignUpInputs } from "./CustomAuthActionInputs.js"; +import { ResetPasswordStartResult } from "./reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { IPublicClientApplication } from "@azure/msal-browser"; + +export interface ICustomAuthPublicClientApplication extends IPublicClientApplication { + /** + * Gets the current account from the cache. + * @param {AccountRetrievalInputs} accountRetrievalInputs - Inputs for getting the current cached account + * @returns {GetAccountResult} The result of the operation + */ + getCurrentAccount(accountRetrievalInputs?: AccountRetrievalInputs): GetAccountResult; + + /** + * Initiates the sign-in flow. + * @param {SignInInputs} signInInputs - Inputs for the sign-in flow + * @returns {Promise} A promise that resolves to SignInResult + */ + signIn(signInInputs: SignInInputs): Promise; + + /** + * Initiates the sign-up flow. + * @param {SignUpInputs} signUpInputs - Inputs for the sign-up flow + * @returns {Promise} A promise that resolves to SignUpResult + */ + signUp(signUpInputs: SignUpInputs): Promise; + + /** + * Initiates the reset password flow. + * @param {ResetPasswordInputs} resetPasswordInputs - Inputs for the reset password flow + * @returns {Promise} A promise that resolves to ResetPasswordStartResult + */ + resetPassword(resetPasswordInputs: ResetPasswordInputs): Promise; +} diff --git a/lib/msal-custom-auth/src/UserAccountAttributes.ts b/lib/msal-custom-auth/src/UserAccountAttributes.ts new file mode 100644 index 0000000000..d7bb9957ed --- /dev/null +++ b/lib/msal-custom-auth/src/UserAccountAttributes.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidAttributeErrorCode, UserAccountAttributeError } from "./core/error/UserAccountAttributeError.js"; + +export class UserAccountAttributes { + private attributes: Record; + + constructor() { + this.attributes = {}; + } + + setCustomAttribute(name: string, value: string): void { + if (!name) { + throw new UserAccountAttributeError(InvalidAttributeErrorCode, name, value); + } + + this.attributes[name] = value; + } + + setCity(value: string): void { + this.attributes["city"] = value; + } + + setCountry(value: string): void { + this.attributes["country"] = value; + } + + setDisplayName(value: string): void { + this.attributes["displayName"] = value; + } + + setGivenName(value: string): void { + this.attributes["givenName"] = value; + } + + setJobTitle(value: string): void { + this.attributes["jobTitle"] = value; + } + + setPostalCode(value: string): void { + this.attributes["postalCode"] = value; + } + + setState(value: string): void { + this.attributes["state"] = value; + } + + setStreetAddress(value: string): void { + this.attributes["streetAddress"] = value; + } + + setSurname(value: string): void { + this.attributes["surname"] = value; + } + + toRecord(): Record { + return this.attributes; + } +} diff --git a/lib/msal-custom-auth/src/configuration/CustomAuthConfiguration.ts b/lib/msal-custom-auth/src/configuration/CustomAuthConfiguration.ts new file mode 100644 index 0000000000..141937d695 --- /dev/null +++ b/lib/msal-custom-auth/src/configuration/CustomAuthConfiguration.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Configuration } from "@azure/msal-browser"; +import { BrowserConfiguration } from "../../../msal-browser/lib/types/index.js"; + +export type CustomAuthOptions = { + challengeTypes?: Array; + authApiProxyUrl: string; +}; + +export type CustomAuthConfiguration = Configuration & { + customAuth: CustomAuthOptions; +}; + +export type CustomAuthBrowserConfiguration = BrowserConfiguration & { + customAuth: CustomAuthOptions; +}; diff --git a/lib/msal-custom-auth/src/controller/CustomAuthStandardController.ts b/lib/msal-custom-auth/src/controller/CustomAuthStandardController.ts new file mode 100644 index 0000000000..6cb1bd6990 --- /dev/null +++ b/lib/msal-custom-auth/src/controller/CustomAuthStandardController.ts @@ -0,0 +1,413 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { StandardController } from "@azure/msal-browser"; +import { GetAccountResult } from "../get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "../sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "../sign_up/auth_flow/result/SignUpResult.js"; +import { SignInStartParams, SignInSubmitPasswordParams } from "../sign_in/interaction_client/parameter/SignInParams.js"; +import { SignInClient } from "../sign_in/interaction_client/SignInClient.js"; +import { + AccountRetrievalInputs, + SignInInputs, + SignUpInputs, + ResetPasswordInputs, + CustomAuthActionInputs, +} from "../CustomAuthActionInputs.js"; +import { CustomAuthBrowserConfiguration } from "../configuration/CustomAuthConfiguration.js"; +import { CustomAuthOperatingContext } from "../operating_context/CustomAuthOperatingContext.js"; +import { ICustomAuthStandardController } from "./ICustomAuthStandardController.js"; +import { CustomAuthAccountData } from "../get_account/auth_flow/CustomAuthAccountData.js"; +import { UnexpectedError } from "../core/error/UnexpectedError.js"; +import { ResetPasswordStartResult } from "../reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { CustomAuthAuthority } from "../core/CustomAuthAuthority.js"; +import { DefaultPackageInfo } from "../CustomAuthConstants.js"; +import { + SignInCodeSendResult, + SignInPasswordRequiredResult, +} from "../sign_in/interaction_client/result/SignInActionResult.js"; +import { SignUpClient } from "../sign_up/interaction_client/SignUpClient.js"; +import { CustomAuthInterationClientFactory } from "../core/interaction_client/CustomAuthInterationClientFactory.js"; +import { + SignUpCodeRequiredResult, + SignUpPasswordRequiredResult, +} from "../sign_up/interaction_client/result/SignUpActionResult.js"; +import { ICustomAuthApiClient } from "../core/network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { CustomAuthApiClient } from "../core/network_client/custom_auth_api/CustomAuthApiClient.js"; +import { FetchHttpClient } from "../core/network_client/http_client/FetchHttpClient.js"; +import { ResetPasswordClient } from "../reset_password/interaction_client/ResetPasswordClient.js"; +import { NoCachedAccountFoundError } from "../core/error/NoCachedAccountFoundError.js"; +import { ArgumentValidator } from "../core/utils/ArgumentValidator.js"; +import { UserAlreadySignedInError } from "../core/error/UserAlreadySignedInError.js"; +import { CustomAuthSilentCacheClient } from "../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { UnsupportedEnvironmentError } from "../core/error/UnsupportedEnvironmentError.js"; +import { SignInCodeRequiredState } from "../sign_in/auth_flow/state/SignInCodeRequiredState.js"; +import { SignInPasswordRequiredState } from "../sign_in/auth_flow/state/SignInPasswordRequiredState.js"; +import { SignInCompletedState } from "../sign_in/auth_flow/state/SignInCompletedState.js"; +import { SignUpCodeRequiredState } from "../sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +import { SignUpPasswordRequiredState } from "../sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +import { ResetPasswordCodeRequiredState } from "../reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; + +/* + * Controller for standard native auth operations. + */ +export class CustomAuthStandardController extends StandardController implements ICustomAuthStandardController { + private readonly signInClient: SignInClient; + private readonly signUpClient: SignUpClient; + private readonly resetPasswordClient: ResetPasswordClient; + private readonly cacheClient: CustomAuthSilentCacheClient; + private readonly customAuthConfig: CustomAuthBrowserConfiguration; + private readonly authority: CustomAuthAuthority; + + /* + * Constructor for CustomAuthStandardController. + * @param operatingContext - The operating context for the controller. + * @param customAuthApiClient - The client to use for custom auth API operations. + */ + constructor(operatingContext: CustomAuthOperatingContext, customAuthApiClient?: ICustomAuthApiClient) { + super(operatingContext); + + if (!this.isBrowserEnvironment) { + this.logger.verbose("The SDK can only be used in a browser environment."); + throw new UnsupportedEnvironmentError(); + } + + this.logger = this.logger.clone(DefaultPackageInfo.SKU, DefaultPackageInfo.VERSION); + this.customAuthConfig = operatingContext.getCustomAuthConfig(); + + this.authority = new CustomAuthAuthority( + this.customAuthConfig.auth.authority, + this.customAuthConfig, + this.networkClient, + this.browserStorage, + this.logger, + this.customAuthConfig.customAuth?.authApiProxyUrl, + ); + + const interactionClientFactory = new CustomAuthInterationClientFactory( + this.customAuthConfig, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + customAuthApiClient ?? + new CustomAuthApiClient( + this.authority.getCustomAuthApiDomain(), + this.customAuthConfig.auth.clientId, + new FetchHttpClient(this.logger), + ), + this.authority, + ); + + this.signInClient = interactionClientFactory.create(SignInClient); + this.signUpClient = interactionClientFactory.create(SignUpClient); + this.resetPasswordClient = interactionClientFactory.create(ResetPasswordClient); + this.cacheClient = interactionClientFactory.create(CustomAuthSilentCacheClient); + } + + /* + * Gets the current account from the cache. + * @param accountRetrievalInputs - Inputs for getting the current cached account + * @returns {GetAccountResult} The account result + */ + getCurrentAccount(accountRetrievalInputs?: AccountRetrievalInputs): GetAccountResult { + const correlationId = this.getCorrelationId(accountRetrievalInputs); + try { + this.logger.verbose("Getting current account data.", correlationId); + + const account = this.cacheClient.getCurrentAccount(correlationId); + + if (account) { + this.logger.verbose("Account data found.", correlationId); + + return new GetAccountResult( + new CustomAuthAccountData( + account, + this.customAuthConfig, + this.cacheClient, + this.logger, + correlationId, + ), + ); + } + + throw new NoCachedAccountFoundError(correlationId); + } catch (error) { + this.logger.errorPii(`An error occurred during getting current account: ${error}`, correlationId); + + return GetAccountResult.createWithError(error); + } + } + + /* + * Signs the user in. + * @param signInInputs - Inputs for signing in the user. + * @returns {Promise} The result of the operation. + */ + async signIn(signInInputs: SignInInputs): Promise { + const correlationId = this.getCorrelationId(signInInputs); + + try { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("signInInputs", signInInputs, correlationId); + + ArgumentValidator.ensureArgumentIsNotEmptyString( + "signInInputs.username", + signInInputs.username, + correlationId, + ); + this.ensureUserNotSignedIn(correlationId); + + // start the signin flow + const signInStartParams: SignInStartParams = { + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: this.customAuthConfig.customAuth.challengeTypes ?? [], + username: signInInputs.username, + password: signInInputs.password, + }; + + this.logger.verbose( + `Starting sign-in flow ${!!signInInputs.password ? "with" : "without"} password.`, + correlationId, + ); + + const startResult = await this.signInClient.start(signInStartParams); + + this.logger.verbose("Sign-in flow started.", correlationId); + + if (startResult instanceof SignInCodeSendResult) { + // require code + this.logger.verbose("Code required for sign-in.", correlationId); + + return new SignInResult( + new SignInCodeRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + cacheClient: this.cacheClient, + username: signInInputs.username, + codeLength: startResult.codeLength, + scopes: signInInputs.scopes ?? [], + }), + ); + } else if (startResult instanceof SignInPasswordRequiredResult) { + // require password + this.logger.verbose("Password required for sign-in.", correlationId); + + if (!signInInputs.password) { + this.logger.verbose( + "Password required but not provided. Returning password required state.", + correlationId, + ); + + return new SignInResult( + new SignInPasswordRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + cacheClient: this.cacheClient, + username: signInInputs.username, + scopes: signInInputs.scopes ?? [], + }), + ); + } + + this.logger.verbose("Submitting password for sign-in.", correlationId); + + // if the password is provided, then try to get token silently. + const submitPasswordParams: SignInSubmitPasswordParams = { + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: this.customAuthConfig.customAuth.challengeTypes ?? [], + scopes: signInInputs.scopes ?? [], + continuationToken: startResult.continuationToken, + password: signInInputs.password, + username: signInInputs.username, + }; + + const completedResult = await this.signInClient.submitPassword(submitPasswordParams); + + this.logger.verbose("Sign-in flow completed.", correlationId); + + const accountInfo = new CustomAuthAccountData( + completedResult.authenticationResult.account, + this.customAuthConfig, + this.cacheClient, + this.logger, + correlationId, + ); + + return new SignInResult(new SignInCompletedState(), accountInfo); + } + + this.logger.error("Unexpected sign-in result type. Returning error.", correlationId); + + throw new UnexpectedError("Unknow sign-in result type", correlationId); + } catch (error) { + this.logger.errorPii(`An error occurred during starting sign-in: ${error}`, correlationId); + + return SignInResult.createWithError(error); + } + } + + /* + * Signs the user up. + * @param signUpInputs - Inputs for signing up the user. + * @returns {Promise} The result of the operation + */ + async signUp(signUpInputs: SignUpInputs): Promise { + const correlationId = this.getCorrelationId(signUpInputs); + + try { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("signUpInputs", signUpInputs, correlationId); + + ArgumentValidator.ensureArgumentIsNotEmptyString( + "signUpInputs.username", + signUpInputs.username, + correlationId, + ); + this.ensureUserNotSignedIn(correlationId); + + this.logger.verbose( + `Starting sign-up flow${ + !!signUpInputs.password + ? ` with ${!!signUpInputs.attributes ? "password and attributes" : "password"}` + : "" + }.`, + correlationId, + ); + + const startResult = await this.signUpClient.start({ + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: this.customAuthConfig.customAuth.challengeTypes ?? [], + username: signUpInputs.username, + password: signUpInputs.password, + attributes: signUpInputs.attributes?.toRecord(), + }); + + this.logger.verbose("Sign-up flow started.", correlationId); + + if (startResult instanceof SignUpCodeRequiredResult) { + // Code required + this.logger.verbose("Code required for sign-up.", correlationId); + + return new SignUpResult( + new SignUpCodeRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + signUpClient: this.signUpClient, + cacheClient: this.cacheClient, + username: signUpInputs.username, + codeLength: startResult.codeLength, + codeResendInterval: startResult.interval, + }), + ); + } else if (startResult instanceof SignUpPasswordRequiredResult) { + // Password required + this.logger.verbose("Password required for sign-up.", correlationId); + + return new SignUpResult( + new SignUpPasswordRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + signUpClient: this.signUpClient, + cacheClient: this.cacheClient, + username: signUpInputs.username, + }), + ); + } + + this.logger.error("Unexpected sign-up result type. Returning error.", correlationId); + + throw new UnexpectedError("Unknown sign-up result type", correlationId); + } catch (error) { + this.logger.errorPii(`An error occurred during starting sign-up: ${error}`, correlationId); + + return SignUpResult.createWithError(error); + } + } + + /* + * Resets the user's password. + * @param resetPasswordInputs - Inputs for resetting the user's password. + * @returns {Promise} The result of the operation. + */ + async resetPassword(resetPasswordInputs: ResetPasswordInputs): Promise { + const correlationId = this.getCorrelationId(resetPasswordInputs); + + try { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "resetPasswordInputs", + resetPasswordInputs, + correlationId, + ); + + ArgumentValidator.ensureArgumentIsNotEmptyString( + "resetPasswordInputs.username", + resetPasswordInputs.username, + correlationId, + ); + this.ensureUserNotSignedIn(correlationId); + + this.logger.verbose("Starting password-reset flow.", correlationId); + + const startResult = await this.resetPasswordClient.start({ + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: this.customAuthConfig.customAuth.challengeTypes ?? [], + username: resetPasswordInputs.username, + }); + + this.logger.verbose("Password-reset flow started.", correlationId); + + return new ResetPasswordStartResult( + new ResetPasswordCodeRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + resetPasswordClient: this.resetPasswordClient, + cacheClient: this.cacheClient, + username: resetPasswordInputs.username, + codeLength: startResult.codeLength, + }), + ); + } catch (error) { + this.logger.errorPii(`An error occurred during starting reset-password: ${error}`, correlationId); + + return ResetPasswordStartResult.createWithError(error); + } + } + + private getCorrelationId(actionInputs: CustomAuthActionInputs | undefined): string { + return actionInputs?.correlationId || this.browserCrypto.createNewGuid(); + } + + private ensureUserNotSignedIn(correlationId: string): void { + const account = this.getCurrentAccount({ + correlationId: correlationId, + }); + + if (account && !!account.data) { + this.logger.error("User has already signed in.", correlationId); + + throw new UserAlreadySignedInError(correlationId); + } + } +} diff --git a/lib/msal-custom-auth/src/controller/ICustomAuthStandardController.ts b/lib/msal-custom-auth/src/controller/ICustomAuthStandardController.ts new file mode 100644 index 0000000000..593820fe05 --- /dev/null +++ b/lib/msal-custom-auth/src/controller/ICustomAuthStandardController.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { IController } from "@azure/msal-browser"; +import { GetAccountResult } from "../get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "../sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "../sign_up/auth_flow/result/SignUpResult.js"; +import { AccountRetrievalInputs, ResetPasswordInputs, SignInInputs, SignUpInputs } from "../CustomAuthActionInputs.js"; +import { ResetPasswordStartResult } from "../reset_password/auth_flow/result/ResetPasswordStartResult.js"; + +/* + * Controller interface for standard authentication operations. + */ +export interface ICustomAuthStandardController extends IController { + /* + * Gets the current account from the cache. + * @param accountRetrievalInputs - Inputs for getting the current cached account + * @returns - The result of the operation + */ + getCurrentAccount(accountRetrievalInputs?: AccountRetrievalInputs): GetAccountResult; + + /* + * Signs the current user out. + * @param signInInputs - Inputs for signing in. + * @returns The result of the operation. + */ + signIn(signInInputs: SignInInputs): Promise; + + /* + * Signs the current user up. + * @param signUpInputs - Inputs for signing up. + * @returns The result of the operation. + */ + signUp(signUpInputs: SignUpInputs): Promise; + + /* + * Resets the password for the current user. + * @param resetPasswordInputs - Inputs for resetting the password. + * @returns The result of the operation. + */ + resetPassword(resetPasswordInputs: ResetPasswordInputs): Promise; +} diff --git a/lib/msal-custom-auth/src/core/CustomAuthAuthority.ts b/lib/msal-custom-auth/src/core/CustomAuthAuthority.ts new file mode 100644 index 0000000000..d30c9cef9a --- /dev/null +++ b/lib/msal-custom-auth/src/core/CustomAuthAuthority.ts @@ -0,0 +1,91 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Authority, AuthorityOptions, BrowserConfiguration, INetworkModule, Logger } from "@azure/msal-browser"; +import { ICacheManager } from "../../../msal-common/dist/cache/interface/ICacheManager.js"; +import { CustomAuthApiEndpoint } from "./network_client/custom_auth_api/CustomAuthApiEndpoint.js"; +import { UrlUtils } from "./utils/UrlUtils.js"; + +/** + * Authority class which can be used to create an authority object for Custom Auth features. + */ +export class CustomAuthAuthority extends Authority { + /** + * Constructor for the Custom Auth Authority. + * @param authority - The authority URL for the authority. + * @param networkInterface - The network interface implementation to make requests. + * @param cacheManager - The cache manager interface implementation to interact with the cache. + * @param authorityOptions - The options for the authority. + * @param logger - The logger for the authority. + * @param customAuthProxyDomain - The custom auth proxy domain. + */ + constructor( + authority: string, + config: BrowserConfiguration, + networkInterface: INetworkModule, + cacheManager: ICacheManager, + logger: Logger, + private customAuthProxyDomain?: string, + ) { + const ciamAuthorityUrl = CustomAuthAuthority.transformCIAMAuthority(authority); + + const authorityOptions: AuthorityOptions = { + protocolMode: config.auth.protocolMode, + OIDCOptions: config.auth.OIDCOptions, + knownAuthorities: config.auth.knownAuthorities, + cloudDiscoveryMetadata: config.auth.cloudDiscoveryMetadata, + authorityMetadata: config.auth.authorityMetadata, + skipAuthorityMetadataCache: config.auth.skipAuthorityMetadataCache, + }; + + super(ciamAuthorityUrl, networkInterface, cacheManager, authorityOptions, logger, ""); + + // Set the metadata for the authority + const metadataEntity = { + aliases: [this.hostnameAndPort], + preferred_cache: this.getPreferredCache(), + preferred_network: this.hostnameAndPort, + canonical_authority: this.canonicalAuthority, + authorization_endpoint: "", + token_endpoint: this.tokenEndpoint, + end_session_endpoint: "", + issuer: "", + aliasesFromNetwork: false, + endpointsFromNetwork: false, + /* + * give max value to make sure it doesn't expire, + * as we only initiate the authority metadata entity once and it doesn't change + */ + expiresAt: Number.MAX_SAFE_INTEGER, + jwks_uri: "", + }; + const cacheKey = this.cacheManager.generateAuthorityMetadataCacheKey(metadataEntity.preferred_cache); + cacheManager.setAuthorityMetadata(cacheKey, metadataEntity); + } + + /** + * Gets the custom auth endpoint. + * The open id configuration doesn't have the correct endpoint for the auth APIs. + * We need to generate the endpoint manually based on the authority URL. + * @returns The custom auth endpoint + */ + getCustomAuthApiDomain(): string { + /* + * The customAuthProxyDomain is used to resolve the CORS issue when calling the auth APIs. + * If the customAuthProxyDomain is not provided, we will generate the auth API domain based on the authority URL. + */ + return !this.customAuthProxyDomain ? this.canonicalAuthority : this.customAuthProxyDomain; + } + + override getPreferredCache(): string { + return this.canonicalAuthorityUrlComponents.HostNameAndPort; + } + + override get tokenEndpoint(): string { + const endpointUrl = UrlUtils.buildUrl(this.getCustomAuthApiDomain(), CustomAuthApiEndpoint.SIGNIN_TOKEN); + + return endpointUrl.href; + } +} diff --git a/lib/msal-custom-auth/src/core/auth_flow/AuthFlowErrorBase.ts b/lib/msal-custom-auth/src/core/auth_flow/AuthFlowErrorBase.ts new file mode 100644 index 0000000000..9ef888f50e --- /dev/null +++ b/lib/msal-custom-auth/src/core/auth_flow/AuthFlowErrorBase.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthApiError, RedirectError } from "../error/CustomAuthApiError.js"; +import { CustomAuthError } from "../error/CustomAuthError.js"; +import { NoCachedAccountFoundError } from "../error/NoCachedAccountFoundError.js"; +import { InvalidArgumentError } from "../error/InvalidArgumentError.js"; +import { + CustomAuthApiErrorCode, + CustomAuthApiSuberror, +} from "../network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +/** + * Base class for all auth flow errors. + */ +export class AuthFlowErrorBase { + constructor(public errorData: CustomAuthError) {} + + protected isUserNotFoundError(): boolean { + return this.errorData.error === CustomAuthApiErrorCode.USER_NOT_FOUND; + } + + protected isUserInvalidError(): boolean { + return ( + (this.errorData instanceof InvalidArgumentError && this.errorData.errorDescription?.includes("username")) || + (this.errorData instanceof CustomAuthApiError && + !!this.errorData.errorDescription?.includes("username parameter is empty or not valid") && + !!this.errorData.errorCodes?.includes(90100)) + ); + } + + protected isUnsupportedChallengeTypeError(): boolean { + return ( + (this.errorData.error === CustomAuthApiErrorCode.INVALID_REQUEST && + (this.errorData.errorDescription?.includes( + "The challenge_type list parameter contains an unsupported challenge type", + ) ?? + false)) || + this.errorData.error === CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE + ); + } + + protected isPasswordIncorrectError(): boolean { + const isIncorrectPassword = + this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + this.errorData instanceof CustomAuthApiError && + (this.errorData.errorCodes ?? []).includes(50126); + + const isPasswordEmpty = + this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("password") === true; + + return isIncorrectPassword || isPasswordEmpty; + } + + protected isInvalidCodeError(): boolean { + return ( + (this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + this.errorData instanceof CustomAuthApiError && + this.errorData.subError === CustomAuthApiSuberror.INVALID_OOB_VALUE) || + (this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("code") === true) + ); + } + + protected isRedirectError(): boolean { + return this.errorData instanceof RedirectError; + } + + protected isInvalidNewPasswordError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + [ + CustomAuthApiSuberror.PASSWORD_BANNED, + CustomAuthApiSuberror.PASSWORD_IS_INVALID, + CustomAuthApiSuberror.PASSWORD_RECENTLY_USED, + CustomAuthApiSuberror.PASSWORD_TOO_LONG, + CustomAuthApiSuberror.PASSWORD_TOO_SHORT, + CustomAuthApiSuberror.PASSWORD_TOO_WEAK, + ].includes(this.errorData.subError ?? "") + ); + } + + protected isUserAlreadyExistsError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.USER_ALREADY_EXISTS + ); + } + + protected isAttributeRequiredError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED + ); + } + + protected isAttributeValidationFailedError(): boolean { + return ( + (this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + this.errorData.subError === CustomAuthApiSuberror.ATTRIBUTE_VALIATION_FAILED) || + (this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("attributes") === true) + ); + } + + protected isNoCachedAccountFoundError(): boolean { + return this.errorData instanceof NoCachedAccountFoundError; + } +} diff --git a/lib/msal-custom-auth/src/core/auth_flow/AuthFlowResultBase.ts b/lib/msal-custom-auth/src/core/auth_flow/AuthFlowResultBase.ts new file mode 100644 index 0000000000..adc7723b5a --- /dev/null +++ b/lib/msal-custom-auth/src/core/auth_flow/AuthFlowResultBase.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthError } from "@azure/msal-browser"; +import { CustomAuthError } from "../error/CustomAuthError.js"; +import { MsalCustomAuthError } from "../error/MsalCustomAuthError.js"; +import { UnexpectedError } from "../error/UnexpectedError.js"; +import { AuthFlowErrorBase } from "./AuthFlowErrorBase.js"; +import { AuthFlowStateBase } from "./AuthFlowState.js"; + +/* + * Base class for a result of an authentication operation. + * @typeParam TState - The type of the auth flow state. + * @typeParam TError - The type of error. + * @typeParam TData - The type of the result data. + */ +export abstract class AuthFlowResultBase< + TState extends AuthFlowStateBase, + TError extends AuthFlowErrorBase, + TData = void, +> { + /* + *constructor for ResultBase + * @param state - The state. + * @param data - The result data. + */ + constructor( + public state: TState, + public data?: TData, + ) {} + + /* + * The error that occurred during the authentication operation. + */ + error?: TError; + + /* + * Creates a CustomAuthError with an error. + * @param error - The error that occurred. + * @returns The auth error. + */ + protected static createErrorData(error: unknown): CustomAuthError { + if (error instanceof CustomAuthError) { + return error; + } else if (error instanceof AuthError) { + return new MsalCustomAuthError(error.errorCode, error.errorMessage, error.subError, error.correlationId); + } else { + return new UnexpectedError(error); + } + } +} diff --git a/lib/msal-custom-auth/src/core/auth_flow/AuthFlowState.ts b/lib/msal-custom-auth/src/core/auth_flow/AuthFlowState.ts new file mode 100644 index 0000000000..311e88e2ba --- /dev/null +++ b/lib/msal-custom-auth/src/core/auth_flow/AuthFlowState.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Logger } from "@azure/msal-browser"; +import { ArgumentValidator } from "../utils/ArgumentValidator.js"; +import { InvalidArgumentError } from "../error/InvalidArgumentError.js"; +import { CustomAuthBrowserConfiguration } from "../../configuration/CustomAuthConfiguration.js"; + +export interface AuthFlowActionRequiredStateParameters { + correlationId: string; + logger: Logger; + config: CustomAuthBrowserConfiguration; + continuationToken?: string; +} + +/** + * Base class for the state of an authentication flow. + */ +export abstract class AuthFlowStateBase {} + +/** + * Base class for the action requried state in an authentication flow. + */ +export abstract class AuthFlowActionRequiredStateBase< + TParameter extends AuthFlowActionRequiredStateParameters, +> extends AuthFlowStateBase { + /** + * Creates a new instance of AuthFlowActionRequiredStateBase. + * @param stateParameters The parameters for the auth state. + */ + protected constructor(protected readonly stateParameters: TParameter) { + ArgumentValidator.ensureArgumentIsNotEmptyString("correlationId", stateParameters.correlationId); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "logger", + stateParameters.logger, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "config", + stateParameters.config, + stateParameters.correlationId, + ); + + super(); + } + + protected ensureCodeIsValid(code: string, codeLength: number): void { + if (!code || code.length !== codeLength) { + this.stateParameters.logger.error( + "Code parameter is not provided or invalid for authentication flow.", + this.stateParameters.correlationId, + ); + + throw new InvalidArgumentError("code", this.stateParameters.correlationId); + } + } + + protected ensurePasswordIsNotEmpty(password: string): void { + if (!password) { + this.stateParameters.logger.error( + "Password parameter is not provided for authentication flow.", + this.stateParameters.correlationId, + ); + + throw new InvalidArgumentError("password", this.stateParameters.correlationId); + } + } +} diff --git a/lib/msal-custom-auth/src/core/error/CustomAuthApiError.ts b/lib/msal-custom-auth/src/core/error/CustomAuthApiError.ts new file mode 100644 index 0000000000..857492532b --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/CustomAuthApiError.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAttribute } from "../network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { CustomAuthError } from "./CustomAuthError.js"; + +/** + * Error when no required authentication method by Microsoft Entra is supported + */ +export class RedirectError extends CustomAuthError { + constructor(correlationId?: string) { + super( + "redirect", + "No required authentication method by Microsoft Entra is supported, a fallback to the web-based authentication flow is needed.", + correlationId, + ); + Object.setPrototypeOf(this, RedirectError.prototype); + } +} + +/** + * Custom Auth API error. + */ +export class CustomAuthApiError extends CustomAuthError { + constructor( + error: string, + errorDescription: string, + correlationId?: string, + public errorCodes?: Array, + public subError?: string, + public attributes?: Array, + public continuationToken?: string, + public traceId?: string, + public timestamp?: string, + ) { + super(error, errorDescription, correlationId); + Object.setPrototypeOf(this, CustomAuthApiError.prototype); + + this.errorCodes = errorCodes ?? []; + this.subError = subError ?? ""; + } +} diff --git a/lib/msal-custom-auth/src/core/error/CustomAuthError.ts b/lib/msal-custom-auth/src/core/error/CustomAuthError.ts new file mode 100644 index 0000000000..1a445cf712 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/CustomAuthError.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export class CustomAuthError extends Error { + constructor( + public error: string, + public errorDescription?: string, + public correlationId?: string, + ) { + super(`${error}: ${errorDescription ?? ""}`); + Object.setPrototypeOf(this, CustomAuthError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/error/HttpError.ts b/lib/msal-custom-auth/src/core/error/HttpError.ts new file mode 100644 index 0000000000..4c586f9306 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/HttpError.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class HttpError extends CustomAuthError { + constructor(error: string, message: string, correlationId?: string) { + super(error, message, correlationId); + Object.setPrototypeOf(this, HttpError.prototype); + } +} + +export const NoNetworkConnectivity = "no_network_connectivity"; +export const FailedSendRequest = "failed_send_request"; diff --git a/lib/msal-custom-auth/src/core/error/InvalidArgumentError.ts b/lib/msal-custom-auth/src/core/error/InvalidArgumentError.ts new file mode 100644 index 0000000000..7ba7ce0cab --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/InvalidArgumentError.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class InvalidArgumentError extends CustomAuthError { + constructor(argName: string, correlationId?: string) { + const errorDescription = `The argument '${argName}' is invalid.`; + + super("invalid_argument", errorDescription, correlationId); + Object.setPrototypeOf(this, InvalidArgumentError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/error/InvalidConfigurationError.ts b/lib/msal-custom-auth/src/core/error/InvalidConfigurationError.ts new file mode 100644 index 0000000000..b8301081fa --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/InvalidConfigurationError.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class InvalidConfigurationError extends CustomAuthError { + constructor(error: string, message: string, correlationId?: string) { + super(error, message, correlationId); + Object.setPrototypeOf(this, InvalidConfigurationError.prototype); + } +} + +export const MissingConfiguration = "missing_configuration"; +export const InvalidAuthority = "invalid_authority"; +export const InvalidAuthApiProxyDomain = "invalid_auth_api_proxy_domain"; +export const InvalidChallengeType = "invalid_challenge_type"; diff --git a/lib/msal-custom-auth/src/core/error/MethodNotImplementedError.ts b/lib/msal-custom-auth/src/core/error/MethodNotImplementedError.ts new file mode 100644 index 0000000000..05f24ec0ca --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/MethodNotImplementedError.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class MethodNotImplementedError extends CustomAuthError { + constructor(method: string, correlationId?: string) { + const errorDescription = `The method '${method}' is not implemented, please do not use.`; + + super("method_not_implemented", errorDescription, correlationId); + Object.setPrototypeOf(this, MethodNotImplementedError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/error/MsalCustomAuthError.ts b/lib/msal-custom-auth/src/core/error/MsalCustomAuthError.ts new file mode 100644 index 0000000000..0a89a1bde6 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/MsalCustomAuthError.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Constants } from "@azure/msal-browser"; +import { CustomAuthError } from "./CustomAuthError.js"; + +export class MsalCustomAuthError extends CustomAuthError { + subError: string | undefined; + + constructor(error: string, errorDescription?: string, subError?: string, correlationId?: string) { + super(error, errorDescription, correlationId); + Object.setPrototypeOf(this, MsalCustomAuthError.prototype); + + this.subError = subError || Constants.EMPTY_STRING; + } +} diff --git a/lib/msal-custom-auth/src/core/error/NoCachedAccountFoundError.ts b/lib/msal-custom-auth/src/core/error/NoCachedAccountFoundError.ts new file mode 100644 index 0000000000..3be1235424 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/NoCachedAccountFoundError.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class NoCachedAccountFoundError extends CustomAuthError { + constructor(correlationId?: string) { + super("no_cached_account_found", "No account found in the cache", correlationId); + Object.setPrototypeOf(this, NoCachedAccountFoundError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/error/ParsedUrlError.ts b/lib/msal-custom-auth/src/core/error/ParsedUrlError.ts new file mode 100644 index 0000000000..5bd05f7b2f --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/ParsedUrlError.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class ParsedUrlError extends CustomAuthError { + constructor(error: string, message: string, correlationId?: string) { + super(error, message, correlationId); + Object.setPrototypeOf(this, ParsedUrlError.prototype); + } +} + +export const UnsecureUrl = "unsecure_url"; +export const InvalidUrl = "invalid_url"; diff --git a/lib/msal-custom-auth/src/core/error/UnexpectedError.ts b/lib/msal-custom-auth/src/core/error/UnexpectedError.ts new file mode 100644 index 0000000000..d84c6a1e36 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/UnexpectedError.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UnexpectedError extends CustomAuthError { + constructor(errorData: unknown, correlationId?: string) { + let errorDescription: string; + + if (errorData instanceof Error) { + errorDescription = errorData.message; + } else if (typeof errorData === "string") { + errorDescription = errorData; + } else if (typeof errorData === "object" && errorData !== null) { + errorDescription = JSON.stringify(errorData); + } else { + errorDescription = "An unexpected error occurred."; + } + + super("unexpected_error", errorDescription, correlationId); + Object.setPrototypeOf(this, UnexpectedError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/error/UnsupportedEnvironmentError.ts b/lib/msal-custom-auth/src/core/error/UnsupportedEnvironmentError.ts new file mode 100644 index 0000000000..9984156bff --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/UnsupportedEnvironmentError.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UnsupportedEnvironmentError extends CustomAuthError { + constructor(correlationId?: string) { + super("unsupported_env", "The current environment is not browser", correlationId); + Object.setPrototypeOf(this, UnsupportedEnvironmentError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/error/UserAccountAttributeError.ts b/lib/msal-custom-auth/src/core/error/UserAccountAttributeError.ts new file mode 100644 index 0000000000..2c4a7409d8 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/UserAccountAttributeError.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UserAccountAttributeError extends CustomAuthError { + constructor(error: string, attributeName: string, attributeValue: string) { + const errorDescription = `Failed to set attribute '${attributeName}' with value '${attributeValue}'`; + + super(error, errorDescription); + Object.setPrototypeOf(this, UserAccountAttributeError.prototype); + } +} + +export const InvalidAttributeErrorCode = "invalid_attribute"; diff --git a/lib/msal-custom-auth/src/core/error/UserAlreadySignedInError.ts b/lib/msal-custom-auth/src/core/error/UserAlreadySignedInError.ts new file mode 100644 index 0000000000..511289da81 --- /dev/null +++ b/lib/msal-custom-auth/src/core/error/UserAlreadySignedInError.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UserAlreadySignedInError extends CustomAuthError { + constructor(correlationId?: string) { + super("user_already_signed_in", "The user has already signed in.", correlationId); + Object.setPrototypeOf(this, UserAlreadySignedInError.prototype); + } +} diff --git a/lib/msal-custom-auth/src/core/interaction_client/CustomAuthInteractionClientBase.ts b/lib/msal-custom-auth/src/core/interaction_client/CustomAuthInteractionClientBase.ts new file mode 100644 index 0000000000..ee3f2c880f --- /dev/null +++ b/lib/msal-custom-auth/src/core/interaction_client/CustomAuthInteractionClientBase.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AuthenticationResult, + BrowserCacheManager, + BrowserConfiguration, + ClearCacheRequest, + Constants, + EndSessionRequest, + EventHandler, + ICrypto, + INavigationClient, + IPerformanceClient, + Logger, + PopupRequest, + RedirectRequest, + SsoSilentRequest, + StandardInteractionClient, +} from "@azure/msal-browser"; +import { ICustomAuthApiClient } from "../network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { ArgumentValidator } from "../utils/ArgumentValidator.js"; +import { MethodNotImplementedError } from "../error/MethodNotImplementedError.js"; +import { CustomAuthAuthority } from "../CustomAuthAuthority.js"; +import { ChallengeType } from "../../CustomAuthConstants.js"; + +export abstract class CustomAuthInteractionClientBase extends StandardInteractionClient { + constructor( + config: BrowserConfiguration, + storageImpl: BrowserCacheManager, + browserCrypto: ICrypto, + logger: Logger, + eventHandler: EventHandler, + navigationClient: INavigationClient, + performanceClient: IPerformanceClient, + protected customAuthApiClient: ICustomAuthApiClient, + protected customAuthAuthority: CustomAuthAuthority, + ) { + super(config, storageImpl, browserCrypto, logger, eventHandler, navigationClient, performanceClient); + + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "customAuthApiClient", + customAuthApiClient, + this.correlationId, + ); + + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "customAuthAuthority", + customAuthAuthority, + this.correlationId, + ); + } + + protected getChallengeTypes(configuredChallengeTypes: string[] | undefined): string { + const challengeType = configuredChallengeTypes ?? []; + if (!challengeType.some((type) => type.toLowerCase() === ChallengeType.REDIRECT)) { + challengeType.push(ChallengeType.REDIRECT); + } + return challengeType.join(" "); + } + + protected getScopes(scopes: string[] | undefined): string[] { + if (!!scopes && scopes.length > 0) { + scopes; + } + + return [Constants.OPENID_SCOPE, Constants.PROFILE_SCOPE, Constants.OFFLINE_ACCESS_SCOPE]; + } + + // It is not necessary to implement this method from base class. + acquireToken( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: RedirectRequest | PopupRequest | SsoSilentRequest, + ): Promise { + throw new MethodNotImplementedError("SignInClient.acquireToken"); + } + + // It is not necessary to implement this method from base class. + logout( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: EndSessionRequest | ClearCacheRequest | undefined, + ): Promise { + throw new MethodNotImplementedError("SignInClient.logout"); + } +} diff --git a/lib/msal-custom-auth/src/core/interaction_client/CustomAuthInterationClientFactory.ts b/lib/msal-custom-auth/src/core/interaction_client/CustomAuthInterationClientFactory.ts new file mode 100644 index 0000000000..ea0a16e22c --- /dev/null +++ b/lib/msal-custom-auth/src/core/interaction_client/CustomAuthInterationClientFactory.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + BrowserCacheManager, + BrowserConfiguration, + EventHandler, + ICrypto, + INavigationClient, + IPerformanceClient, + Logger, +} from "@azure/msal-browser"; +import { ICustomAuthApiClient } from "../network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { CustomAuthAuthority } from "../CustomAuthAuthority.js"; +import { ArgumentValidator } from "../utils/ArgumentValidator.js"; +import { CustomAuthInteractionClientBase } from "./CustomAuthInteractionClientBase.js"; + +export class CustomAuthInterationClientFactory { + constructor( + private config: BrowserConfiguration, + private storageImpl: BrowserCacheManager, + private browserCrypto: ICrypto, + private logger: Logger, + private eventHandler: EventHandler, + private navigationClient: INavigationClient, + private performanceClient: IPerformanceClient, + private customAuthApiClient: ICustomAuthApiClient, + private customAuthAuthority: CustomAuthAuthority, + ) { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("config", config); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("storageImpl", storageImpl); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("browserCrypto", browserCrypto); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("logger", logger); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("eventHandler", eventHandler); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("navigationClient", navigationClient); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("performanceClient", performanceClient); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("customAuthApiClient", customAuthApiClient); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("customAuthAuthority", customAuthAuthority); + } + + create( + clientConstructor: new ( + config: BrowserConfiguration, + storageImpl: BrowserCacheManager, + browserCrypto: ICrypto, + logger: Logger, + eventHandler: EventHandler, + navigationClient: INavigationClient, + performanceClient: IPerformanceClient, + customAuthApiClient: ICustomAuthApiClient, + customAuthAuthority: CustomAuthAuthority, + ) => TClient, + ): TClient { + return new clientConstructor( + this.config, + this.storageImpl, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.customAuthApiClient, + this.customAuthAuthority, + ); + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/BaseApiClient.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/BaseApiClient.ts new file mode 100644 index 0000000000..fbfead9611 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/BaseApiClient.ts @@ -0,0 +1,127 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AADServerParamKeys, ServerTelemetryManager } from "@azure/msal-browser"; +import { ChallengeType, DefaultPackageInfo, HttpHeaderKeys } from "../../../CustomAuthConstants.js"; +import { IHttpClient } from "../http_client/IHttpClient.js"; +import { ApiErrorResponse, CustomAuthApiErrorCode } from "./types/ApiErrorResponseTypes.js"; +import { UrlUtils } from "../../utils/UrlUtils.js"; +import { CustomAuthApiError, RedirectError } from "../../error/CustomAuthApiError.js"; + +export abstract class BaseApiClient { + private readonly baseRequestUrl: URL; + + constructor( + baseUrl: string, + private readonly clientId: string, + private httpClient: IHttpClient, + ) { + this.baseRequestUrl = UrlUtils.parseUrl(!baseUrl.endsWith("/") ? `${baseUrl}/` : baseUrl); + } + + protected async request( + endpoint: string, + data: Record, + telemetryManager: ServerTelemetryManager, + correlationId: string, + ): Promise { + const formData = new URLSearchParams({ + client_id: this.clientId, + ...data, + }); + const headers = this.getCommonHeaders(correlationId, telemetryManager); + const url = UrlUtils.buildUrl(this.baseRequestUrl.href, endpoint); + + let response: Response; + + try { + response = await this.httpClient.post(url, formData, headers); + } catch (e) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.HTTP_REQUEST_FAILED, + `Failed to perform '${endpoint}' request: ${e}`, + correlationId, + ); + } + + return this.handleApiResponse(response, correlationId); + } + + protected ensureContinuationTokenIsValid(continuationToken: string | undefined, correlationId: string): void { + if (!continuationToken) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.CONTINUATION_TOKEN_MISSING, + "Continuation token is missing in the response body", + correlationId, + ); + } + } + + private readResponseCorrelationId(response: Response, requestCorrelationId: string): string { + return response.headers.get(HttpHeaderKeys.X_MS_REQUEST_ID) || requestCorrelationId; + } + + private getCommonHeaders(correlationId: string, telemetryManager: ServerTelemetryManager): Record { + return { + [HttpHeaderKeys.CONTENT_TYPE]: "application/x-www-form-urlencoded", + [AADServerParamKeys.X_CLIENT_SKU]: DefaultPackageInfo.SKU, + [AADServerParamKeys.X_CLIENT_VER]: DefaultPackageInfo.VERSION, + [AADServerParamKeys.X_CLIENT_OS]: DefaultPackageInfo.OS, + [AADServerParamKeys.X_CLIENT_CPU]: DefaultPackageInfo.CPU, + [AADServerParamKeys.X_CLIENT_CURR_TELEM]: telemetryManager.generateCurrentRequestHeaderValue(), + [AADServerParamKeys.X_CLIENT_LAST_TELEM]: telemetryManager.generateLastRequestHeaderValue(), + [AADServerParamKeys.CLIENT_REQUEST_ID]: correlationId, + }; + } + + private async handleApiResponse(response: Response | undefined, requestCorrelationId: string): Promise { + if (!response) { + throw new CustomAuthApiError("empty_response", "Response is empty", requestCorrelationId); + } + + const correlationId = this.readResponseCorrelationId(response, requestCorrelationId); + + const responseData = await response.json(); + + if (response.ok) { + // Ensure the response doesn't have redirect challenge type + if (typeof responseData === "object" && responseData.challenge_type === ChallengeType.REDIRECT) { + throw new RedirectError(correlationId); + } + + return { + ...responseData, + correlation_id: correlationId, + }; + } + + const responseError = responseData as ApiErrorResponse; + + if (!responseError) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_RESPONSE_BODY, + "Response error body is empty or invalid", + correlationId, + ); + } + + const attributes = + !!responseError.required_attributes && responseError.required_attributes.length > 0 + ? responseError.required_attributes + : (responseError.invalid_attributes ?? []); + + throw new CustomAuthApiError( + responseError.error, + responseError.error_description, + responseError.correlation_id, + responseError.error_codes, + responseError.suberror, + attributes, + responseError.continuation_token, + responseError.trace_id, + responseError.timestamp, + ); + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/CustomAuthApiClient.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/CustomAuthApiClient.ts new file mode 100644 index 0000000000..b677f1c0cd --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/CustomAuthApiClient.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordApiClient } from "./ResetPasswordApiClient.js"; +import { SignupApiClient } from "./SignupApiClient.js"; +import { SignInApiClient } from "./SignInApiClient.js"; +import { ICustomAuthApiClient } from "./ICustomAuthApiClient.js"; +import { IHttpClient } from "../http_client/IHttpClient.js"; + +export class CustomAuthApiClient implements ICustomAuthApiClient { + signInApi: SignInApiClient; + signUpApi: SignupApiClient; + resetPasswordApi: ResetPasswordApiClient; + + constructor(customAuthApiBaseUrl: string, clientId: string, httpClient: IHttpClient) { + this.signInApi = new SignInApiClient(customAuthApiBaseUrl, clientId, httpClient); + this.signUpApi = new SignupApiClient(customAuthApiBaseUrl, clientId, httpClient); + this.resetPasswordApi = new ResetPasswordApiClient(customAuthApiBaseUrl, clientId, httpClient); + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts new file mode 100644 index 0000000000..1f270d61a2 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const CustomAuthApiEndpoint = { + SIGNIN_INITIATE: `/oauth2/v2.0/initiate`, + SIGNIN_CHALLENGE: `/oauth2/v2.0/challenge`, + SIGNIN_TOKEN: `/oauth2/v2.0/token`, + + SIGNUP_START: `/signup/v1.0/start`, + SIGNUP_CHALLENGE: `/signup/v1.0/challenge`, + SIGNUP_CONTINUE: `/signup/v1.0/continue`, + + RESET_PWD_START: `/resetpassword/v1.0/start`, + RESET_PWD_CHALLENGE: `/resetpassword/v1.0/challenge`, + RESET_PWD_CONTINUE: `/resetpassword/v1.0/continue`, + RESET_PWD_SUBMIT: `/resetpassword/v1.0/submit`, + RESET_PWD_POLL: `/resetpassword/v1.0/poll_completion`, +} as const; diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/ICustomAuthApiClient.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/ICustomAuthApiClient.ts new file mode 100644 index 0000000000..6d4cad1186 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/ICustomAuthApiClient.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordApiClient } from "./ResetPasswordApiClient.js"; +import { SignupApiClient } from "./SignupApiClient.js"; +import { SignInApiClient } from "./SignInApiClient.js"; +export interface ICustomAuthApiClient { + signInApi: SignInApiClient; + signUpApi: SignupApiClient; + resetPasswordApi: ResetPasswordApiClient; +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/ResetPasswordApiClient.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/ResetPasswordApiClient.ts new file mode 100644 index 0000000000..189ad677da --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/ResetPasswordApiClient.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GrantType, ResetPasswordPollStatus } from "../../../CustomAuthConstants.js"; +import { CustomAuthApiError } from "../../error/CustomAuthApiError.js"; +import { BaseApiClient } from "./BaseApiClient.js"; +import { CustomAuthApiEndpoint } from "./CustomAuthApiEndpoint.js"; +import { CustomAuthApiErrorCode } from "./types/ApiErrorResponseTypes.js"; +import { + ResetPasswordChallengeRequest, + ResetPasswordContinueRequest, + ResetPasswordPollCompletionRequest, + ResetPasswordStartRequest, + ResetPasswordSubmitRequest, +} from "./types/ApiRequestTypes.js"; +import { + ResetPasswordChallengeResponse, + ResetPasswordContinueResponse, + ResetPasswordPollCompletionResponse, + ResetPasswordStartResponse, + ResetPasswordSubmitResponse, +} from "./types/ApiResponseTypes.js"; + +export class ResetPasswordApiClient extends BaseApiClient { + /** + * Start the password reset flow + */ + async start(params: ResetPasswordStartRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_START, + { + challenge_type: params.challenge_type, + username: params.username, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Request a challenge (OTP) to be sent to the user's email + * @param ChallengeResetPasswordRequest Parameters for the challenge request + */ + async requestChallenge(params: ResetPasswordChallengeRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_CHALLENGE, + { + challenge_type: params.challenge_type, + continuation_token: params.continuation_token, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Submit the code for verification + * @param ContinueResetPasswordRequest Token from previous response + */ + async continueWithCode(params: ResetPasswordContinueRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.OOB, + oob: params.oob, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Submit the new password + * @param SubmitResetPasswordResponse Token from previous response + */ + async submitNewPassword(params: ResetPasswordSubmitRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_SUBMIT, + { + continuation_token: params.continuation_token, + new_password: params.new_password, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + if (result.poll_interval === 0) { + result.poll_interval = 2; + } + + return result; + } + + /** + * Poll for password reset completion status + * @param continuationToken Token from previous response + */ + async pollCompletion(params: ResetPasswordPollCompletionRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_POLL, + { + continuation_token: params.continuation_token, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensurePollStatusIsValid(result.status, params.correlationId); + + return result; + } + + protected ensurePollStatusIsValid(status: string, correlationId: string): void { + if ( + status !== ResetPasswordPollStatus.FAILED && + status !== ResetPasswordPollStatus.IN_PROGRESS && + status !== ResetPasswordPollStatus.SUCCEEDED && + status !== ResetPasswordPollStatus.NOT_STARTED + ) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_POLL_STATUS, + `The poll status '${status}' for password reset is invalid`, + correlationId, + ); + } + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/SignInApiClient.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/SignInApiClient.ts new file mode 100644 index 0000000000..621568188c --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/SignInApiClient.ts @@ -0,0 +1,160 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-browser"; +import { GrantType } from "../../../CustomAuthConstants.js"; +import { CustomAuthApiError } from "../../error/CustomAuthApiError.js"; +import { BaseApiClient } from "./BaseApiClient.js"; +import { CustomAuthApiEndpoint } from "./CustomAuthApiEndpoint.js"; +import { CustomAuthApiErrorCode } from "./types/ApiErrorResponseTypes.js"; +import { + SignInChallengeRequest, + SignInContinuationTokenRequest, + SignInInitiateRequest, + SignInOobTokenRequest, + SignInPasswordTokenRequest, +} from "./types/ApiRequestTypes.js"; +import { SignInChallengeResponse, SignInInitiateResponse, SignInTokenResponse } from "./types/ApiResponseTypes.js"; + +export class SignInApiClient extends BaseApiClient { + /** + * Initiates the sign-in flow + * @param username User's email + * @param authMethod 'email-otp' | 'email-password' + */ + async initiate(params: SignInInitiateRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNIN_INITIATE, + { + username: params.username, + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Requests authentication challenge (OTP or password validation) + * @param continuationToken Token from initiate response + * @param authMethod 'email-otp' | 'email-password' + */ + async requestChallenge(params: SignInChallengeRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNIN_CHALLENGE, + { + continuation_token: params.continuation_token, + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Requests security tokens using either password or OTP + * @param continuationToken Token from challenge response + * @param credentials Password or OTP + * @param authMethod 'email-otp' | 'email-password' + */ + async requestTokensWithPassword(params: SignInPasswordTokenRequest): Promise { + return this.requestTokens( + { + continuation_token: params.continuation_token, + grant_type: GrantType.PASSWORD, + scope: params.scope, + password: params.password, + }, + params.telemetryManager, + params.correlationId, + ); + } + + async requestTokensWithOob(params: SignInOobTokenRequest): Promise { + return this.requestTokens( + { + continuation_token: params.continuation_token, + scope: params.scope, + oob: params.oob, + grant_type: GrantType.OOB, + }, + params.telemetryManager, + params.correlationId, + ); + } + + async requestTokenWithContinuationToken(params: SignInContinuationTokenRequest): Promise { + return this.requestTokens( + { + continuation_token: params.continuation_token, + username: params.username, + scope: params.scope, + grant_type: GrantType.CONTINUATION_TOKEN, + client_info: true, + }, + params.telemetryManager, + params.correlationId, + ); + } + + private async requestTokens( + requestData: Record, + telemetryManager: ServerTelemetryManager, + correlationId: string, + ): Promise { + // The client_info parameter is required for MSAL to return the uid and utid in the response. + requestData.client_info = true; + + const result = await this.request( + CustomAuthApiEndpoint.SIGNIN_TOKEN, + requestData, + telemetryManager, + correlationId, + ); + + SignInApiClient.ensureTokenResponseIsValid(result); + + return result; + } + + private static ensureTokenResponseIsValid(tokenResponse: SignInTokenResponse): void { + let errorCode = ""; + let errorDescription = ""; + + if (!tokenResponse.access_token) { + errorCode = CustomAuthApiErrorCode.ACCESS_TOKEN_MISSING; + errorDescription = "Access token is missing in the response body"; + } else if (!tokenResponse.id_token) { + errorCode = CustomAuthApiErrorCode.ID_TOKEN_MISSING; + errorDescription = "Id token is missing in the response body"; + } else if (!tokenResponse.refresh_token) { + errorCode = CustomAuthApiErrorCode.REFRESH_TOKEN_MISSING; + errorDescription = "Refresh token is missing in the response body"; + } else if (!tokenResponse.expires_in || tokenResponse.expires_in <= 0) { + errorCode = CustomAuthApiErrorCode.INVALID_EXPIRES_IN; + errorDescription = "Expires in is invalid in the response body"; + } else if (tokenResponse.token_type !== "Bearer") { + errorCode = CustomAuthApiErrorCode.INVALID_TOKEN_TYPE; + errorDescription = `Token type '${tokenResponse.token_type}' is invalid in the response body`; + } else if (!tokenResponse.client_info) { + errorCode = CustomAuthApiErrorCode.CLIENT_INFO_MISSING; + errorDescription = "Client info is missing in the response body"; + } + + if (!errorCode && !errorDescription) { + return; + } + + throw new CustomAuthApiError(errorCode, errorDescription, tokenResponse.correlation_id); + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/SignupApiClient.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/SignupApiClient.ts new file mode 100644 index 0000000000..703ff8ad9e --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/SignupApiClient.ts @@ -0,0 +1,114 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GrantType } from "../../../CustomAuthConstants.js"; +import { BaseApiClient } from "./BaseApiClient.js"; +import { CustomAuthApiEndpoint } from "./CustomAuthApiEndpoint.js"; +import { + SignUpChallengeRequest, + SignUpContinueWithAttributesRequest, + SignUpContinueWithOobRequest, + SignUpContinueWithPasswordRequest, + SignUpStartRequest, +} from "./types/ApiRequestTypes.js"; +import { SignUpChallengeResponse, SignUpContinueResponse, SignUpStartResponse } from "./types/ApiResponseTypes.js"; + +export class SignupApiClient extends BaseApiClient { + /** + * Start the sign-up flow + */ + async start(params: SignUpStartRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_START, + { + username: params.username, + ...(params.password && { password: params.password }), + ...(params.attributes && { + attributes: JSON.stringify(params.attributes), + }), + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Request challenge (e.g., OTP) + */ + async requestChallenge(params: SignUpChallengeRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CHALLENGE, + { + continuation_token: params.continuation_token, + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + /** + * Continue sign-up flow with code. + */ + async continueWithCode(params: SignUpContinueWithOobRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.OOB, + oob: params.oob, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + async continueWithPassword(params: SignUpContinueWithPasswordRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.PASSWORD, + password: params.password, + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } + + async continueWithAttributes(params: SignUpContinueWithAttributesRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.ATTRIBUTES, + attributes: JSON.stringify(params.attributes), + }, + params.telemetryManager, + params.correlationId, + ); + + this.ensureContinuationTokenIsValid(result.continuation_token, params.correlationId); + + return result; + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts new file mode 100644 index 0000000000..c4656c1856 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const CustomAuthApiErrorCode = { + CONTINUATION_TOKEN_MISSING: "continuation_token_missing", + INVALID_RESPONSE_BODY: "invalid_response_body", + EMPTY_RESPONSE: "empty_response", + UNSUPPORTED_CHALLENGE_TYPE: "unsupported_challenge_type", + ACCESS_TOKEN_MISSING: "access_token_missing", + ID_TOKEN_MISSING: "id_token_missing", + REFRESH_TOKEN_MISSING: "refresh_token_missing", + INVALID_EXPIRES_IN: "invalid_expires_in", + INVALID_TOKEN_TYPE: "invalid_token_type", + HTTP_REQUEST_FAILED: "http_request_failed", + INVALID_REQUEST: "invalid_request", + USER_NOT_FOUND: "user_not_found", + INVALID_GRANT: "invalid_grant", + CREDENTIAL_REQUIRED: "credential_required", + ATTRIBUTES_REQUIRED: "attributes_required", + USER_ALREADY_EXISTS: "user_already_exists", + INVALID_POLL_STATUS: "invalid_poll_status", + PASSWORD_CHANGE_FAILED: "password_change_failed", + PASSWORD_RESET_TIMEOUT: "password_reset_timeout", + CLIENT_INFO_MISSING: "client_info_missing", +}; + +export const CustomAuthApiSuberror = { + PASSWORD_TOO_WEAK: "password_too_weak", + PASSWORD_TOO_SHORT: "password_too_short", + PASSWORD_TOO_LONG: "password_too_long", + PASSWORD_RECENTLY_USED: "password_recently_used", + PASSWORD_BANNED: "password_banned", + PASSWORD_IS_INVALID: "password_is_invalid", + INVALID_OOB_VALUE: "invalid_oob_value", + ATTRIBUTE_VALIATION_FAILED: "attribute_validation_failed", + NATIVEAUTHAPI_DISABLED: "nativeauthapi_disabled", +}; + +export interface InvalidAttribute { + name: string; + reason: string; +} + +/** + * Detailed error interface for Microsoft Entra signup errors + */ +export interface ApiErrorResponse { + error: string; + error_description: string; + correlation_id: string; + error_codes?: number[]; + suberror?: string; + continuation_token?: string; + timestamp?: string; + trace_id?: string; + required_attributes?: Array; + invalid_attributes?: Array; +} + +export interface UserAttribute { + name: string; + type?: string; + required?: boolean; + options?: UserAttributeOption; +} + +export interface UserAttributeOption { + regex?: string; +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiRequestTypes.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiRequestTypes.ts new file mode 100644 index 0000000000..40c17fc777 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiRequestTypes.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ApiRequestBase } from "./ApiTypesBase.js"; + +/* Sign-in API request types */ +export interface SignInInitiateRequest extends ApiRequestBase { + challenge_type: string; + username: string; +} + +export interface SignInChallengeRequest extends ApiRequestBase { + challenge_type: string; + continuation_token: string; +} + +interface SignInTokenRequestBase extends ApiRequestBase { + continuation_token: string; + scope: string; +} + +export interface SignInPasswordTokenRequest extends SignInTokenRequestBase { + password: string; +} + +export interface SignInOobTokenRequest extends SignInTokenRequestBase { + oob: string; +} + +export interface SignInContinuationTokenRequest extends SignInTokenRequestBase { + username: string; +} + +/* Sign-up API request types */ +export interface SignUpStartRequest extends ApiRequestBase { + username: string; + challenge_type: string; + password?: string; + attributes?: Record; +} + +export interface SignUpChallengeRequest extends ApiRequestBase { + continuation_token: string; + challenge_type: string; +} + +interface SignUpContinueRequestBase extends ApiRequestBase { + continuation_token: string; +} + +export interface SignUpContinueWithOobRequest extends SignUpContinueRequestBase { + oob: string; +} + +export interface SignUpContinueWithPasswordRequest extends SignUpContinueRequestBase { + password: string; +} + +export interface SignUpContinueWithAttributesRequest extends SignUpContinueRequestBase { + attributes: Record; +} + +/* Reset password API request types */ +export interface ResetPasswordStartRequest extends ApiRequestBase { + challenge_type: string; + username: string; +} + +export interface ResetPasswordChallengeRequest extends ApiRequestBase { + challenge_type: string; + continuation_token: string; +} + +export interface ResetPasswordContinueRequest extends ApiRequestBase { + continuation_token: string; + oob: string; +} + +export interface ResetPasswordSubmitRequest extends ApiRequestBase { + continuation_token: string; + new_password: string; +} + +export interface ResetPasswordPollCompletionRequest extends ApiRequestBase { + continuation_token: string; +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiResponseTypes.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiResponseTypes.ts new file mode 100644 index 0000000000..dc5e3a318f --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiResponseTypes.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ApiResponseBase } from "./ApiTypesBase.js"; + +interface ContinuousResponse extends ApiResponseBase { + continuation_token?: string; +} + +interface InitiateResponse extends ContinuousResponse { + challenge_type?: string; +} + +interface ChallengeResponse extends ApiResponseBase { + continuation_token?: string; + challenge_type?: string; + binding_method?: string; + challenge_channel?: string; + challenge_target_label?: string; + code_length?: number; +} + +/* Sign-in API response types */ +export type SignInInitiateResponse = InitiateResponse; + +export type SignInChallengeResponse = ChallengeResponse; + +export interface SignInTokenResponse extends ApiResponseBase { + token_type: "Bearer"; + scope: string; + expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; + client_info: string; + ext_expires_in?: number; +} + +/* Sign-up API response types */ +export type SignUpStartResponse = InitiateResponse; + +export interface SignUpChallengeResponse extends ChallengeResponse { + interval?: number; +} + +export type SignUpContinueResponse = InitiateResponse; + +/* Reset password API response types */ +export type ResetPasswordStartResponse = InitiateResponse; + +export type ResetPasswordChallengeResponse = ChallengeResponse; + +export interface ResetPasswordContinueResponse extends ContinuousResponse { + expires_in: number; +} + +export interface ResetPasswordSubmitResponse extends ContinuousResponse { + poll_interval: number; +} + +export interface ResetPasswordPollCompletionResponse extends ContinuousResponse { + status: string; +} diff --git a/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiTypesBase.ts b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiTypesBase.ts new file mode 100644 index 0000000000..44b3272082 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/custom_auth_api/types/ApiTypesBase.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-browser"; + +export type ApiRequestBase = { + correlationId: string; + telemetryManager: ServerTelemetryManager; +}; + +export type ApiResponseBase = { + correlation_id: string; +}; diff --git a/lib/msal-custom-auth/src/core/network_client/http_client/FetchHttpClient.ts b/lib/msal-custom-auth/src/core/network_client/http_client/FetchHttpClient.ts new file mode 100644 index 0000000000..af7d7503e2 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/http_client/FetchHttpClient.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AADServerParamKeys, Logger } from "@azure/msal-browser"; +import { HttpMethod, IHttpClient, RequestBody } from "./IHttpClient.js"; +import { FailedSendRequest, HttpError, NoNetworkConnectivity } from "../../error/HttpError.js"; + +/** + * Implementation of IHttpClient using fetch. + */ +export class FetchHttpClient implements IHttpClient { + constructor(private logger: Logger) {} + + async sendAsync(url: string | URL, options: RequestInit): Promise { + const headers = options.headers as Record; + const correlationId = headers?.[AADServerParamKeys.CLIENT_REQUEST_ID] || undefined; + + try { + this.logger.verbosePii(`Sending request to ${url}`, correlationId); + + const startTime = performance.now(); + const response = await fetch(url, options); + const endTime = performance.now(); + + this.logger.verbosePii( + `Request to '${url}' completed in ${endTime - startTime}ms with status code ${response.status}`, + correlationId, + ); + + return response; + } catch (e) { + this.logger.errorPii(`Failed to send request to ${url}: ${e}`, correlationId); + + if (!window.navigator.onLine) { + throw new HttpError(NoNetworkConnectivity, `No network connectivity: ${e}`, correlationId); + } + + throw new HttpError(FailedSendRequest, `Failed to send request: ${e}`, correlationId); + } + } + + async post(url: string | URL, body: RequestBody, headers: Record = {}): Promise { + return this.sendAsync(url, { + method: HttpMethod.POST, + headers, + body, + }); + } + + async get(url: string | URL, headers: Record = {}): Promise { + return this.sendAsync(url, { + method: HttpMethod.GET, + headers, + }); + } +} diff --git a/lib/msal-custom-auth/src/core/network_client/http_client/IHttpClient.ts b/lib/msal-custom-auth/src/core/network_client/http_client/IHttpClient.ts new file mode 100644 index 0000000000..28b497d2f2 --- /dev/null +++ b/lib/msal-custom-auth/src/core/network_client/http_client/IHttpClient.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export type RequestBody = string | ArrayBuffer | DataView | Blob | File | URLSearchParams | FormData | ReadableStream; +/** + * Interface for HTTP client. + */ +export interface IHttpClient { + /** + * Sends a request. + * @param url The URL to send the request to. + * @param options Additional fetch options. + */ + sendAsync(url: string | URL, options: RequestInit): Promise; + + /** + * Sends a POST request. + * @param url The URL to send the request to. + * @param body The body of the request. + * @param headers Optional headers for the request. + */ + post(url: string | URL, body: RequestBody, headers?: Record): Promise; + + /** + * Sends a GET request. + * @param url The URL to send the request to. + * @param headers Optional headers for the request. + */ + get(url: string | URL, headers?: Record): Promise; +} + +/** + * Represents an HTTP method type. + */ +export const HttpMethod = { + GET: "GET", + POST: "POST", + PUT: "PUT", + DELETE: "DELETE", +} as const; diff --git a/lib/msal-custom-auth/src/core/telemetry/PublicApiId.ts b/lib/msal-custom-auth/src/core/telemetry/PublicApiId.ts new file mode 100644 index 0000000000..4dac669bf1 --- /dev/null +++ b/lib/msal-custom-auth/src/core/telemetry/PublicApiId.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * The public API id for telemetry purpose. + */ +export const PublicApiId = { + /* + * The public API ids should be claim in the MSAL telemtry tracker. + * All the following ids are hardcoded, so we need to find a way to claim them in the future and update them here. + */ + + // Sign in + SIGN_IN_WITH_CODE_START: 100001, + SIGN_IN_WITH_PASSWORD_START: 100002, + SIGN_IN_SUBMIT_CODE: 100003, + SIGN_IN_SUBMIT_PASSWORD: 100004, + SIGN_IN_RESEND_CODE: 100005, + SIGN_IN_AFTER_SIGN_UP: 100006, + SIGN_IN_AFTER_PASSWORD_RESET: 100007, + + // Sign up + SIGN_UP_WITH_PASSWORD_START: 100021, + SIGN_UP_START: 100022, + SIGN_UP_SUBMIT_CODE: 100023, + SIGN_UP_SUBMIT_PASSWORD: 100024, + SIGN_UP_SUBMIT_ATTRIBUTES: 100025, + SIGN_UP_RESEND_CODE: 100026, + + // Password reset + PASSWORD_RESET_START: 100041, + PASSWORD_RESET_SUBMIT_CODE: 100042, + PASSWORD_RESET_SUBMIT_PASSWORD: 100043, + PASSWORD_RESET_RESEND_CODE: 100044, + + // Get account + ACCOUNT_GET_ACCOUNT: 100061, + ACCOUNT_SIGN_OUT: 100062, + ACCOUNT_GET_ACCESS_TOKEN: 100063, +}; diff --git a/lib/msal-custom-auth/src/core/utils/ArgumentValidator.ts b/lib/msal-custom-auth/src/core/utils/ArgumentValidator.ts new file mode 100644 index 0000000000..06021570cc --- /dev/null +++ b/lib/msal-custom-auth/src/core/utils/ArgumentValidator.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidArgumentError } from "../error/InvalidArgumentError.js"; + +export class ArgumentValidator { + static ensureArgumentIsNotEmptyString(argName: string, argValue: string | undefined, correlationId?: string): void { + if (!argValue || argValue.trim() === "") { + throw new InvalidArgumentError(argName, correlationId); + } + } + + static ensureArgumentIsNotNullOrUndefined( + argName: string, + argValue: T | undefined | null, + correlationId?: string, + ): asserts argValue is T { + if (argValue === null || argValue === undefined) { + throw new InvalidArgumentError(argName, correlationId); + } + } +} diff --git a/lib/msal-custom-auth/src/core/utils/StringUtils.ts b/lib/msal-custom-auth/src/core/utils/StringUtils.ts new file mode 100644 index 0000000000..b3077c12f8 --- /dev/null +++ b/lib/msal-custom-auth/src/core/utils/StringUtils.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Utility class for string operations. + */ +export class StringUtils { + /** + * Trims the slashes from the input string. + * @param input The string to trim. + * @returns The trimmed string. + */ + static trimSlashes(input: string): string { + if (!input) { + return input; + } + + let trimmedInput = input; + + while (trimmedInput.startsWith("/")) { + trimmedInput = trimmedInput.substring(1); + } + while (trimmedInput.endsWith("/")) { + trimmedInput = trimmedInput.substring(0, trimmedInput.length - 1); + } + + return trimmedInput; + } +} diff --git a/lib/msal-custom-auth/src/core/utils/UrlUtils.ts b/lib/msal-custom-auth/src/core/utils/UrlUtils.ts new file mode 100644 index 0000000000..7b35f52d34 --- /dev/null +++ b/lib/msal-custom-auth/src/core/utils/UrlUtils.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidUrl, ParsedUrlError, UnsecureUrl } from "../error/ParsedUrlError.js"; + +export class UrlUtils { + /** + * Validates whether a given URL is valid. + * @param url The target URL to validate + * @returns The result of the URL validation + */ + static IsValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + /** + * Validates whether a given URL is valid and secured. + * @param url The target URL to validate + * @returns The result of the URL validation + */ + static IsValidSecureUrl(url: string): boolean { + try { + const urlComponents = new URL(url); + + if (urlComponents.protocol !== "https:") { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + /** + * Parses a URL string into a URL object. + * @param url The URL to parse + * @returns The parsed URL object + */ + static parseUrl(url: string): URL { + try { + return new URL(url); + } catch (e) { + throw new ParsedUrlError(InvalidUrl, `The URL "${url}" is invalid: ${e}`); + } + } + + /** + * Parses a URL string into a URL object and ensure its protocol is HTTPS. + * @param url The URL to parse + * @returns The parsed URL object + */ + static parseSecureUrl(url: string): URL { + const parsedUrl = UrlUtils.parseUrl(url); + + if (parsedUrl.protocol !== "https:") { + throw new ParsedUrlError(UnsecureUrl, `The URL "${url}" is not secure. Only HTTPS URLs are supported.`); + } + + return parsedUrl; + } + /** + * Builds a URL object from a base URL and a path. + * @param baseUrl The base URL + * @param path The path to append to the base URL + * @returns The constructed URL object + */ + static buildUrl(baseUrl: string, path: string): URL { + const newBaseUrl = !baseUrl.endsWith("/") ? `${baseUrl}/` : baseUrl; + const newPath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(newPath, newBaseUrl); + return url; + } +} diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/CustomAuthAccountData.ts b/lib/msal-custom-auth/src/get_account/auth_flow/CustomAuthAccountData.ts new file mode 100644 index 0000000000..6fe0085ba2 --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/CustomAuthAccountData.ts @@ -0,0 +1,167 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthBrowserConfiguration } from "../../configuration/CustomAuthConfiguration.js"; +import { SignOutResult } from "./result/SignOutResult.js"; +import { GetAccessTokenResult } from "./result/GetAccessTokenResult.js"; +import { + AccountInfo, + AuthenticationScheme, + CommonSilentFlowRequest, + Logger, + SilentRequest, + TokenClaims, +} from "@azure/msal-browser"; +import { ArgumentValidator } from "../../core/utils/ArgumentValidator.js"; +import { CustomAuthSilentCacheClient } from "../interaction_client/CustomAuthSilentCacheClient.js"; +import { NoCachedAccountFoundError } from "../../core/error/NoCachedAccountFoundError.js"; +import { DefaultScopes } from "../../CustomAuthConstants.js"; +import { AccessTokenRetrievalInputs } from "../../CustomAuthActionInputs.js"; + +/* + * Account information. + */ +export class CustomAuthAccountData { + constructor( + private readonly account: AccountInfo, + private readonly config: CustomAuthBrowserConfiguration, + private readonly cacheClient: CustomAuthSilentCacheClient, + private readonly logger: Logger, + private readonly correlationId: string, + ) { + ArgumentValidator.ensureArgumentIsNotEmptyString("correlationId", correlationId); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("account", account, correlationId); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("config", config, correlationId); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("cacheClient", cacheClient, correlationId); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("logger", logger, correlationId); + } + + /** + * This method triggers a sign-out operation, + * which removes the current account info and its tokens from browser cache. + * If sign-out successfully, redirect the page to postLogoutRedirectUri if provided in the configuration. + * @returns {Promise} The result of the SignOut operation. + */ + async signOut(): Promise { + try { + const currentAccount = this.cacheClient.getCurrentAccount(this.correlationId); + + if (!currentAccount) { + throw new NoCachedAccountFoundError(this.correlationId); + } + + this.logger.verbose("Signing out user", this.correlationId); + + await this.cacheClient.logout({ + correlationId: this.correlationId, + account: currentAccount, + }); + + this.logger.verbose("User signed out", this.correlationId); + + return new SignOutResult(); + } catch (error) { + this.logger.errorPii(`An error occurred during sign out: ${error}`, this.correlationId); + + return SignOutResult.createWithError(error); + } + } + + getAccount(): AccountInfo { + return this.account; + } + + /** + * Gets the raw id-token of current account. + * Idtoken is only issued if openid scope is present in the scopes parameter when requesting for tokens, + * otherwise will return undefined from the response. + * @returns {string|undefined} The account id-token. + */ + getIdToken(): string | undefined { + return this.account.idToken; + } + + /** + * Gets the id token claims extracted from raw IdToken of current account. + * @returns {AuthTokenClaims|undefined} The token claims. + */ + getClaims(): AuthTokenClaims | undefined { + return this.account.idTokenClaims; + } + + /** + * Gets the access token of current account from browser cache if it is not expired, + * otherwise renew the token using cached refresh token if valid. + * If no refresh token is found or it is expired, then throws error. + * @param {AccessTokenRetrievalInputs} accessTokenRetrievalInputs - The inputs for retrieving the access token. + * @returns {Promise} The result of the operation. + */ + async getAccessToken(accessTokenRetrievalInputs: AccessTokenRetrievalInputs): Promise { + try { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "accessTokenRetrievalInputs", + accessTokenRetrievalInputs, + this.correlationId, + ); + + this.logger.verbose("Getting current account.", this.correlationId); + + const currentAccount = this.cacheClient.getCurrentAccount(this.account.username); + + if (!currentAccount) { + throw new NoCachedAccountFoundError(this.correlationId); + } + + this.logger.verbose("Getting access token.", this.correlationId); + + const newScopes = + accessTokenRetrievalInputs.scopes && accessTokenRetrievalInputs.scopes.length > 0 + ? accessTokenRetrievalInputs.scopes + : [...DefaultScopes]; + const commonSilentFlowRequest = this.createCommonSilentFlowRequest( + currentAccount, + accessTokenRetrievalInputs.forceRefresh, + newScopes, + ); + const result = await this.cacheClient.acquireToken(commonSilentFlowRequest); + + this.logger.verbose("Successfully got access token from cache.", this.correlationId); + + return new GetAccessTokenResult(result); + } catch (error) { + this.logger.error("Failed to get access token from cache.", this.correlationId); + + return GetAccessTokenResult.createWithError(error); + } + } + + private createCommonSilentFlowRequest( + accountInfo: AccountInfo, + forceRefresh: boolean = false, + requestScopes: Array, + ): CommonSilentFlowRequest { + const silentRequest: SilentRequest = { + authority: this.config.auth.authority, + correlationId: this.correlationId, + scopes: requestScopes || [], + account: accountInfo, + forceRefresh: forceRefresh || false, + storeInCache: { + idToken: true, + accessToken: true, + refreshToken: true, + }, + }; + + return { + ...silentRequest, + authenticationScheme: AuthenticationScheme.BEARER, + } as CommonSilentFlowRequest; + } +} + +export type AuthTokenClaims = TokenClaims & { + [key: string]: string | number | string[] | object | undefined | unknown; +}; diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/error_type/GetAccountError.ts b/lib/msal-custom-auth/src/get_account/auth_flow/error_type/GetAccountError.ts new file mode 100644 index 0000000000..33d0a865d2 --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/error_type/GetAccountError.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; + +/** + * The error class for get account errors. + */ +export class GetAccountError extends AuthFlowErrorBase { + /** + * Checks if the error is due to no cached account found. + * @returns true if the error is due to no cached account found, false otherwise. + */ + isCurrentAccountNotFound(): boolean { + return this.isNoCachedAccountFoundError(); + } +} + +/** + * The error class for sign-out errors. + */ +export class SignOutError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the user is not signed in. + * @returns true if the error is due to the user is not signed in, false otherwise. + */ + isUserNotSignedIn(): boolean { + return this.isNoCachedAccountFoundError(); + } +} + +/** + * The error class for getting the current account access token errors. + */ +export class GetCurrentAccountAccessTokenError extends AuthFlowErrorBase { + /** + * Checks if the error is due to no cached account found. + * @returns true if the error is due to no cached account found, false otherwise. + */ + isCurrentAccountNotFound(): boolean { + return this.isNoCachedAccountFoundError(); + } +} diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/result/GetAccessTokenResult.ts b/lib/msal-custom-auth/src/get_account/auth_flow/result/GetAccessTokenResult.ts new file mode 100644 index 0000000000..d3f3a6d33f --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/result/GetAccessTokenResult.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthenticationResult } from "@azure/msal-browser"; +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { GetCurrentAccountAccessTokenError } from "../error_type/GetAccountError.js"; +import { GetAccessTokenCompletedState, GetAccessTokenFailedState } from "../state/GetAccessTokenState.js"; + +/* + * Result of getting an access token. + */ +export class GetAccessTokenResult extends AuthFlowResultBase< + GetAccessTokenResultState, + GetCurrentAccountAccessTokenError, + AuthenticationResult +> { + /** + * Creates a new instance of GetAccessTokenResult. + * @param resultData The result data of the access token. + */ + constructor(resultData?: AuthenticationResult) { + super(new GetAccessTokenCompletedState(), resultData); + } + + /** + * Creates a new instance of GetAccessTokenResult with an error. + * @param error The error that occurred. + * @return {GetAccessTokenResult} The result with the error. + */ + static createWithError(error: unknown): GetAccessTokenResult { + const result = new GetAccessTokenResult(); + result.error = new GetCurrentAccountAccessTokenError(GetAccessTokenResult.createErrorData(error)); + result.state = new GetAccessTokenFailedState(); + + return result; + } + + /** + * Checks if the result is completed. + */ + isCompleted(): this is GetAccessTokenResult & { state: GetAccessTokenCompletedState } { + return this.state instanceof GetAccessTokenCompletedState; + } + + /** + * Checks if the result is failed. + */ + isFailed(): this is GetAccessTokenResult & { state: GetAccessTokenFailedState } { + return this.state instanceof GetAccessTokenFailedState; + } +} + +/** + * The possible states for the GetAccessTokenResult. + * This includes: + * - GetAccessTokenCompletedState: The access token was successfully retrieved. + * - GetAccessTokenFailedState: The access token retrieval failed. + */ +export type GetAccessTokenResultState = GetAccessTokenCompletedState | GetAccessTokenFailedState; diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/result/GetAccountResult.ts b/lib/msal-custom-auth/src/get_account/auth_flow/result/GetAccountResult.ts new file mode 100644 index 0000000000..c90193d525 --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/result/GetAccountResult.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { CustomAuthAccountData } from "../CustomAuthAccountData.js"; +import { GetAccountError } from "../error_type/GetAccountError.js"; +import { GetAccountCompletedState, GetAccountFailedState } from "../state/GetAccountState.js"; + +/* + * Result of getting an account. + */ +export class GetAccountResult extends AuthFlowResultBase< + GetAccountResultState, + GetAccountError, + CustomAuthAccountData +> { + /** + * Creates a new instance of GetAccountResult. + * @param resultData The result data. + */ + constructor(resultData?: CustomAuthAccountData) { + super(new GetAccountCompletedState(), resultData); + } + + /** + * Creates a new instance of GetAccountResult with an error. + * @param error The error data. + */ + static createWithError(error: unknown): GetAccountResult { + const result = new GetAccountResult(); + result.error = new GetAccountError(GetAccountResult.createErrorData(error)); + result.state = new GetAccountFailedState(); + + return result; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is GetAccountResult & { state: GetAccountCompletedState } { + return this.state instanceof GetAccountCompletedState; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is GetAccountResult & { state: GetAccountFailedState } { + return this.state instanceof GetAccountFailedState; + } +} + +/** + * The possible states for the GetAccountResult. + * This includes: + * - GetAccountCompletedState: The account was successfully retrieved. + * - GetAccountFailedState: The account retrieval failed. + */ +export type GetAccountResultState = GetAccountCompletedState | GetAccountFailedState; diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/result/SignOutResult.ts b/lib/msal-custom-auth/src/get_account/auth_flow/result/SignOutResult.ts new file mode 100644 index 0000000000..e32ce6a9f2 --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/result/SignOutResult.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignOutError } from "../error_type/GetAccountError.js"; +import { SignOutCompletedState, SignOutFailedState } from "../state/SignOutState.js"; + +/* + * Result of a sign-out operation. + */ +export class SignOutResult extends AuthFlowResultBase { + /** + * Creates a new instance of SignOutResult. + * @param state The state of the result. + */ + constructor() { + super(new SignOutCompletedState()); + } + + /** + * Creates a new instance of SignOutResult with an error. + * @param error The error that occurred during the sign-out operation. + */ + static createWithError(error: unknown): SignOutResult { + const result = new SignOutResult(); + result.error = new SignOutError(SignOutResult.createErrorData(error)); + result.state = new SignOutFailedState(); + + return result; + } + + /** + * Checks if the sign-out operation is completed. + */ + isCompleted(): this is SignOutResult & { state: SignOutCompletedState } { + return this.state instanceof SignOutCompletedState; + } + + /** + * Checks if the sign-out operation failed. + */ + isFailed(): this is SignOutResult & { state: SignOutFailedState } { + return this.state instanceof SignOutFailedState; + } +} + +/** + * The possible states for the SignOutResult. + * This includes: + * - SignOutCompletedState: The sign-out operation was successful. + * - SignOutFailedState: The sign-out operation failed. + */ +export type SignOutResultState = SignOutCompletedState | SignOutFailedState; diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/state/GetAccessTokenState.ts b/lib/msal-custom-auth/src/get_account/auth_flow/state/GetAccessTokenState.ts new file mode 100644 index 0000000000..77a2bc91ce --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/state/GetAccessTokenState.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * The completed state of the get access token flow. + */ +export class GetAccessTokenCompletedState extends AuthFlowStateBase {} + +/** + * The failed state of the get access token flow. + */ +export class GetAccessTokenFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/state/GetAccountState.ts b/lib/msal-custom-auth/src/get_account/auth_flow/state/GetAccountState.ts new file mode 100644 index 0000000000..9489a06f51 --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/state/GetAccountState.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * The completed state of the get account flow. + */ +export class GetAccountCompletedState extends AuthFlowStateBase {} + +/** + * The failed state of the get account flow. + */ +export class GetAccountFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-custom-auth/src/get_account/auth_flow/state/SignOutState.ts b/lib/msal-custom-auth/src/get_account/auth_flow/state/SignOutState.ts new file mode 100644 index 0000000000..ef679df9d5 --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/auth_flow/state/SignOutState.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * The completed state of the sign-out flow. + */ +export class SignOutCompletedState extends AuthFlowStateBase {} + +/** + * The failed state of the sign-out flow. + */ +export class SignOutFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-custom-auth/src/get_account/interaction_client/CustomAuthSilentCacheClient.ts b/lib/msal-custom-auth/src/get_account/interaction_client/CustomAuthSilentCacheClient.ts new file mode 100644 index 0000000000..1e4fc7cfcf --- /dev/null +++ b/lib/msal-custom-auth/src/get_account/interaction_client/CustomAuthSilentCacheClient.ts @@ -0,0 +1,162 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AccountInfo, + ApiId, + AuthenticationResult, + BrowserUtils, + ClearCacheRequest, + ClientAuthError, + ClientAuthErrorCodes, + ClientConfiguration, + CommonSilentFlowRequest, + RefreshTokenClient, + ServerTelemetryManager, + SilentFlowClient, + UrlString, +} from "@azure/msal-browser"; +import { CustomAuthAuthority } from "../../core/CustomAuthAuthority.js"; +import { DefaultPackageInfo } from "../../CustomAuthConstants.js"; +import { PublicApiId } from "../../core/telemetry/PublicApiId.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; + +export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase { + /** + * Acquires a token from the cache if it is not expired. Otherwise, makes a request to renew the token. + * If forceRresh is set to false, then looks up the access token in cache first. + * If access token is expired or not found, then uses refresh token to get a new access token. + * If no refresh token is found or it is expired, then throws error. + * If forceRefresh is set to true, then skips token cache lookup and fetches a new token using refresh token + * If no refresh token is found or it is expired, then throws error. + * @param silentRequest The silent request object. + * @returns {Promise} The promise that resolves to an AuthenticationResult. + */ + override async acquireToken(silentRequest: CommonSilentFlowRequest): Promise { + const telemetryManager = this.initializeServerTelemetryManager(PublicApiId.ACCOUNT_GET_ACCESS_TOKEN); + const clientConfig = this.getCustomAuthClientConfiguration(telemetryManager, this.customAuthAuthority); + const silentFlowClient = new SilentFlowClient(clientConfig, this.performanceClient); + + try { + this.logger.verbose("Starting silent flow to acquire token from cache", this.correlationId); + + const result = await silentFlowClient.acquireCachedToken(silentRequest); + + this.logger.verbose( + "Silent flow to acquire token from cache is completed and token is found", + this.correlationId, + ); + + return result[0] as AuthenticationResult; + } catch (error) { + if (error instanceof ClientAuthError && error.errorCode === ClientAuthErrorCodes.tokenRefreshRequired) { + this.logger.verbose("Token refresh is required to acquire token silently", this.correlationId); + + const refreshTokenClient = new RefreshTokenClient(clientConfig, this.performanceClient); + + this.logger.verbose("Starting refresh flow to refresh token", this.correlationId); + + const refreshTokenResult = await refreshTokenClient.acquireTokenByRefreshToken(silentRequest); + + this.logger.verbose("Refresh flow to refresh token is completed", this.correlationId); + + return refreshTokenResult as AuthenticationResult; + } + + throw error; + } + } + + override async logout(logoutRequest?: ClearCacheRequest): Promise { + const validLogoutRequest = this.initializeLogoutRequest(logoutRequest); + + // Clear the cache + this.logger.verbose("Start to clear the cache", logoutRequest?.correlationId); + await this.clearCacheOnLogout(validLogoutRequest?.account); + this.logger.verbose("Cache cleared", logoutRequest?.correlationId); + + const postLogoutRedirectUri = this.config.auth.postLogoutRedirectUri; + + if (postLogoutRedirectUri) { + const absoluteRedirectUri = UrlString.getAbsoluteUrl(postLogoutRedirectUri, BrowserUtils.getCurrentUri()); + + this.logger.verbose("Post logout redirect uri is set, redirecting to uri", logoutRequest?.correlationId); + + // Redirect to post logout redirect uri + await this.navigationClient.navigateExternal(absoluteRedirectUri, { + apiId: ApiId.logout, + timeout: this.config.system.redirectNavigationTimeout, + noHistory: false, + }); + } + } + + getCurrentAccount(correlationId: string): AccountInfo | null { + let account: AccountInfo | null = null; + + this.logger.verbose("Getting the first account from cache.", correlationId); + + const allAccounts = this.browserStorage.getAllAccounts(); + + if (allAccounts.length > 0) { + if (allAccounts.length !== 1) { + this.logger.warning( + "Multiple accounts found in cache. This is not supported in the Native Auth scenario.", + correlationId, + ); + } + + account = allAccounts[0]; + } + + if (account) { + this.logger.verbose("Account data found.", correlationId); + } else { + this.logger.verbose("No account data found.", correlationId); + } + + return account; + } + + private getCustomAuthClientConfiguration( + serverTelemetryManager: ServerTelemetryManager, + customAuthAuthority: CustomAuthAuthority, + ): ClientConfiguration { + const logger = this.config.system.loggerOptions; + + return { + authOptions: { + clientId: this.config.auth.clientId, + authority: customAuthAuthority, + clientCapabilities: this.config.auth.clientCapabilities, + redirectUri: this.config.auth.redirectUri, + }, + systemOptions: { + tokenRenewalOffsetSeconds: this.config.system.tokenRenewalOffsetSeconds, + preventCorsPreflight: true, + }, + loggerOptions: { + loggerCallback: logger.loggerCallback, + piiLoggingEnabled: logger.piiLoggingEnabled, + logLevel: logger.logLevel, + correlationId: this.correlationId, + }, + cacheOptions: { + claimsBasedCachingEnabled: this.config.cache.claimsBasedCachingEnabled, + }, + cryptoInterface: this.browserCrypto, + networkInterface: this.networkClient, + storageInterface: this.browserStorage, + serverTelemetryManager: serverTelemetryManager, + libraryInfo: { + sku: DefaultPackageInfo.SKU, + version: DefaultPackageInfo.VERSION, + cpu: DefaultPackageInfo.CPU, + os: DefaultPackageInfo.OS, + }, + telemetry: this.config.telemetry, + }; + } +} diff --git a/lib/msal-custom-auth/src/index.ts b/lib/msal-custom-auth/src/index.ts new file mode 100644 index 0000000000..c34f0de54f --- /dev/null +++ b/lib/msal-custom-auth/src/index.ts @@ -0,0 +1,151 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +// Application and Controller +export { CustomAuthPublicClientApplication } from "./CustomAuthPublicClientApplication.js"; +export { ICustomAuthPublicClientApplication } from "./ICustomAuthPublicClientApplication.js"; +export { ICustomAuthStandardController } from "./controller/ICustomAuthStandardController.js"; + +// Configuration +export { CustomAuthConfiguration } from "./configuration/CustomAuthConfiguration.js"; + +// Account Data +export { CustomAuthAccountData } from "./get_account/auth_flow/CustomAuthAccountData.js"; + +// Operation Inputs +export { + SignInInputs, + SignUpInputs, + ResetPasswordInputs, + AccountRetrievalInputs, + AccessTokenRetrievalInputs, + SignInWithContinuationTokenInputs, +} from "./CustomAuthActionInputs.js"; + +// Operation Errors +export { + GetAccountError, + SignOutError, + GetCurrentAccountAccessTokenError, +} from "./get_account/auth_flow/error_type/GetAccountError.js"; +export { + SignInError, + SignInSubmitPasswordError, + SignInSubmitCodeError, + SignInResendCodeError, +} from "./sign_in/auth_flow/error_type/SignInError.js"; +export { + SignUpError, + SignUpSubmitPasswordError, + SignUpSubmitCodeError, + SignUpSubmitAttributesError, + SignUpResendCodeError, +} from "./sign_up/auth_flow/error_type/SignUpError.js"; +export { + ResetPasswordError, + ResetPasswordSubmitPasswordError, + ResetPasswordSubmitCodeError, + ResetPasswordResendCodeError, +} from "./reset_password/auth_flow/error_type/ResetPasswordError.js"; + +// Sign-in State +export { SignInCodeRequiredState } from "./sign_in/auth_flow/state/SignInCodeRequiredState.js"; +export { SignInContinuationState } from "./sign_in/auth_flow/state/SignInContinuationState.js"; +export { SignInPasswordRequiredState } from "./sign_in/auth_flow/state/SignInPasswordRequiredState.js"; +export { SignInCompletedState } from "./sign_in/auth_flow/state/SignInCompletedState.js"; +export { SignInFailedState } from "./sign_in/auth_flow/state/SignInFailedState.js"; + +// Sign-in Results +export { SignInResult, SignInResultState } from "./sign_in/auth_flow/result/SignInResult.js"; +export { SignInSubmitCodeResult } from "./sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +export { + SignInResendCodeResult, + SignInResendCodeResultState, +} from "./sign_in/auth_flow/result/SignInResendCodeResult.js"; +export { SignInSubmitPasswordResult } from "./sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; +export { SignInSubmitCredentialResultState } from "./sign_in/auth_flow/result/SignInSubmitCredentialResult.js"; + +// Sign-up User Account Attributes +export { UserAccountAttributes } from "./UserAccountAttributes.js"; + +// Sign-up State +export { SignUpAttributesRequiredState } from "./sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; +export { SignUpCodeRequiredState } from "./sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +export { SignUpPasswordRequiredState } from "./sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +export { SignUpCompletedState } from "./sign_up/auth_flow/state/SignUpCompletedState.js"; +export { SignUpFailedState } from "./sign_up/auth_flow/state/SignUpFailedState.js"; + +// Sign-up Results +export { SignUpResult, SignUpResultState } from "./sign_up/auth_flow/result/SignUpResult.js"; +export { + SignUpSubmitAttributesResult, + SignUpSubmitAttributesResultState, +} from "./sign_up/auth_flow/result/SignUpSubmitAttributesResult.js"; +export { + SignUpSubmitCodeResult, + SignUpSubmitCodeResultState, +} from "./sign_up/auth_flow/result/SignUpSubmitCodeResult.js"; +export { + SignUpResendCodeResult, + SignUpResendCodeResultState, +} from "./sign_up/auth_flow/result/SignUpResendCodeResult.js"; +export { + SignUpSubmitPasswordResult, + SignUpSubmitPasswordResultState, +} from "./sign_up/auth_flow/result/SignUpSubmitPasswordResult.js"; + +// Reset-password State +export { ResetPasswordCodeRequiredState } from "./reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +export { ResetPasswordPasswordRequiredState } from "./reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; +export { ResetPasswordCompletedState } from "./reset_password/auth_flow/state/ResetPasswordCompletedState.js"; +export { ResetPasswordFailedState } from "./reset_password/auth_flow/state/ResetPasswordFailedState.js"; + +// Reset-password Results +export { + ResetPasswordStartResult, + ResetPasswordStartResultState, +} from "./reset_password/auth_flow/result/ResetPasswordStartResult.js"; +export { + ResetPasswordSubmitCodeResult, + ResetPasswordSubmitCodeResultState, +} from "./reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.js"; +export { + ResetPasswordResendCodeResult, + ResetPasswordResendCodeResultState, +} from "./reset_password/auth_flow/result/ResetPasswordResendCodeResult.js"; +export { + ResetPasswordSubmitPasswordResult, + ResetPasswordSubmitPasswordResultState, +} from "./reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.js"; + +// Get Access Token Results +export { + GetAccessTokenResult, + GetAccessTokenResultState, +} from "./get_account/auth_flow/result/GetAccessTokenResult.js"; + +// Get Account Results +export { GetAccountResult, GetAccountResultState } from "./get_account/auth_flow/result/GetAccountResult.js"; + +// Sign Out Results +export { SignOutResult, SignOutResultState } from "./get_account/auth_flow/result/SignOutResult.js"; + +// Errors +export { CustomAuthApiError } from "./core/error/CustomAuthApiError.js"; +export { CustomAuthError } from "./core/error/CustomAuthError.js"; +export { HttpError } from "./core/error/HttpError.js"; +export { InvalidArgumentError } from "./core/error/InvalidArgumentError.js"; +export { InvalidConfigurationError } from "./core/error/InvalidConfigurationError.js"; +export { MethodNotImplementedError } from "./core/error/MethodNotImplementedError.js"; +export { MsalCustomAuthError } from "./core/error/MsalCustomAuthError.js"; +export { NoCachedAccountFoundError } from "./core/error/NoCachedAccountFoundError.js"; +export { ParsedUrlError } from "./core/error/ParsedUrlError.js"; +export { UnexpectedError } from "./core/error/UnexpectedError.js"; +export { UnsupportedEnvironmentError } from "./core/error/UnsupportedEnvironmentError.js"; +export { UserAccountAttributeError } from "./core/error/UserAccountAttributeError.js"; +export { UserAlreadySignedInError } from "./core/error/UserAlreadySignedInError.js"; + +// Components from msal_browser +export { LogLevel } from "@azure/msal-browser"; diff --git a/lib/msal-custom-auth/src/operating_context/CustomAuthOperatingContext.ts b/lib/msal-custom-auth/src/operating_context/CustomAuthOperatingContext.ts new file mode 100644 index 0000000000..b14106e953 --- /dev/null +++ b/lib/msal-custom-auth/src/operating_context/CustomAuthOperatingContext.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { BaseOperatingContext } from "@azure/msal-browser"; +import { + CustomAuthBrowserConfiguration, + CustomAuthConfiguration, + CustomAuthOptions, +} from "../configuration/CustomAuthConfiguration.js"; + +export class CustomAuthOperatingContext extends BaseOperatingContext { + private readonly customAuthOptions: CustomAuthOptions; + private static readonly MODULE_NAME: string = ""; + private static readonly ID: string = "CustomAuthOperatingContext"; + + constructor(configuration: CustomAuthConfiguration) { + super(configuration); + + this.customAuthOptions = configuration.customAuth; + } + + getModuleName(): string { + return CustomAuthOperatingContext.MODULE_NAME; + } + + getId(): string { + return CustomAuthOperatingContext.ID; + } + + getCustomAuthConfig(): CustomAuthBrowserConfiguration { + return { + ...this.getConfig(), + customAuth: this.customAuthOptions, + }; + } + + async initialize(): Promise { + this.available = typeof window !== "undefined"; + return this.available; + } +} diff --git a/lib/msal-custom-auth/src/packageMetadata.ts b/lib/msal-custom-auth/src/packageMetadata.ts new file mode 100644 index 0000000000..cd94214dd8 --- /dev/null +++ b/lib/msal-custom-auth/src/packageMetadata.ts @@ -0,0 +1,3 @@ +/* eslint-disable header/header */ +export const name = "@azure/msal-custom-auth"; +export const version = "0.0.1"; diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/error_type/ResetPasswordError.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/error_type/ResetPasswordError.ts new file mode 100644 index 0000000000..5a88569db2 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/error_type/ResetPasswordError.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; +import { CustomAuthApiError } from "../../../core/error/CustomAuthApiError.js"; +import { CustomAuthApiErrorCode } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +export class ResetPasswordError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the user not being found. + * @returns true if the error is due to the user not being found, false otherwise. + */ + isUserNotFound(): boolean { + return this.isUserNotFoundError(); + } + + /** + * Checks if the error is due to the username being invalid. + * @returns true if the error is due to the username being invalid, false otherwise. + */ + isInvalidUsername(): boolean { + return this.isUserInvalidError(); + } + + /** + * Checks if the error is due to the provided challenge type is not supported. + * @returns {boolean} True if the error is due to the provided challenge type is not supported, false otherwise. + */ + isUnsupportedChallengeType(): boolean { + return this.isUnsupportedChallengeTypeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if client app doesn't support the challenge type configured in Entra, "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class ResetPasswordSubmitPasswordError extends AuthFlowErrorBase { + /** + * Checks if the new password is invalid or incorrect. + * @returns {boolean} True if the new password is invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return this.isInvalidNewPasswordError() || this.isPasswordIncorrectError(); + } + + /** + * Checks if the password reset failed due to reset timeout or password change failed. + * @returns {boolean} True if the password reset failed, false otherwise. + */ + isPasswordResetFailed(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + (this.errorData.error === CustomAuthApiErrorCode.PASSWORD_RESET_TIMEOUT || + this.errorData.error === CustomAuthApiErrorCode.PASSWORD_CHANGE_FAILED) + ); + } +} + +export class ResetPasswordSubmitCodeError extends AuthFlowErrorBase { + /** + * Checks if the provided code is invalid. + * @returns {boolean} True if the provided code is invalid, false otherwise. + */ + isInvalidCode(): boolean { + return this.isInvalidCodeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if client app doesn't support the challenge type configured in Entra, "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class ResetPasswordResendCodeError extends AuthFlowErrorBase { + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if client app doesn't support the challenge type configured in Entra, "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts new file mode 100644 index 0000000000..cfd5287fed --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordResendCodeError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordCodeRequiredState } from "../state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; + +/* + * Result of resending code in a reset password operation. + */ +export class ResetPasswordResendCodeResult extends AuthFlowResultBase< + ResetPasswordResendCodeResultState, + ResetPasswordResendCodeError, + void +> { + /** + * Creates a new instance of ResetPasswordResendCodeResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordResendCodeResultState) { + super(state); + } + + /** + * Creates a new instance of ResetPasswordResendCodeResult with an error. + * @param error The error that occurred. + * @returns {ResetPasswordResendCodeResult} A new instance of ResetPasswordResendCodeResult with the error set. + */ + static createWithError(error: unknown): ResetPasswordResendCodeResult { + const result = new ResetPasswordResendCodeResult(new ResetPasswordFailedState()); + result.error = new ResetPasswordResendCodeError(ResetPasswordResendCodeResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordResendCodeResult & { state: ResetPasswordFailedState } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is ResetPasswordResendCodeResult & { state: ResetPasswordCodeRequiredState } { + /* + * The instanceof operator couldn't be used here to check the state type since the circular dependency issue. + * So we are using the constructor name to check the state type. + */ + return this.state.constructor?.name === "ResetPasswordCodeRequiredState"; + } +} + +/** + * The possible states for the ResetPasswordResendCodeResult. + * This includes: + * - ResetPasswordCodeRequiredState: The reset password process requires a code. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordResendCodeResultState = ResetPasswordCodeRequiredState | ResetPasswordFailedState; diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordStartResult.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordStartResult.ts new file mode 100644 index 0000000000..9ba834e8ca --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordStartResult.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordCodeRequiredState } from "../state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; + +/* + * Result of a reset password operation. + */ +export class ResetPasswordStartResult extends AuthFlowResultBase< + ResetPasswordStartResultState, + ResetPasswordError, + void +> { + /** + * Creates a new instance of ResetPasswordStartResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordStartResultState) { + super(state); + } + + /** + * Creates a new instance of ResetPasswordStartResult with an error. + * @param error The error that occurred. + * @returns {ResetPasswordStartResult} A new instance of ResetPasswordStartResult with the error set. + */ + static createWithError(error: unknown): ResetPasswordStartResult { + const result = new ResetPasswordStartResult(new ResetPasswordFailedState()); + result.error = new ResetPasswordError(ResetPasswordStartResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordStartResult & { state: ResetPasswordFailedState } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is ResetPasswordStartResult & { state: ResetPasswordCodeRequiredState } { + return this.state instanceof ResetPasswordCodeRequiredState; + } +} + +/** + * The possible states for the ResetPasswordStartResult. + * This includes: + * - ResetPasswordCodeRequiredState: The reset password process requires a code. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordStartResultState = ResetPasswordCodeRequiredState | ResetPasswordFailedState; diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts new file mode 100644 index 0000000000..67109021fe --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordSubmitCodeError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; +import { ResetPasswordPasswordRequiredState } from "../state/ResetPasswordPasswordRequiredState.js"; + +/* + * Result of a reset password operation that requires a code. + */ +export class ResetPasswordSubmitCodeResult extends AuthFlowResultBase< + ResetPasswordSubmitCodeResultState, + ResetPasswordSubmitCodeError, + void +> { + /** + * Creates a new instance of ResetPasswordSubmitCodeResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordSubmitCodeResultState) { + super(state); + } + + /** + * Creates a new instance of ResetPasswordSubmitCodeResult with an error. + * @param error The error that occurred. + * @returns {ResetPasswordSubmitCodeResult} A new instance of ResetPasswordSubmitCodeResult with the error set. + */ + static createWithError(error: unknown): ResetPasswordSubmitCodeResult { + const result = new ResetPasswordSubmitCodeResult(new ResetPasswordFailedState()); + result.error = new ResetPasswordSubmitCodeError(ResetPasswordSubmitCodeResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordSubmitCodeResult & { state: ResetPasswordFailedState } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is ResetPasswordSubmitCodeResult & { state: ResetPasswordPasswordRequiredState } { + return this.state instanceof ResetPasswordPasswordRequiredState; + } +} + +/** + * The possible states for the ResetPasswordSubmitCodeResult. + * This includes: + * - ResetPasswordPasswordRequiredState: The reset password process requires a password. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordSubmitCodeResultState = ResetPasswordPasswordRequiredState | ResetPasswordFailedState; diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts new file mode 100644 index 0000000000..9a27a35ef5 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordSubmitPasswordError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordCompletedState } from "../state/ResetPasswordCompletedState.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; + +/* + * Result of a reset password operation that requires a password. + */ +export class ResetPasswordSubmitPasswordResult extends AuthFlowResultBase< + ResetPasswordSubmitPasswordResultState, + ResetPasswordSubmitPasswordError, + void +> { + /** + * Creates a new instance of ResetPasswordSubmitPasswordResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordSubmitPasswordResultState) { + super(state); + } + + static createWithError(error: unknown): ResetPasswordSubmitPasswordResult { + const result = new ResetPasswordSubmitPasswordResult(new ResetPasswordFailedState()); + result.error = new ResetPasswordSubmitPasswordError(ResetPasswordSubmitPasswordResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordSubmitPasswordResult & { state: ResetPasswordFailedState } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is ResetPasswordSubmitPasswordResult & { state: ResetPasswordCompletedState } { + return this.state instanceof ResetPasswordCompletedState; + } +} + +/** + * The possible states for the ResetPasswordSubmitPasswordResult. + * This includes: + * - ResetPasswordCompletedState: The reset password process has completed successfully. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordSubmitPasswordResultState = ResetPasswordCompletedState | ResetPasswordFailedState; diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts new file mode 100644 index 0000000000..d8ff795f36 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordResendCodeResult } from "../result/ResetPasswordResendCodeResult.js"; +import { ResetPasswordSubmitCodeResult } from "../result/ResetPasswordSubmitCodeResult.js"; +import { ResetPasswordCodeRequiredStateParameters } from "./ResetPasswordStateParameters.js"; +import { ResetPasswordState } from "./ResetPasswordState.js"; +import { ResetPasswordPasswordRequiredState } from "./ResetPasswordPasswordRequiredState.js"; + +/* + * Reset password code required state. + */ +export class ResetPasswordCodeRequiredState extends ResetPasswordState { + /** + * Submits a one-time passcode that the customer user received in their email in order to continue password reset flow. + * @param {string} code - The code to submit. + * @returns {Promise} The result of the operation. + */ + async submitCode(code: string): Promise { + try { + this.ensureCodeIsValid(code, this.stateParameters.codeLength); + + this.stateParameters.logger.verbose( + "Submitting code for password reset.", + this.stateParameters.correlationId, + ); + + const result = await this.stateParameters.resetPasswordClient.submitCode({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + code: code, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "Code is submitted for password reset.", + this.stateParameters.correlationId, + ); + + return new ResetPasswordSubmitCodeResult( + new ResetPasswordPasswordRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + resetPasswordClient: this.stateParameters.resetPasswordClient, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + }), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit code for password reset. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return ResetPasswordSubmitCodeResult.createWithError(error); + } + } + + /** + * Resends another one-time passcode if the previous one hasn't been verified + * @returns {Promise} The result of the operation. + */ + async resendCode(): Promise { + try { + this.stateParameters.logger.verbose( + "Resending code for password reset.", + this.stateParameters.correlationId, + ); + + const result = await this.stateParameters.resetPasswordClient.resendCode({ + clientId: this.stateParameters.config.auth.clientId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + username: this.stateParameters.username, + correlationId: this.stateParameters.correlationId, + continuationToken: this.stateParameters.continuationToken ?? "", + }); + + this.stateParameters.logger.verbose( + "Code is resent for password reset.", + this.stateParameters.correlationId, + ); + + return new ResetPasswordResendCodeResult( + new ResetPasswordCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + resetPasswordClient: this.stateParameters.resetPasswordClient, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + }), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to resend code for password reset. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return ResetPasswordResendCodeResult.createWithError(error); + } + } + + /** + * Gets the sent code length. + * @returns {number} The length of the code. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } +} diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordCompletedState.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordCompletedState.ts new file mode 100644 index 0000000000..a1533df316 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordCompletedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInContinuationState } from "../../../sign_in/auth_flow/state/SignInContinuationState.js"; + +/** + * Represents the state that indicates the successful completion of a password reset operation. + */ +export class ResetPasswordCompletedState extends SignInContinuationState {} diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordFailedState.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordFailedState.ts new file mode 100644 index 0000000000..a920970fbe --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordFailedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * State of a reset password operation that has failed. + */ +export class ResetPasswordFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts new file mode 100644 index 0000000000..e7a5170d6b --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordSubmitPasswordResult } from "../result/ResetPasswordSubmitPasswordResult.js"; +import { ResetPasswordState } from "./ResetPasswordState.js"; +import { ResetPasswordPasswordRequiredStateParameters } from "./ResetPasswordStateParameters.js"; +import { ResetPasswordCompletedState } from "./ResetPasswordCompletedState.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; + +/* + * Reset password password required state. + */ +export class ResetPasswordPasswordRequiredState extends ResetPasswordState { + /** + * Submits a new password for reset password flow. + * @param {string} password - The password to submit. + * @returns {Promise} The result of the operation. + */ + async submitNewPassword(password: string): Promise { + try { + this.ensurePasswordIsNotEmpty(password); + + this.stateParameters.logger.verbose( + "Submitting new password for password reset.", + this.stateParameters.correlationId, + ); + + const result = await this.stateParameters.resetPasswordClient.submitNewPassword({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + newPassword: password, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "New password is submitted for sign-up.", + this.stateParameters.correlationId, + ); + + return new ResetPasswordSubmitPasswordResult( + new ResetPasswordCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + username: this.stateParameters.username, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + signInScenario: SignInScenario.SignInAfterPasswordReset, + }), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit password for password reset. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return ResetPasswordSubmitPasswordResult.createWithError(error); + } + } +} diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordState.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordState.ts new file mode 100644 index 0000000000..509f064e3a --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordState.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowActionRequiredStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { ArgumentValidator } from "../../../core/utils/ArgumentValidator.js"; +import { ResetPasswordStateParameters } from "./ResetPasswordStateParameters.js"; + +/* + * Base state handler for reset password operation. + */ +export abstract class ResetPasswordState< + TParameters extends ResetPasswordStateParameters, +> extends AuthFlowActionRequiredStateBase { + /* + * Creates a new state for reset password operation. + * @param stateParameters - The state parameters for reset-password. + */ + constructor(stateParameters: TParameters) { + super(stateParameters); + + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "config", + this.stateParameters.config, + this.stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotEmptyString( + "username", + this.stateParameters.username, + this.stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "resetPasswordClient", + this.stateParameters.resetPasswordClient, + this.stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "signInClient", + this.stateParameters.signInClient, + this.stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "cacheClient", + this.stateParameters.cacheClient, + this.stateParameters.correlationId, + ); + } +} diff --git a/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordStateParameters.ts b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordStateParameters.ts new file mode 100644 index 0000000000..aa1d6a8c66 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/auth_flow/state/ResetPasswordStateParameters.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordClient } from "../../interaction_client/ResetPasswordClient.js"; +import { SignInClient } from "../../../sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { AuthFlowActionRequiredStateParameters } from "../../../core/auth_flow/AuthFlowState.js"; + +export interface ResetPasswordStateParameters extends AuthFlowActionRequiredStateParameters { + username: string; + resetPasswordClient: ResetPasswordClient; + signInClient: SignInClient; + cacheClient: CustomAuthSilentCacheClient; +} + +export type ResetPasswordPasswordRequiredStateParameters = ResetPasswordStateParameters; + +export interface ResetPasswordCodeRequiredStateParameters extends ResetPasswordStateParameters { + codeLength: number; +} diff --git a/lib/msal-custom-auth/src/reset_password/interaction_client/ResetPasswordClient.ts b/lib/msal-custom-auth/src/reset_password/interaction_client/ResetPasswordClient.ts new file mode 100644 index 0000000000..609f95866c --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/interaction_client/ResetPasswordClient.ts @@ -0,0 +1,255 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-browser"; +import { CustomAuthApiError } from "../../core/error/CustomAuthApiError.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import { CustomAuthApiErrorCode } from "../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { + ResetPasswordChallengeRequest, + ResetPasswordContinueRequest, + ResetPasswordPollCompletionRequest, + ResetPasswordStartRequest, + ResetPasswordSubmitRequest, +} from "../../core/network_client/custom_auth_api/types/ApiRequestTypes.js"; +import { PublicApiId } from "../../core/telemetry/PublicApiId.js"; +import { ArgumentValidator } from "../../core/utils/ArgumentValidator.js"; +import { + ChallengeType, + DefaultCustomAuthApiCodeLength, + PasswordResetPollingTimeoutInMs, + ResetPasswordPollStatus, +} from "../../CustomAuthConstants.js"; +import { + ResetPasswordResendCodeParams, + ResetPasswordStartParams, + ResetPasswordSubmitCodeParams, + ResetPasswordSubmitNewPasswordParams, +} from "./parameter/ResetPasswordParams.js"; +import { + ResetPasswordCodeRequiredResult, + ResetPasswordCompletedResult, + ResetPasswordPasswordRequiredResult, +} from "./result/ResetPasswordActionResult.js"; + +export class ResetPasswordClient extends CustomAuthInteractionClientBase { + /** + * Starts the password reset flow. + * @param parameters The parameters for starting the password reset flow. + * @returns The result of password reset start operation. + */ + async start(parameters: ResetPasswordStartParams): Promise { + const correlationId = parameters.correlationId; + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, correlationId); + + const apiId = PublicApiId.PASSWORD_RESET_START; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const startRequest: ResetPasswordStartRequest = { + challenge_type: this.getChallengeTypes(parameters.challengeType), + username: parameters.username, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose("Calling start endpoint for password reset flow.", correlationId); + + const startResponse = await this.customAuthApiClient.resetPasswordApi.start(startRequest); + + this.logger.verbose("Start endpoint for password reset returned successfully.", correlationId); + + const challengeRequest: ResetPasswordChallengeRequest = { + continuation_token: startResponse.continuation_token ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the code for password reset. + * @param parameters The parameters for submitting the code for password reset. + * @returns The result of submitting the code for password reset. + */ + async submitCode(parameters: ResetPasswordSubmitCodeParams): Promise { + const correlationId = parameters.correlationId; + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, correlationId); + ArgumentValidator.ensureArgumentIsNotEmptyString("parameters.code", parameters.code, correlationId); + + const apiId = PublicApiId.PASSWORD_RESET_SUBMIT_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const continueRequest: ResetPasswordContinueRequest = { + continuation_token: parameters.continuationToken, + oob: parameters.code, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose("Calling continue endpoint with code for password reset.", correlationId); + + const response = await this.customAuthApiClient.resetPasswordApi.continueWithCode(continueRequest); + + this.logger.verbose( + "Continue endpoint called successfully with code for password reset.", + response.correlation_id, + ); + + return new ResetPasswordPasswordRequiredResult(response.correlation_id, response.continuation_token ?? ""); + } + + /** + * Resends the another one-time passcode if the previous one hasn't been verified + * @param parameters The parameters for resending the code for password reset. + * @returns The result of resending the code for password reset. + */ + async resendCode(parameters: ResetPasswordResendCodeParams): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = PublicApiId.PASSWORD_RESET_RESEND_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const challengeRequest: ResetPasswordChallengeRequest = { + continuation_token: parameters.continuationToken, + challenge_type: this.getChallengeTypes(parameters.challengeType), + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the new password for password reset. + * @param parameters The parameters for submitting the new password for password reset. + * @returns The result of submitting the new password for password reset. + */ + async submitNewPassword(parameters: ResetPasswordSubmitNewPasswordParams): Promise { + const correlationId = parameters.correlationId; + + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, correlationId); + ArgumentValidator.ensureArgumentIsNotEmptyString( + "parameters.newPassword", + parameters.newPassword, + correlationId, + ); + + const apiId = PublicApiId.PASSWORD_RESET_SUBMIT_PASSWORD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const submitRequest: ResetPasswordSubmitRequest = { + continuation_token: parameters.continuationToken, + new_password: parameters.newPassword, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose("Calling submit endpoint with new password for password reset.", correlationId); + + const submitResponse = await this.customAuthApiClient.resetPasswordApi.submitNewPassword(submitRequest); + + this.logger.verbose("Submit endpoint called successfully with new password for password reset.", correlationId); + + return this.performPollCompletionRequest( + submitResponse.continuation_token ?? "", + submitResponse.poll_interval, + correlationId, + telemetryManager, + ); + } + + private async performChallengeRequest( + request: ResetPasswordChallengeRequest, + ): Promise { + const correlationId = request.correlationId; + this.logger.verbose("Calling challenge endpoint for password reset flow.", correlationId); + + const response = await this.customAuthApiClient.resetPasswordApi.requestChallenge(request); + + this.logger.verbose("Challenge endpoint for password reset returned successfully.", correlationId); + + if (response.challenge_type === ChallengeType.OOB) { + // Code is required + this.logger.verbose("Code is required for password reset flow.", correlationId); + + return new ResetPasswordCodeRequiredResult( + response.correlation_id, + response.continuation_token ?? "", + response.challenge_channel ?? "", + response.challenge_target_label ?? "", + response.code_length ?? DefaultCustomAuthApiCodeLength, + response.binding_method ?? "", + ); + } + + this.logger.error( + `Unsupported challenge type '${response.challenge_type}' returned from challenge endpoint for password reset.`, + correlationId, + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + `Unsupported challenge type '${response.challenge_type}'.`, + correlationId, + ); + } + + private async performPollCompletionRequest( + continuationToken: string, + pollInterval: number, + correlationId: string, + telemetryManager: ServerTelemetryManager, + ): Promise { + const startTime = performance.now(); + + while (performance.now() - startTime < PasswordResetPollingTimeoutInMs) { + const pollRequest: ResetPasswordPollCompletionRequest = { + continuation_token: continuationToken, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose("Calling the poll completion endpoint for password reset flow.", correlationId); + + const pollResponse = await this.customAuthApiClient.resetPasswordApi.pollCompletion(pollRequest); + + this.logger.verbose("Poll completion endpoint for password reset returned successfully.", correlationId); + + if (pollResponse.status === ResetPasswordPollStatus.SUCCEEDED) { + return new ResetPasswordCompletedResult( + pollResponse.correlation_id, + pollResponse.continuation_token ?? "", + ); + } else if (pollResponse.status === ResetPasswordPollStatus.FAILED) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.PASSWORD_CHANGE_FAILED, + "Password is failed to be reset.", + pollResponse.correlation_id, + ); + } + + this.logger.verbose( + `Poll completion endpoint for password reset is not started or in progress, waiting ${pollInterval} seconds for next check.`, + correlationId, + ); + + await this.delay(pollInterval * 1000); + } + + this.logger.error("Password reset flow has timed out.", correlationId); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.PASSWORD_RESET_TIMEOUT, + "Password reset flow has timed out.", + correlationId, + ); + } + + private async delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/lib/msal-custom-auth/src/reset_password/interaction_client/parameter/ResetPasswordParams.ts b/lib/msal-custom-auth/src/reset_password/interaction_client/parameter/ResetPasswordParams.ts new file mode 100644 index 0000000000..4597d00679 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/interaction_client/parameter/ResetPasswordParams.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface ResetPasswordParamsBase { + clientId: string; + challengeType: Array; + username: string; + correlationId: string; +} + +export type ResetPasswordStartParams = ResetPasswordParamsBase; + +export interface ResetPasswordResendCodeParams extends ResetPasswordParamsBase { + continuationToken: string; +} + +export interface ResetPasswordSubmitCodeParams extends ResetPasswordParamsBase { + continuationToken: string; + code: string; +} + +export interface ResetPasswordSubmitNewPasswordParams extends ResetPasswordParamsBase { + continuationToken: string; + newPassword: string; +} diff --git a/lib/msal-custom-auth/src/reset_password/interaction_client/result/ResetPasswordActionResult.ts b/lib/msal-custom-auth/src/reset_password/interaction_client/result/ResetPasswordActionResult.ts new file mode 100644 index 0000000000..1f4b676aa9 --- /dev/null +++ b/lib/msal-custom-auth/src/reset_password/interaction_client/result/ResetPasswordActionResult.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +class ResetPasswordResultBase { + constructor( + public correlationId: string, + public continuationToken: string, + ) {} +} + +export class ResetPasswordCodeRequiredResult extends ResetPasswordResultBase { + constructor( + correlationId: string, + continuationToken: string, + public challengeChannel: string, + public challengeTargetLabel: string, + public codeLength: number, + public bindingMethod: string, + ) { + super(correlationId, continuationToken); + } +} + +export class ResetPasswordPasswordRequiredResult extends ResetPasswordResultBase {} + +export class ResetPasswordCompletedResult extends ResetPasswordResultBase {} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/error_type/SignInError.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/error_type/SignInError.ts new file mode 100644 index 0000000000..8a78e6f0bf --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/error_type/SignInError.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; + +export class SignInError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the user not found. + * @returns {boolean} True if the error is due to the user not found, false otherwise. + */ + isUserNotFound(): boolean { + return this.isUserNotFoundError(); + } + + /** + * Checks if the error is due to the username being invalid. + * @returns {boolean} True if the error is due to the username being invalid, false otherwise. + */ + isInvalidUsername(): boolean { + return this.isUserInvalidError(); + } + + /** + * Checks if the error is due to the password being incorrect. + * @returns {boolean} True if the error is due to the password being incorrect, false otherwise. + */ + isIncorrectPassword(): boolean { + return this.isPasswordIncorrectError(); + } + + /** + * Checks if the error is due to the provided challenge type not being supported. + * @returns {boolean} True if the error is due to the provided challenge type not being supported, false otherwise. + */ + isUnsupportedChallengeType(): boolean { + return this.isUnsupportedChallengeTypeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignInSubmitPasswordError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the password being incorrect. + * @returns {boolean} True if the error is due to the password being incorrect, false otherwise. + */ + isIncorrectPassword(): boolean { + return this.isPasswordIncorrectError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignInSubmitCodeError extends AuthFlowErrorBase { + /** + * Checks if the provided code is invalid. + * @returns {boolean} True if the provided code is invalid, false otherwise. + */ + isInvalidCode(): boolean { + return this.isInvalidCodeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignInResendCodeError extends AuthFlowErrorBase { + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInResendCodeResult.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInResendCodeResult.ts new file mode 100644 index 0000000000..6466da9df2 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInResendCodeResult.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignInResendCodeError } from "../error_type/SignInError.js"; +import { SignInCodeRequiredState } from "../state/SignInCodeRequiredState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; + +/* + * Result of resending code in a sign-in operation. + */ +export class SignInResendCodeResult extends AuthFlowResultBase< + SignInResendCodeResultState, + SignInResendCodeError, + void +> { + /** + * Creates a new instance of SignInResendCodeResult. + * @param state The state of the result. + */ + constructor(state: SignInResendCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignInResendCodeResult with an error. + * @param error The error that occurred. + * @returns {SignInResendCodeResult} A new instance of SignInResendCodeResult with the error set. + */ + static createWithError(error: unknown): SignInResendCodeResult { + const result = new SignInResendCodeResult(new SignInFailedState()); + result.error = new SignInResendCodeError(SignInResendCodeResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInResendCodeResult & { state: SignInFailedState } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignInResendCodeResult & { state: SignInCodeRequiredState } { + return this.state instanceof SignInCodeRequiredState; + } +} + +/** + * The possible states for the SignInResendCodeResult. + * This includes: + * - SignInCodeRequiredState: The sign-in process requires a code. + * - SignInFailedState: The sign-in process has failed. + */ +export type SignInResendCodeResultState = SignInCodeRequiredState | SignInFailedState; diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInSubmitCodeResult.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInSubmitCodeResult.ts new file mode 100644 index 0000000000..48e3a74f3d --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInSubmitCodeResult.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignInSubmitCodeError } from "../error_type/SignInError.js"; +import { SignInCompletedState } from "../state/SignInCompletedState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; + +/* + * Result of a sign-in operation that requires a code. + */ +export class SignInSubmitCodeResult extends AuthFlowResultBase< + SignInSubmitCodeResultState, + SignInSubmitCodeError, + void +> { + /** + * Creates a new instance of SignInSubmitCodeResult. + * @param state The state of the result. + */ + constructor(state: SignInSubmitCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignInSubmitCodeResult with an error. + * @param error The error that occurred. + * @returns {SignInSubmitCodeResult} A new instance of SignInSubmitCodeResult with the error set. + */ + static createWithError(error: unknown): SignInSubmitCodeResult { + const result = new SignInSubmitCodeResult(new SignInFailedState()); + result.error = new SignInSubmitCodeError(SignInSubmitCodeResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInSubmitCodeResult & { state: SignInFailedState } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignInSubmitCodeResult & { state: SignInCompletedState } { + return this.state instanceof SignInCompletedState; + } +} + +/** + * The possible states for the SignInSubmitCodeResult. + * This includes: + * - SignInCompletedState: The sign-in process has completed successfully. + * - SignInFailedState: The sign-in process has failed. + */ +export type SignInSubmitCodeResultState = SignInCompletedState | SignInFailedState; diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts new file mode 100644 index 0000000000..217503442d --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignInSubmitPasswordError } from "../error_type/SignInError.js"; +import { SignInCodeRequiredState } from "../state/SignInCodeRequiredState.js"; +import { SignInCompletedState } from "../state/SignInCompletedState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; + +/* + * Result of a sign-in operation that requires a password. + */ +export class SignInSubmitPasswordResult extends AuthFlowResultBase< + SignInSubmitPasswordResultState, + SignInSubmitPasswordError, + void +> { + /** + * Creates a new instance of SignInSubmitPasswordResult. + * @param state The state of the result. + */ + constructor(state: SignInSubmitPasswordResultState) { + super(state); + } + + /** + * Creates a new instance of SignInSubmitPasswordResult with an error. + * @param error The error that occurred. + * @returns {SignInSubmitPasswordResult} A new instance of SignInSubmitPasswordResult with the error set. + */ + static createWithError(error: unknown): SignInSubmitPasswordResult { + const result = new SignInSubmitPasswordResult(new SignInFailedState()); + result.error = new SignInSubmitPasswordError(SignInSubmitPasswordResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInSubmitPasswordResult & { state: SignInFailedState } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignInSubmitPasswordResult & { state: SignInCodeRequiredState } { + return this.state instanceof SignInCodeRequiredState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignInSubmitPasswordResult & { state: SignInCompletedState } { + return this.state instanceof SignInCompletedState; + } +} + +/** + * The possible states for the SignInSubmitPasswordResult. + * This includes: + * - SignInCodeRequiredState: The sign-in process requires a code. + * - SignInCompletedState: The sign-in process has completed successfully. + * - SignInFailedState: The sign-in process has failed. + */ +export type SignInSubmitPasswordResultState = SignInCodeRequiredState | SignInCompletedState | SignInFailedState; diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInCodeRequiredState.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInCodeRequiredState.ts new file mode 100644 index 0000000000..c6abec9439 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInCodeRequiredState.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { SignInState } from "./SignInState.js"; +import { SignInCodeRequiredStateParameters } from "./SignInStateParameters.js"; +import { SignInSubmitCodeResult } from "../result/SignInSubmitCodeResult.js"; +import { SignInCompletedState } from "./SignInCompletedState.js"; +import { SignInResendCodeResult } from "../result/SignInResendCodeResult.js"; +import { SignInCompletedResult, SignInCodeRequiredResult } from "../../interaction_client/result/SignInActionResult.js"; + +/* + * Sign-in code required state. + */ +export class SignInCodeRequiredState extends SignInState { + /** + * Submit one-time passcode to continue sign-in flow. + * @param {string} code - The code to submit. + * @returns {Promise} The result of the operation. + */ + async submitCode(code: string): Promise { + try { + this.ensureCodeIsValid(code, this.stateParameters.codeLength); + + this.stateParameters.logger.verbose("Submitting code for sign-in.", this.stateParameters.correlationId); + + const result = await this.stateParameters.signInClient.submitCode({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + code: code, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose("Code submitted for sign-in.", this.stateParameters.correlationId); + + if (result instanceof SignInCompletedResult) { + // Sign-in completed + this.stateParameters.logger.verbose("Sign-in completed.", this.stateParameters.correlationId); + + return new SignInSubmitCodeResult( + new SignInCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + }), + ); + } + + return SignInSubmitCodeResult.createWithError( + new UnexpectedError("Unknown sign-in result type.", this.stateParameters.correlationId), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit code for sign in. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignInSubmitCodeResult.createWithError(error); + } + } + + /** + * Resends another one-time passcode for sign-in flow if the previous one hasn't been verified. + * @returns {Promise} The result of the operation. + */ + async resendCode(): Promise { + try { + this.stateParameters.logger.verbose("Resending code for sign-in.", this.stateParameters.correlationId); + + const result = await this.stateParameters.signInClient.resendCode({ + clientId: this.stateParameters.config.auth.clientId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + username: this.stateParameters.username, + correlationId: this.stateParameters.correlationId, + continuationToken: this.stateParameters.continuationToken ?? "", + }); + + this.stateParameters.logger.verbose("Code resent for sign-in.", this.stateParameters.correlationId); + + return new SignInResendCodeResult( + new SignInCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + codeResendInterval: result.interval, + }), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to resend code for sign in. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignInResendCodeResult.createWithError(error); + } + } + + /** + * Gets the sent code length. + * @returns {number} The length of the code. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } + + /** + * Gets the interval in seconds for the code to be resent. + * @returns {number} The interval in seconds for the code to be resent. + */ + getCodeResendInterval(): number { + return this.stateParameters.codeResendInterval; + } +} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInCompletedState.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInCompletedState.ts new file mode 100644 index 0000000000..d8e67c50b2 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInCompletedState.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { SignInStateParameters } from "./SignInStateParameters.js"; + +/** + * Represents the state of a sign-in operation that has been completed successfully. + */ +export class SignInCompletedState extends AuthFlowStateBase { + protected readonly stateParameters: SignInStateParameters; + + /** + * Creates a new instance of SignInCompletedState. + * @param stateParameters The parameters for the completed sign-in state. + */ + constructor(stateParameters: SignInStateParameters) { + super(); + this.stateParameters = stateParameters; + + // Validate required parameters + if (!stateParameters.signInClient) { + throw new Error("signInClient is required for SignInCompletedState"); + } + } + + /** + * Gets the username associated with the completed sign-in. + * @returns {string} The username. + */ + getUsername(): string { + return this.stateParameters.username; + } + + /** + * Gets the continuation token associated with the completed sign-in. + * @returns {string} The continuation token. + */ + getContinuationToken(): string { + return this.stateParameters.continuationToken ?? ""; + } + + /** + * Gets the client that can be used to perform further sign-in operations. + * @returns {SignInClient} The sign-in client. + */ + getSignInClient(): any { + return this.stateParameters.signInClient; + } +} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInFailedState.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInFailedState.ts new file mode 100644 index 0000000000..1382acde61 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInFailedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * Represents the state of a sign-in operation that has failed. + */ +export class SignInFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInPasswordRequiredState.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInPasswordRequiredState.ts new file mode 100644 index 0000000000..197fbf111d --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInPasswordRequiredState.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { SignInState } from "./SignInState.js"; +import { SignInPasswordRequiredStateParameters } from "./SignInStateParameters.js"; +import { SignInCompletedState } from "./SignInCompletedState.js"; +import { SignInCodeRequiredState } from "./SignInCodeRequiredState.js"; +import { SignInSubmitPasswordResult } from "../result/SignInSubmitPasswordResult.js"; +import { SignInCompletedResult, SignInCodeRequiredResult } from "../../interaction_client/result/SignInActionResult.js"; + +/* + * Sign-in password required state. + */ +export class SignInPasswordRequiredState extends SignInState { + /** + * Submits a password for sign-in. + * @param {string} password - The password to submit. + * @returns {Promise} The result of the operation. + */ + async submitPassword(password: string): Promise { + try { + this.ensurePasswordIsNotEmpty(password); + + this.stateParameters.logger.verbose("Submitting password for sign-in.", this.stateParameters.correlationId); + + const result = await this.stateParameters.signInClient.submitPassword({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + password: password, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose("Password submitted for sign-in.", this.stateParameters.correlationId); + + if (result instanceof SignInCodeRequiredResult) { + // Code required + this.stateParameters.logger.verbose("Code required for sign-in.", this.stateParameters.correlationId); + + return new SignInSubmitPasswordResult( + new SignInCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + codeResendInterval: result.interval, + }), + ); + } else if (result instanceof SignInCompletedResult) { + // Sign-in completed + this.stateParameters.logger.verbose("Sign-in completed.", this.stateParameters.correlationId); + + return new SignInSubmitPasswordResult( + new SignInCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + }), + ); + } + + return SignInSubmitPasswordResult.createWithError( + new UnexpectedError("Unknown sign-in result type.", this.stateParameters.correlationId), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit password for sign in. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignInSubmitPasswordResult.createWithError(error); + } + } +} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInState.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInState.ts new file mode 100644 index 0000000000..2ae8f5c8ec --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInState.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ArgumentValidator } from "../../../core/utils/ArgumentValidator.js"; +import { AuthFlowActionRequiredStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { SignInStateParameters } from "./SignInStateParameters.js"; + +/* + * Base state handler for sign-in flow. + */ +export abstract class SignInState< + TParameters extends SignInStateParameters, +> extends AuthFlowActionRequiredStateBase { + /* + * Creates a new SignInState. + * @param stateParameters - The state parameters for sign-in. + */ + constructor(stateParameters: TParameters) { + super(stateParameters); + + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "config", + stateParameters.config, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotEmptyString( + "username", + stateParameters.username, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "signInClient", + stateParameters.signInClient, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotEmptyString( + "continuationToken", + stateParameters.continuationToken, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "cacheClient", + stateParameters.cacheClient, + stateParameters.correlationId, + ); + } +} diff --git a/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInStateParameters.ts b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInStateParameters.ts new file mode 100644 index 0000000000..27563f9427 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/auth_flow/state/SignInStateParameters.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInClient } from "../../interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { AuthFlowActionRequiredStateParameters } from "../../../core/auth_flow/AuthFlowState.js"; +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +export interface SignInStateParameters extends AuthFlowActionRequiredStateParameters { + username: string; + signInClient: SignInClient; + cacheClient: CustomAuthSilentCacheClient; +} + +export type SignInPasswordRequiredStateParameters = SignInStateParameters; + +export interface SignInCodeRequiredStateParameters extends SignInStateParameters { + codeLength: number; + codeResendInterval: number; +} + +export interface SignInAttributesRequiredStateParameters extends SignInStateParameters { + requiredAttributes: Array; +} diff --git a/lib/msal-custom-auth/src/sign_in/interaction_client/SignInClient.ts b/lib/msal-custom-auth/src/sign_in/interaction_client/SignInClient.ts new file mode 100644 index 0000000000..b60931a1b0 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/interaction_client/SignInClient.ts @@ -0,0 +1,294 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-browser"; +import { CustomAuthApiError } from "../../core/error/CustomAuthApiError.js"; +import { CustomAuthApiErrorCode } from "../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { UnexpectedError } from "../../core/error/UnexpectedError.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import { PublicApiId } from "../../core/telemetry/PublicApiId.js"; +import { ArgumentValidator } from "../../core/utils/ArgumentValidator.js"; +import { + ChallengeType, + DefaultCustomAuthApiCodeLength, + DefaultCustomAuthApiCodeResendIntervalInSec, +} from "../../CustomAuthConstants.js"; +import { + SignInParamsBase, + SignInStartParams, + SignInSubmitCodeParams, + SignInSubmitPasswordParams, + SignInResendCodeParams, +} from "./parameter/SignInParams.js"; +import { + SignInAttributesRequiredResult, + SignInCodeRequiredResult, + SignInCompletedResult, + SignInPasswordRequiredResult, +} from "./result/SignInActionResult.js"; + +export class SignInClient extends CustomAuthInteractionClientBase { + /** + * Starts the sign in flow. + * @param parameters The parameters for the sign in start action. + * @returns The result of the sign in start action. + */ + async start(parameters: SignInStartParams): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = !parameters.password + ? PublicApiId.SIGN_IN_WITH_CODE_START + : PublicApiId.SIGN_IN_WITH_PASSWORD_START; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const startRequest = { + username: parameters.username, + password: parameters.password, + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: parameters.correlationId, + }; + + this.logger.verbose("Initiating sign in.", parameters.correlationId); + + const startResponse = await this.customAuthApiClient.signInApi.initiate(startRequest); + + this.logger.verbose("Sign in initiated.", parameters.correlationId); + + const challengeRequest = { + continuation_token: startResponse.continuation_token ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: startResponse.correlation_id, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the code for the sign in flow. + * @param parameters The parameters for the sign in submit code action. + * @returns The result of the sign in submit code action. + */ + async submitCode( + parameters: SignInSubmitCodeParams, + ): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = PublicApiId.SIGN_IN_SUBMIT_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const requestSubmitCode = { + continuation_token: parameters.continuationToken, + oob: parameters.code, + scope: "openid profile", // Default scopes required for sign-in + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performContinueRequest( + "SignInClient.submitCode", + parameters, + telemetryManager, + () => this.customAuthApiClient.signInApi.requestTokensWithOob(requestSubmitCode), + parameters.correlationId, + ); + + if (result instanceof SignInCodeRequiredResult) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'oob' is invalid after submitting code for sign in.", + parameters.correlationId, + ); + } + + return result; + } + + /** + * Submits the password for the sign in flow. + * @param parameters The parameters for the sign in submit password action. + * @returns The result of the sign in submit password action. + */ + async submitPassword( + parameters: SignInSubmitPasswordParams, + ): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = PublicApiId.SIGN_IN_SUBMIT_PASSWORD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const requestSubmitPassword = { + continuation_token: parameters.continuationToken, + password: parameters.password, + scope: "openid profile", // Default scopes required for sign-in + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performContinueRequest( + "SignInClient.submitPassword", + parameters, + telemetryManager, + () => this.customAuthApiClient.signInApi.requestTokensWithPassword(requestSubmitPassword), + parameters.correlationId, + ); + + return result; + } + + /** + * Resends the code for the sign in flow. + * @param parameters The parameters for the sign in resend code action. + * @returns The result of the sign in resend code action. + */ + async resendCode(parameters: SignInResendCodeParams): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = PublicApiId.SIGN_IN_RESEND_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const challengeRequest = { + continuation_token: parameters.continuationToken ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performChallengeRequest(challengeRequest); + + if (result instanceof SignInPasswordRequiredResult) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'password' is invalid after resending code for sign in.", + parameters.correlationId, + ); + } + + return result; + } + + private async performChallengeRequest( + request: any, + ): Promise { + this.logger.verbose("Calling challenge endpoint for sign in.", request.correlationId); + + const challengeResponse = await this.customAuthApiClient.signInApi.requestChallenge(request); + + this.logger.verbose("Challenge endpoint called for sign in.", request.correlationId); + + if (challengeResponse.challenge_type === ChallengeType.OOB) { + // Code is required + this.logger.verbose("Challenge type is oob for sign in.", request.correlationId); + + return new SignInCodeRequiredResult( + challengeResponse.correlation_id, + challengeResponse.continuation_token ?? "", + challengeResponse.challenge_channel ?? "", + challengeResponse.challenge_target_label ?? "", + challengeResponse.code_length ?? DefaultCustomAuthApiCodeLength, + challengeResponse.code_length ?? DefaultCustomAuthApiCodeLength, + challengeResponse.binding_method ?? "", + ); + } + + if (challengeResponse.challenge_type === ChallengeType.PASSWORD) { + // Password is required + this.logger.verbose("Challenge type is password for sign in.", request.correlationId); + + return new SignInPasswordRequiredResult( + challengeResponse.correlation_id, + challengeResponse.continuation_token ?? "", + ); + } + + this.logger.error( + `Unsupported challenge type '${challengeResponse.challenge_type}' for sign in.`, + request.correlationId, + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + `Unsupported challenge type '${challengeResponse.challenge_type}'.`, + request.correlationId, + ); + } + + private async performContinueRequest( + callerName: string, + requestParams: SignInParamsBase, + telemetryManager: ServerTelemetryManager, + responseGetter: () => Promise, + requestCorrelationId: string, + ): Promise { + this.logger.verbose(`${callerName} is calling continue endpoint for sign in.`, requestCorrelationId); + + try { + const response = await responseGetter(); + + this.logger.verbose(`Continue endpoint called by ${callerName} for sign in.`, requestCorrelationId); + + return new SignInCompletedResult(requestCorrelationId, response.continuation_token ?? ""); + } catch (error) { + if (error instanceof CustomAuthApiError) { + return this.handleContinueResponseError( + error, + error.correlationId ?? requestCorrelationId, + requestParams, + telemetryManager, + ); + } else { + this.logger.errorPii( + `${callerName} failed to call continue endpoint for sign in. Error: ${error}`, + requestCorrelationId, + ); + + throw new UnexpectedError(error, requestCorrelationId); + } + } + } + + private async handleContinueResponseError( + responseError: CustomAuthApiError, + correlationId: string, + requestParams: SignInParamsBase, + telemetryManager: ServerTelemetryManager, + ): Promise { + if ( + responseError.error === CustomAuthApiErrorCode.CREDENTIAL_REQUIRED && + !!responseError.errorCodes && + responseError.errorCodes.includes(55103) + ) { + // Credential is required + this.logger.verbose("The credential is required in the sign in flow.", correlationId); + + const continuationToken = this.readContinuationTokenFromResponeError(responseError); + + // Call the challenge endpoint to ensure the password challenge type is supported. + const challengeRequest = { + continuation_token: continuationToken, + challenge_type: this.getChallengeTypes(requestParams.challengeType), + telemetryManager, + correlationId, + }; + + return this.performChallengeRequest(challengeRequest); + } + + throw responseError; + } + + private readContinuationTokenFromResponeError(responseError: CustomAuthApiError): string { + if (!responseError.continuationToken) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.CONTINUATION_TOKEN_MISSING, + "Continuation token is missing in the response body", + responseError.correlationId, + ); + } + + return responseError.continuationToken; + } +} diff --git a/lib/msal-custom-auth/src/sign_in/interaction_client/parameter/SignInParams.ts b/lib/msal-custom-auth/src/sign_in/interaction_client/parameter/SignInParams.ts new file mode 100644 index 0000000000..6785d5ed80 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/interaction_client/parameter/SignInParams.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface SignInParamsBase { + clientId: string; + challengeType: Array; + username: string; + correlationId: string; +} + +export interface SignInStartParams extends SignInParamsBase { + password?: string; +} + +export interface SignInResendCodeParams extends SignInParamsBase { + continuationToken: string; +} + +export interface SignInContinueParams extends SignInParamsBase { + continuationToken: string; +} + +export interface SignInSubmitCodeParams extends SignInContinueParams { + code: string; +} + +export interface SignInSubmitPasswordParams extends SignInContinueParams { + password: string; +} diff --git a/lib/msal-custom-auth/src/sign_in/interaction_client/result/SignInActionResult.ts b/lib/msal-custom-auth/src/sign_in/interaction_client/result/SignInActionResult.ts new file mode 100644 index 0000000000..8cb406195f --- /dev/null +++ b/lib/msal-custom-auth/src/sign_in/interaction_client/result/SignInActionResult.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +class SignInResultBase { + constructor( + public correlationId: string, + public continuationToken: string, + ) {} +} + +export class SignInCompletedResult extends SignInResultBase {} + +export class SignInPasswordRequiredResult extends SignInResultBase {} + +export class SignInCodeRequiredResult extends SignInResultBase { + constructor( + correlationId: string, + continuationToken: string, + public challengeChannel: string, + public challengeTargetLabel: string, + public codeLength: number, + public interval: number, + public bindingMethod: string, + ) { + super(correlationId, continuationToken); + } +} + +export class SignInAttributesRequiredResult extends SignInResultBase { + constructor( + correlationId: string, + continuationToken: string, + public requiredAttributes: Array, + ) { + super(correlationId, continuationToken); + } +} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/error_type/SignUpError.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/error_type/SignUpError.ts new file mode 100644 index 0000000000..c8879c80a0 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/error_type/SignUpError.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; + +export class SignUpError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the user already exists. + * @returns {boolean} True if the error is due to the user already exists, false otherwise. + */ + isUserAlreadyExists(): boolean { + return this.isUserAlreadyExistsError(); + } + + /** + * Checks if the error is due to the username is invalid. + * @returns {boolean} True if the error is due to the user is invalid, false otherwise. + */ + isInvalidUsername(): boolean { + return this.isUserInvalidError(); + } + + /** + * Checks if the error is due to the password being invalid or incorrect. + * @returns {boolean} True if the error is due to the password being invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return this.isInvalidNewPasswordError(); + } + + /** + * Checks if the error is due to the required attributes are missing. + * @returns {boolean} True if the error is due to the required attributes are missing, false otherwise. + */ + isMissingRequiredAttributes(): boolean { + return this.isAttributeRequiredError(); + } + + /** + * Checks if the error is due to the attributes validation failed. + * @returns {boolean} True if the error is due to the attributes validation failed, false otherwise. + */ + isAttributesValidationFailed(): boolean { + return this.isAttributeValidationFailedError(); + } + + /** + * Checks if the error is due to the provided challenge type is not supported. + * @returns {boolean} True if the error is due to the provided challenge type is not supported, false otherwise. + */ + isUnsupportedChallengeType(): boolean { + return this.isUnsupportedChallengeTypeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpSubmitPasswordError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the password being invalid or incorrect. + * @returns {boolean} True if the error is due to the password being invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return this.isPasswordIncorrectError() || this.isInvalidNewPasswordError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpSubmitCodeError extends AuthFlowErrorBase { + /** + * Checks if the provided code is invalid. + * @returns {boolean} True if the provided code is invalid, false otherwise. + */ + isInvalidCode(): boolean { + return this.isInvalidCodeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpSubmitAttributesError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the required attributes are missing. + * @returns {boolean} True if the error is due to the required attributes are missing, false otherwise. + */ + isMissingRequiredAttributes(): boolean { + return this.isAttributeRequiredError(); + } + + /** + * Checks if the error is due to the attributes validation failed. + * @returns {boolean} True if the error is due to the attributes validation failed, false otherwise. + */ + isAttributesValidationFailed(): boolean { + return this.isAttributeValidationFailedError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpResendCodeError extends AuthFlowErrorBase { + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpResendCodeResult.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpResendCodeResult.ts new file mode 100644 index 0000000000..bff897bfea --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpResendCodeResult.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpResendCodeError } from "../error_type/SignUpError.js"; +import { SignUpCodeRequiredState } from "../state/SignUpCodeRequiredState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of resending code in a sign-up operation. + */ +export class SignUpResendCodeResult extends AuthFlowResultBase< + SignUpResendCodeResultState, + SignUpResendCodeError, + void +> { + /** + * Creates a new instance of SignUpResendCodeResult. + * @param state The state of the result. + */ + constructor(state: SignUpResendCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpResendCodeResult with an error. + * @param error The error that occurred. + * @returns {SignUpResendCodeResult} A new instance of SignUpResendCodeResult with the error set. + */ + static createWithError(error: unknown): SignUpResendCodeResult { + const result = new SignUpResendCodeResult(new SignUpFailedState()); + result.error = new SignUpResendCodeError(SignUpResendCodeResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpResendCodeResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignUpResendCodeResult & { state: SignUpCodeRequiredState } { + /* + * The instanceof operator couldn't be used here to check the state type since the circular dependency issue. + * So we are using the constructor name to check the state type. + */ + return this.state.constructor?.name === "SignUpCodeRequiredState"; + } +} + +/** + * The possible states for the SignUpResendCodeResult. + * This includes: + * - SignUpCodeRequiredState: The sign-up process requires a code. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpResendCodeResultState = SignUpCodeRequiredState | SignUpFailedState; diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpResult.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpResult.ts new file mode 100644 index 0000000000..15540ee3d7 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpResult.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpError } from "../error_type/SignUpError.js"; +import { SignUpAttributesRequiredState } from "../state/SignUpAttributesRequiredState.js"; +import { SignUpCodeRequiredState } from "../state/SignUpCodeRequiredState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; +import { SignUpPasswordRequiredState } from "../state/SignUpPasswordRequiredState.js"; + +/* + * Result of a sign-up operation. + */ +export class SignUpResult extends AuthFlowResultBase { + /** + * Creates a new instance of SignUpResult. + * @param state The state of the result. + */ + constructor(state: SignUpResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpResult with an error. + * @param error The error that occurred. + * @returns {SignUpResult} A new instance of SignUpResult with the error set. + */ + static createWithError(error: unknown): SignUpResult { + const result = new SignUpResult(new SignUpFailedState()); + result.error = new SignUpError(SignUpResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignUpResult & { state: SignUpCodeRequiredState } { + return this.state instanceof SignUpCodeRequiredState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is SignUpResult & { state: SignUpPasswordRequiredState } { + return this.state instanceof SignUpPasswordRequiredState; + } + + /** + * Checks if the result is in an attributes required state. + */ + isAttributesRequired(): this is SignUpResult & { state: SignUpAttributesRequiredState } { + return this.state instanceof SignUpAttributesRequiredState; + } +} + +/** + * The possible states for the SignUpResult. + * This includes: + * - SignUpCodeRequiredState: The sign-up process requires a code. + * - SignUpPasswordRequiredState: The sign-up process requires a password. + * - SignUpAttributesRequiredState: The sign-up process requires additional attributes. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpResultState = + | SignUpCodeRequiredState + | SignUpPasswordRequiredState + | SignUpAttributesRequiredState + | SignUpFailedState; diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts new file mode 100644 index 0000000000..3e65cf745f --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpSubmitAttributesError } from "../error_type/SignUpError.js"; +import { SignUpCompletedState } from "../state/SignUpCompletedState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of a sign-up operation that requires attributes. + */ +export class SignUpSubmitAttributesResult extends AuthFlowResultBase< + SignUpSubmitAttributesResultState, + SignUpSubmitAttributesError, + void +> { + /** + * Creates a new instance of SignUpSubmitAttributesResult. + * @param state The state of the result. + */ + constructor(state: SignUpSubmitAttributesResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpSubmitAttributesResult with an error. + * @param error The error that occurred. + * @returns {SignUpSubmitAttributesResult} A new instance of SignUpSubmitAttributesResult with the error set. + */ + static createWithError(error: unknown): SignUpSubmitAttributesResult { + const result = new SignUpSubmitAttributesResult(new SignUpFailedState()); + result.error = new SignUpSubmitAttributesError(SignUpSubmitAttributesResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpSubmitAttributesResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignUpSubmitAttributesResult & { state: SignUpCompletedState } { + return this.state instanceof SignUpCompletedState; + } +} + +/** + * The possible states for the SignUpSubmitAttributesResult. + * This includes: + * - SignUpCompletedState: The sign-up process has completed successfully. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpSubmitAttributesResultState = SignUpCompletedState | SignUpFailedState; diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts new file mode 100644 index 0000000000..13e16a9446 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpSubmitCodeError } from "../error_type/SignUpError.js"; +import { SignUpAttributesRequiredState } from "../state/SignUpAttributesRequiredState.js"; +import { SignUpPasswordRequiredState } from "../state/SignUpPasswordRequiredState.js"; +import { SignUpCompletedState } from "../state/SignUpCompletedState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of a sign-up operation that requires a code. + */ +export class SignUpSubmitCodeResult extends AuthFlowResultBase< + SignUpSubmitCodeResultState, + SignUpSubmitCodeError, + void +> { + /** + * Creates a new instance of SignUpSubmitCodeResult. + * @param state The state of the result. + */ + constructor(state: SignUpSubmitCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpSubmitCodeResult with an error. + * @param error The error that occurred. + * @returns {SignUpSubmitCodeResult} A new instance of SignUpSubmitCodeResult with the error set. + */ + static createWithError(error: unknown): SignUpSubmitCodeResult { + const result = new SignUpSubmitCodeResult(new SignUpFailedState()); + result.error = new SignUpSubmitCodeError(SignUpSubmitCodeResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpSubmitCodeResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is SignUpSubmitCodeResult & { state: SignUpPasswordRequiredState } { + return this.state instanceof SignUpPasswordRequiredState; + } + + /** + * Checks if the result is in an attributes required state. + */ + isAttributesRequired(): this is SignUpSubmitCodeResult & { state: SignUpAttributesRequiredState } { + return this.state instanceof SignUpAttributesRequiredState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignUpSubmitCodeResult & { state: SignUpCompletedState } { + return this.state instanceof SignUpCompletedState; + } +} + +/** + * The possible states for the SignUpSubmitCodeResult. + * This includes: + * - SignUpPasswordRequiredState: The sign-up process requires a password. + * - SignUpAttributesRequiredState: The sign-up process requires additional attributes. + * - SignUpCompletedState: The sign-up process has completed successfully. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpSubmitCodeResultState = + | SignUpPasswordRequiredState + | SignUpAttributesRequiredState + | SignUpCompletedState + | SignUpFailedState; diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts new file mode 100644 index 0000000000..2fc136f502 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpSubmitPasswordError } from "../error_type/SignUpError.js"; +import { SignUpAttributesRequiredState } from "../state/SignUpAttributesRequiredState.js"; +import { SignUpCompletedState } from "../state/SignUpCompletedState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of a sign-up operation that requires a password. + */ +export class SignUpSubmitPasswordResult extends AuthFlowResultBase< + SignUpSubmitPasswordResultState, + SignUpSubmitPasswordError, + void +> { + /** + * Creates a new instance of SignUpSubmitPasswordResult. + * @param state The state of the result. + */ + constructor(state: SignUpSubmitPasswordResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpSubmitPasswordResult with an error. + * @param error The error that occurred. + * @returns {SignUpSubmitPasswordResult} A new instance of SignUpSubmitPasswordResult with the error set. + */ + static createWithError(error: unknown): SignUpSubmitPasswordResult { + const result = new SignUpSubmitPasswordResult(new SignUpFailedState()); + result.error = new SignUpSubmitPasswordError(SignUpSubmitPasswordResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpSubmitPasswordResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in an attributes required state. + */ + isAttributesRequired(): this is SignUpSubmitPasswordResult & { state: SignUpAttributesRequiredState } { + return this.state instanceof SignUpAttributesRequiredState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignUpSubmitPasswordResult & { state: SignUpCompletedState } { + return this.state instanceof SignUpCompletedState; + } +} + +/** + * The possible states for the SignUpSubmitPasswordResult. + * This includes: + * - SignUpAttributesRequiredState: The sign-up process requires additional attributes. + * - SignUpCompletedState: The sign-up process has completed successfully. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpSubmitPasswordResultState = SignUpAttributesRequiredState | SignUpCompletedState | SignUpFailedState; diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts new file mode 100644 index 0000000000..9efc32624e --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidArgumentError } from "../../../core/error/InvalidArgumentError.js"; +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { UserAccountAttributes } from "../../../UserAccountAttributes.js"; +import { SignUpCompletedResult } from "../../interaction_client/result/SignUpActionResult.js"; +import { SignUpSubmitAttributesResult } from "../result/SignUpSubmitAttributesResult.js"; +import { SignUpState } from "./SignUpState.js"; +import { SignUpAttributesRequiredStateParameters } from "./SignUpStateParameters.js"; +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { SignUpCompletedState } from "./SignUpCompletedState.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; + +/* + * Sign-up attributes required state. + */ +export class SignUpAttributesRequiredState extends SignUpState { + /** + * Submits attributes to continue sign-up flow. + * This methods is used to submit required attributes. + * These attributes, built in or custom, were configured in the Microsoft Entra admin center by the tenant administrator. + * @param {UserAccountAttributes} attributes - The attributes to submit. + * @returns {Promise} The result of the operation. + */ + async submitAttributes(attributes: UserAccountAttributes): Promise { + if (!attributes || Object.keys(attributes.toRecord()).length === 0) { + this.stateParameters.logger.error( + "Attributes are required for sign-up.", + this.stateParameters.correlationId, + ); + + return Promise.resolve( + SignUpSubmitAttributesResult.createWithError( + new InvalidArgumentError("attributes", this.stateParameters.correlationId), + ), + ); + } + + try { + this.stateParameters.logger.verbose( + "Submitting attributes for sign-up.", + this.stateParameters.correlationId, + ); + + const result = await this.stateParameters.signUpClient.submitAttributes({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + attributes: attributes.toRecord(), + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "Attributes submitted for sign-up.", + this.stateParameters.correlationId, + ); + + if (result instanceof SignUpCompletedResult) { + // Sign-up completed + this.stateParameters.logger.verbose("Sign-up completed.", this.stateParameters.correlationId); + + return new SignUpSubmitAttributesResult( + new SignUpCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + signInScenario: SignInScenario.SignInAfterSignUp, + }), + ); + } + + return SignUpSubmitAttributesResult.createWithError( + new UnexpectedError("Unknown sign-up result type.", this.stateParameters.correlationId), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit attributes for sign up. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignUpSubmitAttributesResult.createWithError(error); + } + } + + /** + * Gets the required attributes for sign-up. + * @returns {UserAttribute[]} The required attributes for sign-up. + */ + getRequiredAttributes(): UserAttribute[] { + return this.stateParameters.requiredAttributes; + } +} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpCodeRequiredState.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpCodeRequiredState.ts new file mode 100644 index 0000000000..68ae2b1e39 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpCodeRequiredState.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { + SignUpAttributesRequiredResult, + SignUpCompletedResult, + SignUpPasswordRequiredResult, +} from "../../interaction_client/result/SignUpActionResult.js"; +import { SignUpResendCodeResult } from "../result/SignUpResendCodeResult.js"; +import { SignUpSubmitCodeResult } from "../result/SignUpSubmitCodeResult.js"; +import { SignUpState } from "./SignUpState.js"; +import { SignUpCodeRequiredStateParameters } from "./SignUpStateParameters.js"; +import { SignUpPasswordRequiredState } from "./SignUpPasswordRequiredState.js"; +import { SignUpAttributesRequiredState } from "./SignUpAttributesRequiredState.js"; +import { SignUpCompletedState } from "./SignUpCompletedState.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; + +/* + * Sign-up code required state. + */ +export class SignUpCodeRequiredState extends SignUpState { + /** + * Submit one-time passcode to continue sign-up flow. + * @param {string} code - The code to submit. + * @returns {Promise} The result of the operation. + */ + async submitCode(code: string): Promise { + try { + this.ensureCodeIsValid(code, this.stateParameters.codeLength); + + this.stateParameters.logger.verbose("Submitting code for sign-up.", this.stateParameters.correlationId); + + const result = await this.stateParameters.signUpClient.submitCode({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + code: code, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose("Code submitted for sign-up.", this.stateParameters.correlationId); + + if (result instanceof SignUpPasswordRequiredResult) { + // Password required + this.stateParameters.logger.verbose( + "Password required for sign-up.", + this.stateParameters.correlationId, + ); + + return new SignUpSubmitCodeResult( + new SignUpPasswordRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + }), + ); + } else if (result instanceof SignUpAttributesRequiredResult) { + // Attributes required + this.stateParameters.logger.verbose( + "Attributes required for sign-up.", + this.stateParameters.correlationId, + ); + + return new SignUpSubmitCodeResult( + new SignUpAttributesRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + requiredAttributes: result.requiredAttributes, + }), + ); + } else if (result instanceof SignUpCompletedResult) { + // Sign-up completed + this.stateParameters.logger.verbose("Sign-up completed.", this.stateParameters.correlationId); + + return new SignUpSubmitCodeResult( + new SignUpCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + signInScenario: SignInScenario.SignInAfterSignUp, + }), + ); + } + + return SignUpSubmitCodeResult.createWithError( + new UnexpectedError("Unknown sign-up result type.", this.stateParameters.correlationId), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit code for sign up. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignUpSubmitCodeResult.createWithError(error); + } + } + + /** + * Resends the another one-time passcode for sign-up flow if the previous one hasn't been verified. + * @returns {Promise} The result of the operation. + */ + async resendCode(): Promise { + try { + this.stateParameters.logger.verbose("Resending code for sign-up.", this.stateParameters.correlationId); + + const result = await this.stateParameters.signUpClient.resendCode({ + clientId: this.stateParameters.config.auth.clientId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + username: this.stateParameters.username, + correlationId: this.stateParameters.correlationId, + continuationToken: this.stateParameters.continuationToken ?? "", + }); + + this.stateParameters.logger.verbose("Code resent for sign-up.", this.stateParameters.correlationId); + + return new SignUpResendCodeResult( + new SignUpCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + codeResendInterval: result.interval, + }), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to resend code for sign up. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignUpResendCodeResult.createWithError(error); + } + } + + /** + * Gets the sent code length. + * @returns {number} The length of the code. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } + + /** + * Gets the interval in seconds for the code to be resent. + * @returns {number} The interval in seconds for the code to be resent. + */ + getCodeResendInterval(): number { + return this.stateParameters.codeResendInterval; + } +} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpCompletedState.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpCompletedState.ts new file mode 100644 index 0000000000..4526ae5724 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpCompletedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInContinuationState } from "../../../sign_in/auth_flow/state/SignInContinuationState.js"; + +/** + * Represents the state of a sign-up operation that has been completed scuccessfully. + */ +export class SignUpCompletedState extends SignInContinuationState {} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpFailedState.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpFailedState.ts new file mode 100644 index 0000000000..c3b631308a --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpFailedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * Represents the state of a sign-up operation that has failed. + */ +export class SignUpFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts new file mode 100644 index 0000000000..7642a9d3b6 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; +import { + SignUpAttributesRequiredResult, + SignUpCompletedResult, +} from "../../interaction_client/result/SignUpActionResult.js"; +import { SignUpSubmitPasswordResult } from "../result/SignUpSubmitPasswordResult.js"; +import { SignUpAttributesRequiredState } from "./SignUpAttributesRequiredState.js"; +import { SignUpCompletedState } from "./SignUpCompletedState.js"; +import { SignUpState } from "./SignUpState.js"; +import { SignUpPasswordRequiredStateParameters } from "./SignUpStateParameters.js"; + +/* + * Sign-up password required state. + */ +export class SignUpPasswordRequiredState extends SignUpState { + /** + * Submits a password for sign-up. + * @param {string} password - The password to submit. + * @returns {Promise} The result of the operation. + */ + async submitPassword(password: string): Promise { + try { + this.ensurePasswordIsNotEmpty(password); + + this.stateParameters.logger.verbose("Submitting password for sign-up.", this.stateParameters.correlationId); + + const result = await this.stateParameters.signUpClient.submitPassword({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + password: password, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose("Password submitted for sign-up.", this.stateParameters.correlationId); + + if (result instanceof SignUpAttributesRequiredResult) { + // Attributes required + this.stateParameters.logger.verbose( + "Attributes required for sign-up.", + this.stateParameters.correlationId, + ); + + return new SignUpSubmitPasswordResult( + new SignUpAttributesRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + requiredAttributes: result.requiredAttributes, + }), + ); + } else if (result instanceof SignUpCompletedResult) { + // Sign-up completed + this.stateParameters.logger.verbose("Sign-up completed.", this.stateParameters.correlationId); + + return new SignUpSubmitPasswordResult( + new SignUpCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + signInScenario: SignInScenario.SignInAfterSignUp, + }), + ); + } + + return SignUpSubmitPasswordResult.createWithError( + new UnexpectedError("Unknown sign-up result type.", this.stateParameters.correlationId), + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit password for sign up. Error: ${error}.`, + this.stateParameters.correlationId, + ); + + return SignUpSubmitPasswordResult.createWithError(error); + } + } +} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpState.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpState.ts new file mode 100644 index 0000000000..ad2db62760 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpState.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ArgumentValidator } from "../../../core/utils/ArgumentValidator.js"; +import { AuthFlowActionRequiredStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { SignUpStateParameters } from "./SignUpStateParameters.js"; + +/* + * Base state handler for sign-up flow. + */ +export abstract class SignUpState< + TParameters extends SignUpStateParameters, +> extends AuthFlowActionRequiredStateBase { + /* + * Creates a new SignUpState. + * @param stateParameters - The state parameters for sign-up. + */ + constructor(stateParameters: TParameters) { + super(stateParameters); + + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "config", + stateParameters.config, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotEmptyString( + "username", + stateParameters.username, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "signUpClient", + stateParameters.signUpClient, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotEmptyString( + "continuationToken", + stateParameters.continuationToken, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "signInClient", + stateParameters.signInClient, + stateParameters.correlationId, + ); + ArgumentValidator.ensureArgumentIsNotNullOrUndefined( + "cacheClient", + stateParameters.cacheClient, + stateParameters.correlationId, + ); + } +} diff --git a/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpStateParameters.ts b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpStateParameters.ts new file mode 100644 index 0000000000..ddeb396af7 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/auth_flow/state/SignUpStateParameters.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignUpClient } from "../../interaction_client/SignUpClient.js"; +import { SignInClient } from "../../../sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { AuthFlowActionRequiredStateParameters } from "../../../core/auth_flow/AuthFlowState.js"; +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +export interface SignUpStateParameters extends AuthFlowActionRequiredStateParameters { + username: string; + signUpClient: SignUpClient; + signInClient: SignInClient; + cacheClient: CustomAuthSilentCacheClient; +} + +export type SignUpPasswordRequiredStateParameters = SignUpStateParameters; + +export interface SignUpCodeRequiredStateParameters extends SignUpStateParameters { + codeLength: number; + codeResendInterval: number; +} + +export interface SignUpAttributesRequiredStateParameters extends SignUpStateParameters { + requiredAttributes: Array; +} diff --git a/lib/msal-custom-auth/src/sign_up/interaction_client/SignUpClient.ts b/lib/msal-custom-auth/src/sign_up/interaction_client/SignUpClient.ts new file mode 100644 index 0000000000..95fccd7fce --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/interaction_client/SignUpClient.ts @@ -0,0 +1,399 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-browser"; +import { CustomAuthApiError } from "../../core/error/CustomAuthApiError.js"; +import { CustomAuthApiErrorCode } from "../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { UnexpectedError } from "../../core/error/UnexpectedError.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import { PublicApiId } from "../../core/telemetry/PublicApiId.js"; +import { ArgumentValidator } from "../../core/utils/ArgumentValidator.js"; +import { + ChallengeType, + DefaultCustomAuthApiCodeLength, + DefaultCustomAuthApiCodeResendIntervalInSec, +} from "../../CustomAuthConstants.js"; +import { + SignUpParamsBase, + SignUpResendCodeParams, + SignUpStartParams, + SignUpSubmitCodeParams, + SignUpSubmitPasswordParams, + SignUpSubmitUserAttributesParams, +} from "./parameter/SignUpParams.js"; +import { + SignUpAttributesRequiredResult, + SignUpCodeRequiredResult, + SignUpCompletedResult, + SignUpPasswordRequiredResult, +} from "./result/SignUpActionResult.js"; +import { + SignUpChallengeRequest, + SignUpContinueWithAttributesRequest, + SignUpContinueWithOobRequest, + SignUpContinueWithPasswordRequest, + SignUpStartRequest, +} from "../../core/network_client/custom_auth_api/types/ApiRequestTypes.js"; +import { SignUpContinueResponse } from "../../core/network_client/custom_auth_api/types/ApiResponseTypes.js"; + +export class SignUpClient extends CustomAuthInteractionClientBase { + /** + * Starts the sign up flow. + * @param parameters The parameters for the sign up start action. + * @returns The result of the sign up start action. + */ + async start(parameters: SignUpStartParams): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = !parameters.password ? PublicApiId.SIGN_UP_START : PublicApiId.SIGN_UP_WITH_PASSWORD_START; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const startRequest: SignUpStartRequest = { + username: parameters.username, + password: parameters.password, + attributes: parameters.attributes, + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: parameters.correlationId, + }; + + this.logger.verbose("Calling start endpoint for sign up.", parameters.correlationId); + + const startResponse = await this.customAuthApiClient.signUpApi.start(startRequest); + + this.logger.verbose("Start endpoint called for sign up.", parameters.correlationId); + + const challengeRequest: SignUpChallengeRequest = { + continuation_token: startResponse.continuation_token ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: startResponse.correlation_id, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the code for the sign up flow. + * @param parameters The parameters for the sign up submit code action. + * @returns The result of the sign up submit code action. + */ + async submitCode( + parameters: SignUpSubmitCodeParams, + ): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = PublicApiId.SIGN_UP_SUBMIT_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const requestSubmitCode: SignUpContinueWithOobRequest = { + continuation_token: parameters.continuationToken, + oob: parameters.code, + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performContinueRequest( + "SignUpClient.submitCode", + parameters, + telemetryManager, + () => this.customAuthApiClient.signUpApi.continueWithCode(requestSubmitCode), + parameters.correlationId, + ); + + if (result instanceof SignUpCodeRequiredResult) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'oob' is invalid after submtting code for sign up.", + parameters.correlationId, + ); + } + + return result; + } + + /** + * Submits the password for the sign up flow. + * @param parameter The parameters for the sign up submit password action. + * @returns The result of the sign up submit password action. + */ + async submitPassword( + parameter: SignUpSubmitPasswordParams, + ): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameter", parameter, parameter.correlationId); + + const apiId = PublicApiId.SIGN_UP_SUBMIT_PASSWORD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const requestSubmitPwd: SignUpContinueWithPasswordRequest = { + continuation_token: parameter.continuationToken, + password: parameter.password, + telemetryManager, + correlationId: parameter.correlationId, + }; + + const result = await this.performContinueRequest( + "SignUpClient.submitPassword", + parameter, + telemetryManager, + () => this.customAuthApiClient.signUpApi.continueWithPassword(requestSubmitPwd), + parameter.correlationId, + ); + + if (result instanceof SignUpPasswordRequiredResult) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'password' is invalid after submtting password for sign up.", + parameter.correlationId, + ); + } + + return result; + } + + /** + * Submits the attributes for the sign up flow. + * @param parameter The parameters for the sign up submit attributes action. + * @returns The result of the sign up submit attributes action. + */ + async submitAttributes( + parameter: SignUpSubmitUserAttributesParams, + ): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameter", parameter, parameter.correlationId); + + const apiId = PublicApiId.SIGN_UP_SUBMIT_ATTRIBUTES; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + const reqWithAttr: SignUpContinueWithAttributesRequest = { + continuation_token: parameter.continuationToken, + attributes: parameter.attributes, + telemetryManager, + correlationId: parameter.correlationId, + }; + + const result = await this.performContinueRequest( + "SignUpClient.submitAttributes", + parameter, + telemetryManager, + () => this.customAuthApiClient.signUpApi.continueWithAttributes(reqWithAttr), + parameter.correlationId, + ); + + if (result instanceof SignUpAttributesRequiredResult) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + parameter.correlationId, + [], + "", + result.requiredAttributes, + result.continuationToken, + ); + } + + return result; + } + + /** + * Resends the code for the sign up flow. + * @param parameters The parameters for the sign up resend code action. + * @returns The result of the sign up resend code action. + */ + async resendCode(parameters: SignUpResendCodeParams): Promise { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("parameters", parameters, parameters.correlationId); + + const apiId = PublicApiId.SIGN_UP_RESEND_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const challengeRequest: SignUpChallengeRequest = { + continuation_token: parameters.continuationToken ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performChallengeRequest(challengeRequest); + + if (result instanceof SignUpPasswordRequiredResult) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'password' is invalid after resending code for sign up.", + parameters.correlationId, + ); + } + + return result; + } + + private async performChallengeRequest( + request: SignUpChallengeRequest, + ): Promise { + this.logger.verbose("Calling challenge endpoint for sign up.", request.correlationId); + + const challengeResponse = await this.customAuthApiClient.signUpApi.requestChallenge(request); + + this.logger.verbose("Challenge endpoint called for sign up.", request.correlationId); + + if (challengeResponse.challenge_type === ChallengeType.OOB) { + // Code is required + this.logger.verbose("Challenge type is oob for sign up.", request.correlationId); + + return new SignUpCodeRequiredResult( + challengeResponse.correlation_id, + challengeResponse.continuation_token ?? "", + challengeResponse.challenge_channel ?? "", + challengeResponse.challenge_target_label ?? "", + challengeResponse.code_length ?? DefaultCustomAuthApiCodeLength, + challengeResponse.interval ?? DefaultCustomAuthApiCodeResendIntervalInSec, + challengeResponse.binding_method ?? "", + ); + } + + if (challengeResponse.challenge_type === ChallengeType.PASSWORD) { + // Password is required + this.logger.verbose("Challenge type is password for sign up.", request.correlationId); + + return new SignUpPasswordRequiredResult( + challengeResponse.correlation_id, + challengeResponse.continuation_token ?? "", + ); + } + + this.logger.error( + `Unsupported challenge type '${challengeResponse.challenge_type}' for sign up.`, + request.correlationId, + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + `Unsupported challenge type '${challengeResponse.challenge_type}'.`, + request.correlationId, + ); + } + + private async performContinueRequest( + callerName: string, + requestParams: SignUpParamsBase, + telemetryManager: ServerTelemetryManager, + responseGetter: () => Promise, + requestCorrelationId: string, + ): Promise< + SignUpCompletedResult | SignUpPasswordRequiredResult | SignUpCodeRequiredResult | SignUpAttributesRequiredResult + > { + this.logger.verbose(`${callerName} is calling continue endpoint for sign up.`, requestCorrelationId); + + try { + const response = await responseGetter(); + + this.logger.verbose(`Continue endpoint called by ${callerName} for sign up.`, requestCorrelationId); + + return new SignUpCompletedResult(requestCorrelationId, response.continuation_token ?? ""); + } catch (error) { + if (error instanceof CustomAuthApiError) { + return this.handleContinueResponseError( + error, + error.correlationId ?? requestCorrelationId, + requestParams, + telemetryManager, + ); + } else { + this.logger.errorPii( + `${callerName} is failed to call continue endpoint for sign up. Error: ${error}`, + requestCorrelationId, + ); + + throw new UnexpectedError(error, requestCorrelationId); + } + } + } + + private async handleContinueResponseError( + responseError: CustomAuthApiError, + correlationId: string, + requestParams: SignUpParamsBase, + telemetryManager: ServerTelemetryManager, + ): Promise { + if ( + responseError.error === CustomAuthApiErrorCode.CREDENTIAL_REQUIRED && + !!responseError.errorCodes && + responseError.errorCodes.includes(55103) + ) { + // Credential is required + this.logger.verbose("The credential is required in the sign up flow.", correlationId); + + const continuationToken = this.readContinuationTokenFromResponeError(responseError); + + // Call the challenge endpoint to ensure the password challenge type is supported. + const challengeRequest: SignUpChallengeRequest = { + continuation_token: continuationToken, + challenge_type: this.getChallengeTypes(requestParams.challengeType), + telemetryManager, + correlationId, + }; + + const challengeResult = await this.performChallengeRequest(challengeRequest); + + if (challengeResult instanceof SignUpPasswordRequiredResult) { + return new SignUpPasswordRequiredResult(correlationId, challengeResult.continuationToken); + } + + if (challengeResult instanceof SignUpCodeRequiredResult) { + return new SignUpCodeRequiredResult( + challengeResult.correlationId, + challengeResult.continuationToken, + challengeResult.challengeChannel, + challengeResult.challengeTargetLabel, + challengeResult.codeLength, + challengeResult.interval, + challengeResult.bindingMethod, + ); + } + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type is not supported.", + correlationId, + ); + } + + if (this.isAttributesRequiredError(responseError, correlationId)) { + // Attributes are required + this.logger.verbose("Attributes are required in the sign up flow.", correlationId); + + const continuationToken = this.readContinuationTokenFromResponeError(responseError); + + return new SignUpAttributesRequiredResult(correlationId, continuationToken, responseError.attributes ?? []); + } + + throw responseError; + } + + private isAttributesRequiredError(responseError: CustomAuthApiError, correlationId: string): boolean { + if (responseError.error === CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED) { + if (!responseError.attributes || responseError.attributes.length === 0) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_RESPONSE_BODY, + "Attributes are required but required_attributes field is missing in the response body.", + correlationId, + ); + } + + return true; + } + + return false; + } + + private readContinuationTokenFromResponeError(responseError: CustomAuthApiError): string { + if (!responseError.continuationToken) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.CONTINUATION_TOKEN_MISSING, + "Continuation token is missing in the response body", + responseError.correlationId, + ); + } + + return responseError.continuationToken; + } +} diff --git a/lib/msal-custom-auth/src/sign_up/interaction_client/parameter/SignUpParams.ts b/lib/msal-custom-auth/src/sign_up/interaction_client/parameter/SignUpParams.ts new file mode 100644 index 0000000000..e34643f0a9 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/interaction_client/parameter/SignUpParams.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface SignUpParamsBase { + clientId: string; + challengeType: Array; + username: string; + correlationId: string; +} + +export interface SignUpStartParams extends SignUpParamsBase { + password?: string; + attributes?: Record; +} + +export interface SignUpResendCodeParams extends SignUpParamsBase { + continuationToken: string; +} + +export interface SignUpContinueParams extends SignUpParamsBase { + continuationToken: string; +} + +export interface SignUpSubmitCodeParams extends SignUpContinueParams { + code: string; +} + +export interface SignUpSubmitPasswordParams extends SignUpContinueParams { + password: string; +} + +export interface SignUpSubmitUserAttributesParams extends SignUpContinueParams { + attributes: Record; +} diff --git a/lib/msal-custom-auth/src/sign_up/interaction_client/result/SignUpActionResult.ts b/lib/msal-custom-auth/src/sign_up/interaction_client/result/SignUpActionResult.ts new file mode 100644 index 0000000000..3e5a7fd755 --- /dev/null +++ b/lib/msal-custom-auth/src/sign_up/interaction_client/result/SignUpActionResult.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +class SignUpResultBase { + constructor( + public correlationId: string, + public continuationToken: string, + ) {} +} + +export class SignUpCompletedResult extends SignUpResultBase {} + +export class SignUpPasswordRequiredResult extends SignUpResultBase {} + +export class SignUpCodeRequiredResult extends SignUpResultBase { + constructor( + correlationId: string, + continuationToken: string, + public challengeChannel: string, + public challengeTargetLabel: string, + public codeLength: number, + public interval: number, + public bindingMethod: string, + ) { + super(correlationId, continuationToken); + } +} + +export class SignUpAttributesRequiredResult extends SignUpResultBase { + constructor( + correlationId: string, + continuationToken: string, + public requiredAttributes: Array, + ) { + super(correlationId, continuationToken); + } +} diff --git a/lib/msal-custom-auth/test/CustomAuthPublicClientApplication.spec.ts b/lib/msal-custom-auth/test/CustomAuthPublicClientApplication.spec.ts new file mode 100644 index 0000000000..270616b14f --- /dev/null +++ b/lib/msal-custom-auth/test/CustomAuthPublicClientApplication.spec.ts @@ -0,0 +1,140 @@ +import { Constants } from "@azure/msal-browser"; +import { ICustomAuthStandardController } from "../src/controller/ICustomAuthStandardController.js"; +import { InvalidConfigurationError } from "../src/core/error/InvalidConfigurationError.js"; +import { CustomAuthPublicClientApplication } from "../src/CustomAuthPublicClientApplication.js"; +import { customAuthConfig } from "./test_resources/CustomAuthConfig.js"; +import { SignUpResult } from "../src/sign_up/auth_flow/result/SignUpResult.js"; +import { CustomAuthError } from "../src/core/error/CustomAuthError.js"; +import { ResetPasswordStartResult } from "../src/reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { GetAccountResult } from "../src/get_account/auth_flow/result/GetAccountResult.js"; +import { CustomAuthStandardController } from "../src/controller/CustomAuthStandardController.js"; + +describe("CustomAuthPublicClientApplication", () => { + let mockController: jest.Mocked; + + beforeEach(() => { + mockController = { + signIn: jest.fn(), + signUp: jest.fn(), + resetPassword: jest.fn(), + getCurrentAccount: jest.fn(), + } as unknown as jest.Mocked; + }); + + describe("constructor and config validation", () => { + it("should throw an error if the config is null", async () => { + await expect(CustomAuthPublicClientApplication.create(null as any)).rejects.toThrow( + InvalidConfigurationError, + ); + }); + + it("should throw an error if the authority is missing", async () => { + const invalidConfig = { auth: {}, customAuth: {} } as any; + + await expect(CustomAuthPublicClientApplication.create(invalidConfig)).rejects.toThrow( + InvalidConfigurationError, + ); + }); + + it("should throw an error if challenge type is invalid", async () => { + const invalidConfig = { + auth: { authority: customAuthConfig.auth.authority }, + customAuth: { + challengeTypes: ["invalid-challenge-type", "oob"], + }, + }; + + await expect(CustomAuthPublicClientApplication.create(invalidConfig as any)).rejects.toThrow( + InvalidConfigurationError, + ); + }); + + it("should create an instance if the config is valid", async () => { + const app = await CustomAuthPublicClientApplication.create(customAuthConfig); + + expect(app).toBeInstanceOf(CustomAuthPublicClientApplication); + + ((app as CustomAuthPublicClientApplication)["customAuthController"] as CustomAuthStandardController)[ + "eventHandler" + ]["broadcastChannel"].close(); + }); + }); + + describe("signIn", () => { + it("should call the customAuthController signIn method with correct inputs", async () => { + const mockSignInInputs = { + username: "testuser", + password: "testpassword", + }; + + const mockSignInResult = { accessToken: "test-token" }; + + mockController.signIn.mockResolvedValueOnce(mockSignInResult as any); + + const app = await CustomAuthPublicClientApplication.create(customAuthConfig, mockController); + + const result = await app.signIn(mockSignInInputs); + + expect(mockController.signIn).toHaveBeenCalledWith(mockSignInInputs); + expect(result).toEqual(mockSignInResult); + }); + }); + + describe("signUp", () => { + it("should call the customAuthController signUp method with correct inputs", async () => { + const mockSignUpInputs = { + username: "testuser", + password: "testpassword", + }; + + const mockSignUpResult = SignUpResult.createWithError(new CustomAuthError("test-error")); + + mockController.signUp.mockResolvedValueOnce(mockSignUpResult as any); + + const app = await CustomAuthPublicClientApplication.create(customAuthConfig, mockController); + + const result = await app.signUp(mockSignUpInputs); + + expect(mockController.signUp).toHaveBeenCalledWith(mockSignUpInputs); + expect(result).toEqual(mockSignUpResult); + }); + }); + + describe("resetPassword", () => { + it("should call the customAuthController resetPassword method with correct inputs", async () => { + const mockResetPasswordInputs = { + username: "testuser", + }; + + const mockResetPasswordResult = ResetPasswordStartResult.createWithError(new CustomAuthError("test-error")); + + mockController.resetPassword.mockResolvedValueOnce(mockResetPasswordResult as any); + + const app = await CustomAuthPublicClientApplication.create(customAuthConfig, mockController); + + const result = await app.resetPassword(mockResetPasswordInputs); + + expect(mockController.resetPassword).toHaveBeenCalledWith(mockResetPasswordInputs); + expect(result).toEqual(mockResetPasswordResult); + }); + }); + + describe("getCurrentAccount", () => { + it("should call the customAuthController getCurrentAccount method with correct inputs", async () => { + const mockGetCurrentAccountInputs = { + correlationId: "test-id", + }; + + const mockGetCurrentAccountResult = GetAccountResult.createWithError(new CustomAuthError("test-error")); + + mockController.getCurrentAccount.mockReturnValue(mockGetCurrentAccountResult as any); + + const app = await CustomAuthPublicClientApplication.create(customAuthConfig, mockController); + + const result = await app.getCurrentAccount(mockGetCurrentAccountInputs); + + expect(mockController.getCurrentAccount).toHaveBeenCalledWith(mockGetCurrentAccountInputs); + expect(result).toEqual(mockGetCurrentAccountResult); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/controller/CustomAuthStandardController.spec.ts b/lib/msal-custom-auth/test/controller/CustomAuthStandardController.spec.ts new file mode 100644 index 0000000000..777eca8ebe --- /dev/null +++ b/lib/msal-custom-auth/test/controller/CustomAuthStandardController.spec.ts @@ -0,0 +1,419 @@ +import { CustomAuthStandardController } from "../../src/controller/CustomAuthStandardController.js"; +import { ResetPasswordInputs, SignInInputs, SignUpInputs } from "../../src/CustomAuthActionInputs.js"; +import { CustomAuthOperatingContext } from "../../src/operating_context/CustomAuthOperatingContext.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { SignInError } from "../../src/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInResult } from "../../src/sign_in/auth_flow/result/SignInResult.js"; +import { CustomAuthAccountData } from "../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { SignUpError } from "../../src/sign_up/auth_flow/error_type/SignUpError.js"; +import { ChallengeType } from "../../src/CustomAuthConstants.js"; +import { CustomAuthApiError, RedirectError } from "../../src/core/error/CustomAuthApiError.js"; +import { SignUpResult } from "../../src/sign_up/auth_flow/result/SignUpResult.js"; +import { + CustomAuthApiErrorCode, + CustomAuthApiSuberror, +} from "../../src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { ResetPasswordError } from "../../src/reset_password/auth_flow/error_type/ResetPasswordError.js"; +import { ResetPasswordCodeRequiredState } from "../../src/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordStartResult } from "../../src/reset_password/auth_flow/result/ResetPasswordStartResult.js"; + +jest.mock("../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOTP: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continue: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + // Set up the prototype or instance methods/properties + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + return { CustomAuthApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient }; +}); + +jest.mock("@azure/msal-browser", () => { + const actualModule = jest.requireActual("@azure/msal-browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("CustomAuthStandardController", () => { + let controller: CustomAuthStandardController; + const { signInApiClient, signUpApiClient, resetPasswordApiClient } = jest.requireMock( + "../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", + ); + + beforeEach(() => { + const context = new CustomAuthOperatingContext(customAuthConfig); + controller = new CustomAuthStandardController(context); + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + // controller.closeEventChannel(); + jest.clearAllMocks(); // Clear mocks between tests + if (controller && controller["eventHandler"] && controller["eventHandler"]["broadcastChannel"]) { + controller["eventHandler"]["broadcastChannel"].close(); + } + }); + + test("Check if BroadcastChannel exists in JSDOM", () => { + expect(typeof BroadcastChannel).toBe("function"); + }); + + describe("signIn", () => { + it("should return error result if provided username is invalid", async () => { + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "", + }; + + const result = await controller.signIn(signInInputs); + + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + + expect(result.error?.isInvalidUsername()).toBe(true); + }); + + it("should return code required result if the challenge type is oob", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + target_challenge_label: "email", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCodeRequired()).toBe(true); + }); + + it("should return password required result if the challenge type is password", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should return correct completed result if the challenge type is password and password is provided", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + signInApiClient.requestTokensWithPassword.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + password: "test-password", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should return failed result if the challenge type is redirect", async () => { + signInApiClient.initiate.mockRejectedValue(new RedirectError()); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + password: "test-password", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + }); + + describe("signUp", () => { + it("should return error result if provided username is empty", async () => { + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignUpError); + + expect(result.error?.isInvalidUsername()).toBe(true); + }); + + it("should return result with code required state if the challenge type is oob", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isCodeRequired()).toBe(true); + }); + + it("should return result with password required state if the challenge type is password", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should return failed result if the start endpoint returns redirect challenge type", async () => { + signUpApiClient.start.mockRejectedValue(new RedirectError()); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + + it("should return failed result if the challenge endpoint returns redirect challenge type", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockRejectedValue(new RedirectError()); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + + it("should return failed result if the password is too weak", async () => { + signUpApiClient.start.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Password is too weak", + "correlation-id", + [], + CustomAuthApiSuberror.PASSWORD_TOO_WEAK, + ), + ); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isInvalidPassword()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + }); + + describe("resetPassword", () => { + it("should return error result if provided username is invalid", async () => { + // Empty username + let inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "", + }; + + let result = await controller.resetPassword(inputs); + + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(ResetPasswordError); + + expect(result.error?.isInvalidUsername()).toBe(true); + }); + + it("should return code required result successfully", async () => { + resetPasswordApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 8, + challenge_channel: "email", + target_challenge_label: "email", + }); + + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.resetPassword(inputs); + + expect(result.error).toBeUndefined(); + expect(result.state).toBeInstanceOf(ResetPasswordCodeRequiredState); + expect(result.isCodeRequired()).toBe(true); + }); + + it("should return redirect error if the return challenge is redirect", async () => { + resetPasswordApiClient.start.mockRejectedValue(new RedirectError()); + + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + + it("should return failed result if the user is not found", async () => { + resetPasswordApiClient.start.mockRejectedValue( + new CustomAuthApiError(CustomAuthApiErrorCode.USER_NOT_FOUND, "User not found"), + ); + + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isUserNotFound()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/core/CustomAuthAuthority.spec.ts b/lib/msal-custom-auth/test/core/CustomAuthAuthority.spec.ts new file mode 100644 index 0000000000..4f870f0815 --- /dev/null +++ b/lib/msal-custom-auth/test/core/CustomAuthAuthority.spec.ts @@ -0,0 +1,166 @@ +import { BrowserCacheManager, BrowserConfiguration, INetworkModule, Logger } from "@azure/msal-browser"; +import { CustomAuthAuthority } from "../../src/core/CustomAuthAuthority.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { mock } from "node:test"; + +describe("CustomAuthAuthority", () => { + const authorityUrl = customAuthConfig.auth.authority; + const customAuthProxyDomain = customAuthConfig.customAuth.authApiProxyUrl; + const mockMemoryStorage = new Map(); + const authorityHostname = + authorityUrl && authorityUrl.startsWith("https") ? authorityUrl.split("/")[2] : authorityUrl; + const authorityMetadataEntityKey = `authority-metadata-${customAuthConfig.auth.clientId}-${authorityHostname}`; + const mockCacheManager = { + generateAuthorityMetadataCacheKey: jest.fn().mockImplementation(() => { + return authorityMetadataEntityKey; + }), + setAuthorityMetadata: jest.fn().mockImplementation((key, metadata) => { + mockMemoryStorage.set(key, metadata); + }), + } as unknown as jest.Mocked; + const mockNetworkModule = {} as unknown as jest.Mocked; + const mockLogger = {} as unknown as jest.Mocked; + const mockConfig = { + auth: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } as unknown as jest.Mocked; + + describe("constructor", () => { + it("should correctly parse and store the authority URL", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + ); + expect(customAuthAuthority.canonicalAuthority).toBe( + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/", + ); + }); + + it("should correctly store the customAuthProxyDomain when provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain, + ); + expect(customAuthAuthority["customAuthProxyDomain"]).toBe(customAuthProxyDomain); + }); + + it("should correctly store the customAuthProxyDomain when provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + "https://login.microsoftonline.com/", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain, + ); + expect(customAuthAuthority["customAuthProxyDomain"]).toBe(customAuthProxyDomain); + }); + + it("should save authority metadata entity into cache", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + ); + expect(customAuthAuthority.canonicalAuthority).toBe( + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/", + ); + + const authorityHostname = customAuthAuthority.canonicalAuthorityUrlComponents.HostNameAndPort; + const authorityMetadataCacheKey = + "authority-metadata-d5e97fb9-24bb-418d-8e7a-4e1918303c92-spasamples.ciamlogin.com"; + const metadataEntity = mockMemoryStorage.get(authorityMetadataCacheKey); + + expect(mockMemoryStorage.has(authorityMetadataCacheKey)).toBe(true); + expect(metadataEntity).toMatchObject({ + aliases: [authorityHostname], + preferred_cache: authorityHostname, + }); + }); + }); + + describe("tenant getter", () => { + it("should extract the tenant from the authority URL hostname", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + ); + expect(customAuthAuthority.tenant).toBe("spasamples.onmicrosoft.com"); + }); + }); + + describe("getCustomAuthDomain", () => { + it("should return the customAuthProxyDomain when provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain, + ); + expect(customAuthAuthority.getCustomAuthApiDomain()).toBe(customAuthProxyDomain); + }); + + it("should generate the auth API domain based on the authority URL when customAuthProxyDomain is not provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + ); + expect(customAuthAuthority.getCustomAuthApiDomain()).toBe( + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/", + ); + }); + }); + + describe("getPreferredCache", () => { + it("should return the host of authority as preferred cache", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain, + ); + expect(customAuthAuthority.getPreferredCache()).toBe("spasamples.ciamlogin.com"); + }); + }); + + describe("tokenEndpoint", () => { + it("should return the correct token endpoint", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain, + ); + expect(customAuthAuthority.tokenEndpoint).toBe( + "https://myspafunctiont1.azurewebsites.net/api/ReverseProxy/oauth2/v2.0/token", + ); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts b/lib/msal-custom-auth/test/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts new file mode 100644 index 0000000000..1fb866584a --- /dev/null +++ b/lib/msal-custom-auth/test/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts @@ -0,0 +1,30 @@ +import { Logger } from "@azure/msal-browser"; +import { CustomAuthApiClient } from "../../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js"; +import { FetchHttpClient } from "../../../../src/core/network_client/http_client/FetchHttpClient.js"; + +describe("CustomAuthApiClient", () => { + let customAuthApiClient: CustomAuthApiClient; + + beforeEach(() => { + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + customAuthApiClient = new CustomAuthApiClient("https://test.com", "client_id", new FetchHttpClient(mockLogger)); + }); + + it("should initialize signInApiClient correctly", () => { + expect(customAuthApiClient.signInApi).toBeDefined(); + }); + + it("should initialize signUpApiClient correctly", () => { + expect(customAuthApiClient.signUpApi).toBeDefined(); + }); + + it("should initialize resetPasswordApiClient correctly", () => { + expect(customAuthApiClient.resetPasswordApi).toBeDefined(); + }); +}); diff --git a/lib/msal-custom-auth/test/core/network_client/http_client/FetchClient.spec.ts b/lib/msal-custom-auth/test/core/network_client/http_client/FetchClient.spec.ts new file mode 100644 index 0000000000..185a4440d9 --- /dev/null +++ b/lib/msal-custom-auth/test/core/network_client/http_client/FetchClient.spec.ts @@ -0,0 +1,67 @@ +import { Logger } from "@azure/msal-browser"; +import { FetchHttpClient } from "../../../../src/core/network_client/http_client/FetchHttpClient.js"; + +class MockResponse { + public readonly status: number; + public readonly headers: Headers; + private readonly body: any; + + constructor(body: any, init: ResponseInit = {}) { + this.status = init.status || 200; + this.headers = new Headers(init.headers); + this.body = body; + } + + async json() { + return JSON.parse(this.body); + } +} + +describe("FetchHttpClient", () => { + let httpClient: FetchHttpClient; + let mockFetch: jest.Mock; + const mockLogger = { + clone: jest.fn(), + info: jest.fn(), + infoPii: jest.fn(), + verbose: jest.fn(), + verbosePii: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + errorPii: jest.fn(), + tracePii: jest.fn(), + } as unknown as jest.Mocked; + + beforeEach(() => { + // Create a mock for the global fetch + mockFetch = jest.fn(); + global.fetch = mockFetch; + httpClient = new FetchHttpClient(mockLogger); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("sendAsync", () => { + it("should call fetch with correct parameters", async () => { + const url = "https://api.example.com"; + const options: RequestInit = { + method: "GET", + headers: { "Content-Type": "application/json" }, + }; + const mockResponse = new MockResponse(null, { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + const response = await httpClient.sendAsync(url, options); + expect(mockFetch).toHaveBeenCalledWith(url, options); + expect(response).toBe(mockResponse); + }); + + it("should propagate fetch errors", async () => { + const url = "https://api.example.com"; + const error = new Error("Network error"); + mockFetch.mockRejectedValue(error); + await expect(httpClient.sendAsync(url, {})).rejects.toThrow("Network error"); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/core/utils/ArgumentValidator.spec.ts b/lib/msal-custom-auth/test/core/utils/ArgumentValidator.spec.ts new file mode 100644 index 0000000000..c31de4d63b --- /dev/null +++ b/lib/msal-custom-auth/test/core/utils/ArgumentValidator.spec.ts @@ -0,0 +1,78 @@ +import { InvalidArgumentError } from "../../../src/core/error/InvalidArgumentError.js"; +import { ArgumentValidator } from "../../../src/core/utils/ArgumentValidator.js"; + +describe("ArgumentValidator", () => { + describe("ensureArgumentIsNotEmptyString", () => { + it("should not throw an error if the string is non-empty", () => { + expect(() => { + ArgumentValidator.ensureArgumentIsNotEmptyString("testArg", "validString"); + }).not.toThrow(); + }); + + it("should throw InvalidArgumentError if the string is empty", () => { + expect(() => { + ArgumentValidator.ensureArgumentIsNotEmptyString("testArg", ""); + }).toThrow(InvalidArgumentError); + }); + + it("should throw InvalidArgumentError if the string is only whitespace", () => { + expect(() => { + ArgumentValidator.ensureArgumentIsNotEmptyString("testArg", " "); + }).toThrow(InvalidArgumentError); + }); + + it("should pass correlationId to the error when the string is invalid", () => { + const correlationId = "12345"; + try { + ArgumentValidator.ensureArgumentIsNotEmptyString("testArg", "", correlationId); + } catch (error) { + if (error instanceof InvalidArgumentError) { + expect(error.correlationId).toBe(correlationId); + } else { + throw error; + } + } + }); + }); + + describe("ensureArgumentIsNotNullOrUndefined", () => { + it("should not throw an error if the argument is not null or undefined", () => { + expect(() => { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("testArg", "validValue"); + }).not.toThrow(); + + expect(() => { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("testArg", 42); + }).not.toThrow(); + + expect(() => { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("testArg", {}); + }).not.toThrow(); + }); + + it("should throw InvalidArgumentError if the argument is null", () => { + expect(() => { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("testArg", null); + }).toThrow(InvalidArgumentError); + }); + + it("should throw InvalidArgumentError if the argument is undefined", () => { + expect(() => { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("testArg", undefined); + }).toThrow(InvalidArgumentError); + }); + + it("should pass correlationId to the error when the argument is invalid", () => { + const correlationId = "12345"; + try { + ArgumentValidator.ensureArgumentIsNotNullOrUndefined("testArg", null, correlationId); + } catch (error) { + if (error instanceof InvalidArgumentError) { + expect(error.correlationId).toBe(correlationId); + } else { + throw error; + } + } + }); + }); +}); diff --git a/lib/msal-custom-auth/test/core/utils/StringUtils.spec.ts b/lib/msal-custom-auth/test/core/utils/StringUtils.spec.ts new file mode 100644 index 0000000000..a731c3832c --- /dev/null +++ b/lib/msal-custom-auth/test/core/utils/StringUtils.spec.ts @@ -0,0 +1,35 @@ +import { StringUtils } from "../../../src/core/utils/StringUtils.js"; + +describe("StringUtils", () => { + describe("trim", () => { + it("should trim whitespace from both ends of the string", () => { + const input = "//Hello World//"; + const result = StringUtils.trimSlashes(input); + expect(result).toBe("Hello World"); + }); + + it("should trim whitespace from start of the string", () => { + const input = "//Hello World"; + const result = StringUtils.trimSlashes(input); + expect(result).toBe("Hello World"); + }); + + it("should trim whitespace from end of the string", () => { + const input = "Hello World//"; + const result = StringUtils.trimSlashes(input); + expect(result).toBe("Hello World"); + }); + + it("should return the same string if there are no leading or trailing whitespaces", () => { + const input = "Hello World"; + const result = StringUtils.trimSlashes(input); + expect(result).toBe("Hello World"); + }); + + it("should return an empty string if the input is empty", () => { + const input = ""; + const result = StringUtils.trimSlashes(input); + expect(result).toBe(""); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/core/utils/UrlUtils.spec.ts b/lib/msal-custom-auth/test/core/utils/UrlUtils.spec.ts new file mode 100644 index 0000000000..c9e4bc7d5c --- /dev/null +++ b/lib/msal-custom-auth/test/core/utils/UrlUtils.spec.ts @@ -0,0 +1,120 @@ +import { ParsedUrlError } from "../../../src/core/error/ParsedUrlError.js"; +import { UrlUtils } from "../../../src/core/utils/UrlUtils.js"; + +describe("UrlUtils", () => { + describe("IsValidSecureUrl", () => { + it("should return true for a valid HTTPS URL", () => { + const url = "https://example.com"; + const result = UrlUtils.IsValidSecureUrl(url); + expect(result).toBe(true); + }); + + it("should return false for a non-HTTPS URL", () => { + const url = "http://example.com"; + const result = UrlUtils.IsValidSecureUrl(url); + expect(result).toBe(false); + }); + + it("should return false for an invalid URL", () => { + const url = "invalid-url"; + const result = UrlUtils.IsValidSecureUrl(url); + expect(result).toBe(false); + }); + + it("should return false for an empty string", () => { + const url = ""; + const result = UrlUtils.IsValidSecureUrl(url); + expect(result).toBe(false); + }); + }); + + describe("parseUrl", () => { + it("should return a valid URL object for a correct URL", () => { + const url = "https://example.com"; + const result = UrlUtils.parseUrl(url); + expect(result).toBeInstanceOf(URL); + expect(result.origin).toBe(url); + }); + + it("should throw ParsedUrlError for an invalid URL", () => { + const url = "invalid-url"; + expect(() => UrlUtils.parseUrl(url)).toThrow( + new ParsedUrlError("invalid_url", `The URL "${url}" is invalid: TypeError: Invalid URL: invalid-url`), + ); + }); + }); + + describe("parseSecureUrl", () => { + it("should return a valid URL object for a correct HTTPS URL", () => { + const url = "https://example.com"; + const result = UrlUtils.parseSecureUrl(url); + expect(result).toBeInstanceOf(URL); + expect(result.origin).toBe(url); + }); + + it("should throw ParsedUrlError if the URL is not HTTPS", () => { + const url = "http://example.com"; + expect(() => UrlUtils.parseSecureUrl(url)).toThrow( + new ParsedUrlError("unsecure_url", `The URL "${url}" is not secure. Only HTTPS URLs are supported.`), + ); + }); + + it("should throw ParsedUrlError for an invalid URL", () => { + const url = "invalid-url"; + expect(() => UrlUtils.parseSecureUrl(url)).toThrow( + new ParsedUrlError("invalid_url", `The URL "${url}" is invalid: TypeError: Invalid URL: invalid-url`), + ); + }); + }); + + describe("buildUrl", () => { + test.each([ + [ + "baseUrl does not end with a slash and path does not start with a slash", + "https://example.com", + "path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "baseUrl ends with a slash and path does not start with a slash", + "https://example.com/", + "path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "baseUrl does not end with a slash and path starts with a slash", + "https://example.com", + "/path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "baseUrl ends with a slash and path starts with a slash", + "https://example.com/", + "/path/to/resource", + "https://example.com/path/to/resource", + ], + ["URL with query parameters", "https://example.com", "path?query=1", "https://example.com/path?query=1"], + [ + "baseUrl contains a subpath", + "https://example.com/sub", + "path/to/resource", + "https://example.com/sub/path/to/resource", + ], + ])("should correctly construct a URL when %s", (name, baseUrl, path, expected) => { + const result = UrlUtils.buildUrl(baseUrl, path); + expect(result.toString()).toBe(expected); + }); + }); + + describe("IsValidUrl", () => { + test.each([ + [true, "https://example.com"], + [true, "http://example.com"], + [false, "invalid-url"], + [false, ""], + ])("should return %s for URL '%s'", (expected, url) => { + const result = UrlUtils.IsValidUrl(url); + expect(result).toBe(expected); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/get_account/auth_flow/CustomAuthAccountData.spec.ts b/lib/msal-custom-auth/test/get_account/auth_flow/CustomAuthAccountData.spec.ts new file mode 100644 index 0000000000..c10e713be9 --- /dev/null +++ b/lib/msal-custom-auth/test/get_account/auth_flow/CustomAuthAccountData.spec.ts @@ -0,0 +1,227 @@ +import { + AccountInfo, + AuthenticationResult, + Logger, + InteractionRequiredAuthError, + InteractionRequiredAuthErrorCodes, +} from "@azure/msal-browser"; +import { CustomAuthBrowserConfiguration } from "../../../src/configuration/CustomAuthConfiguration.js"; +import { CustomAuthSilentCacheClient } from "../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { CustomAuthAccountData } from "../../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { SignOutResult } from "../../../src/get_account/auth_flow/result/SignOutResult.js"; +import { SignOutError } from "../../../src/get_account/auth_flow/error_type/GetAccountError.js"; +import { IdTokenClaims } from "../../../../msal-common/dist/exports-common.js"; +import { MsalCustomAuthError } from "../../../src/core/error/MsalCustomAuthError.js"; + +describe("CustomAuthAccountData", () => { + let mockAccount: AccountInfo; + let mockConfig: CustomAuthBrowserConfiguration; + let mockCacheClient: CustomAuthSilentCacheClient; + let mockLogger: Logger; + const correlationId = "test-correlation-id"; + let mockAuthenticationResult: AuthenticationResult; + + beforeEach(() => { + mockAccount = { + homeAccountId: "test-home-account-id", + name: "Test User", + username: "test.user@example.com", + environment: "test-environment", + localAccountId: "test-local-account-id", + tenantId: "test-tenant-id", + idToken: "test-id-token", + idTokenClaims: { + name: "Test User", + }, + }; + + mockAuthenticationResult = { + authority: "test-authority", + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: mockAccount, + idToken: "test-id-token", + idTokenClaims: mockAccount.idTokenClaims as IdTokenClaims, + accessToken: "test-access-token", + fromCache: true, + expiresOn: new Date(), + tokenType: "Bearer", + correlationId: correlationId, + } as AuthenticationResult; + + mockConfig = { + auth: { + authority: "test-authority", + }, + } as CustomAuthBrowserConfiguration; // Mock as needed + mockCacheClient = { + acquireToken: jest.fn(), + getCurrentAccount: jest.fn(), + logout: jest.fn(), + } as unknown as CustomAuthSilentCacheClient; + mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as Logger; + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear mocks between tests + }); + + describe("signOut", () => { + it("should sign out the user successfully", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue(mockAccount); + + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + const result = await accountData.signOut(); + + expect(mockCacheClient.logout).toHaveBeenCalledWith({ + correlationId: correlationId, + account: mockAccount, + }); + expect(result).toBeInstanceOf(SignOutResult); + expect(mockLogger.verbose).toHaveBeenCalledWith("Signing out user", "test-correlation-id"); + expect(mockLogger.verbose).toHaveBeenCalledWith("User signed out", "test-correlation-id"); + }); + + it("should handle errors during sign out", async () => { + const error = new Error("Sign out error"); + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue(mockAccount); + (mockCacheClient.logout as jest.Mock).mockRejectedValue(error); + + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + const result = await accountData.signOut(); + + expect(mockLogger.errorPii).toHaveBeenCalledWith( + `An error occurred during sign out: ${error}`, + "test-correlation-id", + ); + expect(result).toBeInstanceOf(SignOutResult); + expect(result.error).toBeDefined(); + }); + + it("should handle no cached account", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue(null); + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + const result = await accountData.signOut(); + expect(result).toBeInstanceOf(SignOutResult); + expect(result.error).toBeInstanceOf(SignOutError); + expect(result.error?.isUserNotSignedIn()).toBe(true); + }); + }); + + describe("getAccount", () => { + it("should return the account information", () => { + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + const account = accountData.getAccount(); + expect(account).toEqual(mockAccount); + }); + }); + + describe("getIdToken", () => { + it("should return the id token", () => { + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + const idToken = accountData.getIdToken(); + expect(idToken).toEqual(mockAccount.idToken); + }); + }); + + describe("getClaims", () => { + it("should return the token claims", () => { + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + const claims = accountData.getClaims(); + expect(claims).toEqual(mockAccount.idTokenClaims); + }); + }); + + describe("getAccessToken", () => { + it("should return succeed GetAccessTokenState.Completed with cached tokens", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue(mockAccount); + jest.spyOn(CustomAuthAccountData.prototype as any, "createCommonSilentFlowRequest").mockReturnValue({}); + (mockCacheClient.acquireToken as jest.Mock).mockResolvedValue(mockAuthenticationResult); + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + + const response = await accountData.getAccessToken({ forceRefresh: false }); + + expect(response).toBeDefined(); + expect(response.isCompleted()).toBe(true); + expect(response.data?.account).toEqual(mockAccount); + expect(response.data?.idToken).toEqual(mockAuthenticationResult.idToken); + }); + + it("should return GetAccessTokenError if there is an error when aquire tokens", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue(mockAccount); + const errorCode = InteractionRequiredAuthErrorCodes.refreshTokenExpired; + const errorMessage = "Refresh token has expired."; + const subError = "Refresh token has expired, can not use it to get a new access token."; + const mockRefreshTokenExpiredError = new InteractionRequiredAuthError(errorCode, errorMessage, subError); + (mockCacheClient.acquireToken as jest.Mock).mockRejectedValue(mockRefreshTokenExpiredError); + + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId, + ); + + const response = await accountData.getAccessToken({ forceRefresh: false }); + + expect(response).toBeDefined(); + expect(response.isFailed()).toBe(true); + expect(response.error?.errorData).toEqual(mockRefreshTokenExpiredError); + expect(response.error?.errorData).toBeInstanceOf(MsalCustomAuthError); + + const msalError = response.error?.errorData as MsalCustomAuthError; + expect(msalError.error).toEqual(errorCode); + expect(msalError.errorDescription).toEqual(errorMessage); + expect(msalError.subError).toEqual(subError); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/get_account/auth_flow/error_type/GetAccountError.spec.ts b/lib/msal-custom-auth/test/get_account/auth_flow/error_type/GetAccountError.spec.ts new file mode 100644 index 0000000000..089050b3b0 --- /dev/null +++ b/lib/msal-custom-auth/test/get_account/auth_flow/error_type/GetAccountError.spec.ts @@ -0,0 +1,27 @@ +import { NoCachedAccountFoundError } from "../../../../src/core/error/NoCachedAccountFoundError.js"; +import { GetAccountError, SignOutError } from "../../../../src/get_account/auth_flow/error_type/GetAccountError.js"; +import { UnexpectedError } from "../../../../src/index.js"; + +describe("GetAccountError", () => { + it("should return true for isCurrentAccountNotFound when error is NoSignedInAccountFound", () => { + const error = new GetAccountError(new NoCachedAccountFoundError()); + expect(error.isCurrentAccountNotFound()).toBe(true); + }); + + it("should return false for isCurrentAccountNotFound when error is not NoSignedInAccountFound", () => { + const error = new GetAccountError(new UnexpectedError("unknown_error", "Unknown error")); + expect(error.isCurrentAccountNotFound()).toBe(false); + }); +}); + +describe("SignOutError", () => { + it("should return true for isUserNotSignedIn when error is NoCachedAccountFoundError", () => { + const error = new SignOutError(new NoCachedAccountFoundError()); + expect(error.isUserNotSignedIn()).toBe(true); + }); + + it("should return false for isUserNotSignedIn when error is not NoCachedAccountFoundError", () => { + const error = new SignOutError(new UnexpectedError("unknown_error", "Unknown error")); + expect(error.isUserNotSignedIn()).toBe(false); + }); +}); diff --git a/lib/msal-custom-auth/test/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts b/lib/msal-custom-auth/test/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts new file mode 100644 index 0000000000..d8b2327e86 --- /dev/null +++ b/lib/msal-custom-auth/test/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts @@ -0,0 +1,415 @@ +import { + AccountEntity, + AuthenticationScheme, + BrowserCacheManager, + BrowserConfiguration, + CommonSilentFlowRequest, + EventHandler, + ICrypto, + INavigationClient, + INetworkModule, + InteractionRequiredAuthErrorCodes, + Logger, +} from "@azure/msal-browser"; +import { CustomAuthSilentCacheClient } from "../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../src/core/CustomAuthAuthority.js"; +import { + CacheHelpers, + createInteractionRequiredAuthError, + RefreshTokenEntity, + StubPerformanceClient, + TimeUtils, +} from "@azure/msal-common"; +import { + TestTokenResponse, + TestAccounDetails, + TestServerTokenResponse, + TestHomeAccountId, + TestTenantId, + TestIdTokenClaims, + RenewedTokens, +} from "../../test_resources/TestConstants.js"; +import { AccessTokenEntity } from "../../../../msal-common/lib/types/exports-common.js"; +import { DefaultScopes } from "../../../src/CustomAuthConstants.js"; + +jest.mock("@azure/msal-browser", () => { + const actualModule = jest.requireActual("@azure/msal-browser"); + return { + ...actualModule, + ServerTelemetryManager: jest.fn(), + }; +}); + +describe("CustomAuthSilentCacheClient", () => { + let client: CustomAuthSilentCacheClient; + let mockBrowserConfig: BrowserConfiguration; + let mockCacheManager: BrowserCacheManager; + let mockCrypto: ICrypto; + let mockNetworkModule: INetworkModule; + + const mockNavigationClient = { + navigateExternal: jest.fn(), + } as unknown as jest.Mocked; + + beforeEach(() => { + const serverResponse = { + status: 200, + body: { + token_type: "Bearer", + scope: TestServerTokenResponse.scope, + expires_in: 3600, + ext_expires_in: 3600, + correlation_id: "test-correlation-id", + access_token: RenewedTokens.ACCESS_TOKEN, + refresh_token: RenewedTokens.REFRESH_TOKEN, + id_token: TestTokenResponse.ID_TOKEN, + client_info: TestTokenResponse.CLIENT_INFO, + }, + }; + + mockNetworkModule = { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn().mockResolvedValue(serverResponse), + } as unknown as jest.Mocked; + + mockBrowserConfig = { + auth: { + clientId: customAuthConfig.auth.clientId, + authority: customAuthConfig.auth.authority, + postLogoutRedirectUri: "http://example.com", + }, + system: { + loggerOptions: { + loggerCallback: jest.fn(), + piiLoggingEnabled: false, + logLevel: 2, + }, + networkClient: mockNetworkModule, + tokenRenewalOffsetSeconds: 300, + }, + cache: { + claimsBasedCachingEnabled: false, + }, + telemetry: {}, + } as unknown as jest.Mocked; + + const decodedStr = JSON.stringify(TestIdTokenClaims); + mockCrypto = { + createNewGuid: jest.fn(), + base64Decode: jest.fn().mockReturnValue(decodedStr), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockPerformanceClient = new StubPerformanceClient(); + const mockedApiClient = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + warning: jest.fn(), + trace: jest.fn(), + tracePii: jest.fn(), + error: jest.fn(), + verbosePii: jest.fn(), + errorPii: jest.fn(), + infoPii: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger.clone.mockReturnValue(mockLogger); + + mockCacheManager = new BrowserCacheManager( + customAuthConfig.auth.clientId, + mockBrowserConfig.cache, + mockCrypto, + mockLogger, + mockPerformanceClient, + mockEventHandler, + ); + + const authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockBrowserConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl, + ); + + client = new CustomAuthSilentCacheClient( + mockBrowserConfig, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear mocks between tests + }); + + describe("getAccessToken", () => { + let accountEntityToCache: AccountEntity; + let accessTokenEntityToCache: AccessTokenEntity; + let refreshTokenEntityToCache: RefreshTokenEntity; + + const defaultScopes = [...DefaultScopes]; + const commonSilentFlowRequest = { + authority: customAuthConfig.auth.authority, + correlationId: "test-correlation-id", + scopes: defaultScopes, + account: TestAccounDetails, + forceRefresh: false, + storeInCache: { + idToken: true, + accessToken: true, + refreshToken: true, + }, + } as CommonSilentFlowRequest; + + beforeEach(() => { + accountEntityToCache = AccountEntity.createFromAccountInfo(TestAccounDetails); + accessTokenEntityToCache = createAccessTokenEntity(mockCrypto); + refreshTokenEntityToCache = createRefreshTokenEntity(); + + jest.spyOn(AccountEntity, "generateHomeAccountId").mockReturnValue(TestHomeAccountId); + }); + + afterEach(() => { + mockCacheManager.clear(); + }); + + it("should get cached access token successfully and return.", async () => { + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache, + ); + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(accessTokenEntityToCache.secret); + const cachedAccessTokenScopes = accessTokenEntityToCache.target.split(" "); + expect(result.scopes).toEqual(cachedAccessTokenScopes); + }); + + it("should refresh access token (with valid cached refresh token) when cached access token is invalid.", async () => { + accessTokenEntityToCache.cachedAt = new Date(Date.now() - 1000).getTime().toString(); + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache, + ); + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(RenewedTokens.ACCESS_TOKEN); + + const refreshTokenKey = mockCacheManager + .getTokenKeys() + .refreshToken.filter((key) => key.includes(TestHomeAccountId))[0]; + const refreshToken = mockCacheManager.getRefreshTokenCredential(refreshTokenKey); + expect(refreshToken?.secret).toEqual("renewed-refresh-token"); + }); + + it("should renew token when no cached access token found (by giving unmatched scopes)", async () => { + // result in error when fetching access token because given scopes should be subset of cached access token scopes + const unmatchedScope = ["Mail.Read"]; + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache, + ); + + commonSilentFlowRequest.scopes = unmatchedScope; + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(RenewedTokens.ACCESS_TOKEN); + + const refreshTokenKey = mockCacheManager + .getTokenKeys() + .refreshToken.filter((key) => key.includes(TestHomeAccountId))[0]; + const refreshToken = mockCacheManager.getRefreshTokenCredential(refreshTokenKey); + expect(refreshToken?.secret).toEqual("renewed-refresh-token"); + }); + + it("should skip cache lookup and refresh access token when refreshForced is true", async () => { + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache, + ); + + commonSilentFlowRequest.forceRefresh = true; + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(RenewedTokens.ACCESS_TOKEN); + + const refreshTokenKey = mockCacheManager + .getTokenKeys() + .refreshToken.filter((key) => key.includes(TestHomeAccountId))[0]; + const refreshToken = mockCacheManager.getRefreshTokenCredential(refreshTokenKey); + expect(refreshToken?.secret).toEqual("renewed-refresh-token"); + }); + + it("should throw error when refresh token is not found", async () => { + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + ); + + const mockNoTokensFoundError = createInteractionRequiredAuthError( + InteractionRequiredAuthErrorCodes.noTokensFound, + ); + + commonSilentFlowRequest.forceRefresh = true; + + expect(client.acquireToken(commonSilentFlowRequest)).rejects.toThrow(mockNoTokensFoundError); + }); + + it("should throw error when refresh token is expired", async () => { + refreshTokenEntityToCache.expiresOn = TimeUtils.nowSeconds().toString(); + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache, + ); + + const mockRefreshTokenExpiredError = createInteractionRequiredAuthError( + InteractionRequiredAuthErrorCodes.refreshTokenExpired, + ); + + commonSilentFlowRequest.forceRefresh = true; + + expect(client.acquireToken(commonSilentFlowRequest)).rejects.toThrow(mockRefreshTokenExpiredError); + }); + }); + + describe("getCurrentAccount", () => { + it("should return account from cache", () => { + jest.spyOn(mockCacheManager, "getAllAccounts").mockReturnValue([ + { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + localAccountId: "test-local-account-id", + }, + { + homeAccountId: "test-home-account-id-2", + environment: "test-environment-2", + tenantId: "test-tenant-id-2", + username: "test-username-2", + localAccountId: "test-local-account-id-2", + }, + ]); + + const account = client.getCurrentAccount("test-corrlation-id"); + + expect(account).toBeDefined(); + expect(account?.homeAccountId).toBe("test-home-account-id"); + expect(account?.tenantId).toBe("test-tenant-id"); + expect(account?.username).toBe("test-username"); + expect(account?.localAccountId).toBe("test-local-account-id"); + expect(account?.environment).toBe("test-environment"); + }); + + it("should return null if no account found", () => { + jest.spyOn(mockCacheManager, "getAllAccounts").mockReturnValue([]); + + const account = client.getCurrentAccount("test-corrlation-id"); + + expect(account).toBe(null); + }); + }); + + describe("logout", () => { + it("should logout successfully", async () => { + jest.spyOn(mockCacheManager, "getActiveAccount").mockReturnValue({ + homeAccountId: "test-home-account-id-2", + environment: "test-environment-2", + tenantId: "test-tenant-id-2", + username: "test-username-2", + localAccountId: "test-local-account-id-2", + }); + + jest.spyOn(mockCacheManager, "removeAccount"); + + await client.logout({ + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + localAccountId: "test-local-account-id", + }, + }); + + expect(mockCacheManager.removeAccount).toHaveBeenCalled(); + expect(mockNavigationClient.navigateExternal).toHaveBeenCalled(); + }); + }); +}); + +async function saveTokensIntoCache( + correlationId: string, + cacheManager: BrowserCacheManager, + accountEntity?: AccountEntity, + accessTokenEntity?: AccessTokenEntity, + refreshTokenEntity?: RefreshTokenEntity, +): Promise { + accountEntity ? await cacheManager.setAccount(accountEntity, correlationId) : null; + accessTokenEntity ? await cacheManager.setAccessTokenCredential(accessTokenEntity, correlationId) : null; + refreshTokenEntity ? await cacheManager.setRefreshTokenCredential(refreshTokenEntity, correlationId) : null; +} + +function createAccessTokenEntity(browserCrypto: ICrypto): AccessTokenEntity { + const expiresOn = new Date(Date.now() + TestServerTokenResponse.expires_in * 1000).getTime(); + + return CacheHelpers.createAccessTokenEntity( + TestHomeAccountId, + TestAccounDetails.environment, + TestTokenResponse.ACCESS_TOKEN, + customAuthConfig.auth.clientId, + TestTenantId, + TestServerTokenResponse.scope, + expiresOn, + expiresOn + 0, + browserCrypto.base64Decode, + undefined, + TestServerTokenResponse.token_type as AuthenticationScheme, + ); +} + +function createRefreshTokenEntity(): RefreshTokenEntity { + return CacheHelpers.createRefreshTokenEntity( + TestHomeAccountId, + TestAccounDetails.environment, + TestServerTokenResponse.refresh_token, + customAuthConfig.auth.clientId, + ); +} diff --git a/lib/msal-custom-auth/test/integration_tests/GetAccount.spec.ts b/lib/msal-custom-auth/test/integration_tests/GetAccount.spec.ts new file mode 100644 index 0000000000..6fe5b61e49 --- /dev/null +++ b/lib/msal-custom-auth/test/integration_tests/GetAccount.spec.ts @@ -0,0 +1,166 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthPublicClientApplication } from "../../src/CustomAuthPublicClientApplication.js"; +import { ICustomAuthPublicClientApplication } from "../../src/ICustomAuthPublicClientApplication.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { CustomAuthAccountData } from "../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { TestHomeAccountId, TestTenantId, TestTokenResponse, TestUsername } from "../test_resources/TestConstants.js"; +import { CustomAuthStandardController } from "../../src/controller/CustomAuthStandardController.js"; + +describe("GetAccount", () => { + let app: CustomAuthPublicClientApplication; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create(customAuthConfig)) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app["customAuthController"] as CustomAuthStandardController; + if (controller && controller["eventHandler"] && controller["eventHandler"]["broadcastChannel"]) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + describe("GetAccount", () => { + it("should return correct account data after the sign-in is successful", async () => { + await signIn(app); + + const accountData = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + expect(accountData).toBeDefined(); + expect(accountData.error).toBeUndefined(); + expect(accountData.isCompleted()).toBe(true); + expect(accountData.data).toBeDefined(); + expect(accountData.data).toBeInstanceOf(CustomAuthAccountData); + expect(accountData.data?.getAccount()).toBeDefined(); + + const accountInfo = accountData.data?.getAccount(); + + expect(accountInfo?.homeAccountId).toStrictEqual(TestHomeAccountId); + expect(accountInfo?.tenantId).toStrictEqual(TestTenantId); + expect(accountInfo?.username).toStrictEqual(TestUsername); + + await accountData.data?.signOut(); + }); + + it("should return error data if the account is not found", async () => { + const accountData = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + expect(accountData).toBeDefined(); + expect(accountData.error).toBeDefined(); + expect(accountData.error?.errorData).toBeDefined(); + expect(accountData.error?.isCurrentAccountNotFound()).toBe(true); + expect(accountData.isFailed()).toBe(true); + expect(accountData.data).toBeUndefined(); + }); + }); + + describe("SignOut", () => { + it("should sign the user out after the sign-in is successful", async () => { + await signIn(app); + + const result = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + const accountData = result.data; + + expect(accountData).toBeDefined(); + + const signOutResult = await accountData?.signOut(); + + expect(signOutResult).toBeDefined(); + expect(signOutResult?.error).toBeUndefined(); + expect(signOutResult?.isCompleted()).toBe(true); + + const accountResultAfterSignOut = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + expect(accountResultAfterSignOut).toBeDefined(); + expect(accountResultAfterSignOut.error).toBeDefined(); + expect(accountResultAfterSignOut.error?.isCurrentAccountNotFound()).toBe(true); + }); + + it("should return error data if try to sign out an user who is not signed in", async () => { + await signIn(app); + + const result = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + await result.data?.signOut(); + + const accountData = result.data; + const signOutResult = await accountData?.signOut(); + + expect(signOutResult).toBeDefined(); + expect(signOutResult?.error).toBeDefined(); + expect(signOutResult?.isFailed()).toBe(true); + expect(signOutResult?.error?.isUserNotSignedIn()).toBe(true); + }); + }); +}); + +async function signIn(app: ICustomAuthPublicClientApplication): Promise { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: "test-correlation-id", + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: TestTokenResponse.ID_TOKEN, + access_token: TestTokenResponse.ACCESS_TOKEN, + refresh_token: TestTokenResponse.REFRESH_TOKEN, + client_info: TestTokenResponse.CLIENT_INFO, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "abc@test.com", + password: "test-pwd", + correlationId: "test-correlation-id", + }; + + await app.signIn(signInInputs); +} diff --git a/lib/msal-custom-auth/test/integration_tests/ResetPassword.spec.ts b/lib/msal-custom-auth/test/integration_tests/ResetPassword.spec.ts new file mode 100644 index 0000000000..1afde277eb --- /dev/null +++ b/lib/msal-custom-auth/test/integration_tests/ResetPassword.spec.ts @@ -0,0 +1,262 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthPublicClientApplication } from "../../src/CustomAuthPublicClientApplication.js"; +import { ResetPasswordStartResult } from "../../src/reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { ResetPasswordSubmitCodeResult } from "../../src/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.js"; +import { ResetPasswordSubmitPasswordResult } from "../../src/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { SignInResult } from "../../src/sign_in/auth_flow/result/SignInResult.js"; +import { CustomAuthStandardController } from "../../src/controller/CustomAuthStandardController.js"; +import { ResetPasswordCodeRequiredState } from "../../src/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordPasswordRequiredState } from "../../src/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; +import { ResetPasswordCompletedState } from "../../src/reset_password/auth_flow/state/ResetPasswordCompletedState.js"; + +jest.mock("@azure/msal-browser", () => { + const actualModule = jest.requireActual("@azure/msal-browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + idToken: "test-id-token", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("Reset password", () => { + let app: CustomAuthPublicClientApplication; + const correlationId = "test-correlation-id"; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create(customAuthConfig)) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app["customAuthController"] as CustomAuthStandardController; + if (controller && controller["eventHandler"] && controller["eventHandler"]["broadcastChannel"]) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + it("should reset password successfully if the new password is valid", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + expires_in: 600, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + status: "in_progress", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + status: "in_progress", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + status: "succeeded", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.resetPassword(resetPasswordInputs); + + expect(startResult).toBeInstanceOf(ResetPasswordStartResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await (startResult.state as ResetPasswordCodeRequiredState).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(ResetPasswordSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + + expect(submitPasswordResult).toBeInstanceOf(ResetPasswordSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + const signInResult = await (submitPasswordResult.state as ResetPasswordCompletedState).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual("test-id-token"); + }); + + it("should reset password failed if the redirect challenge returned", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + challenge_type: "redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.resetPassword(resetPasswordInputs); + + expect(startResult).toBeInstanceOf(ResetPasswordStartResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should reset password failed if the given user is not found", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "user_not_found", + error_description: "The user account could not be found. Please check the username and try again.", + error_codes: [1003037], + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.resetPassword(resetPasswordInputs); + + expect(startResult).toBeInstanceOf(ResetPasswordStartResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isUserNotFound()).toBe(true); + }); +}); diff --git a/lib/msal-custom-auth/test/integration_tests/SignIn.spec.ts b/lib/msal-custom-auth/test/integration_tests/SignIn.spec.ts new file mode 100644 index 0000000000..e7a2d5fff8 --- /dev/null +++ b/lib/msal-custom-auth/test/integration_tests/SignIn.spec.ts @@ -0,0 +1,430 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthPublicClientApplication } from "../../src/CustomAuthPublicClientApplication.js"; +import { SignInResult } from "../../src/sign_in/auth_flow/result/SignInResult.js"; +import { SignInSubmitCodeResult } from "../../src/sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +import { SignInSubmitPasswordResult } from "../../src/sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { CustomAuthAccountData } from "../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthStandardController } from "../../src/controller/CustomAuthStandardController.js"; +import { SignInCodeRequiredState } from "../../src/sign_in/auth_flow/state/SignInCodeRequiredState.js"; +import { SignInPasswordRequiredState } from "../../src/sign_in/auth_flow/state/SignInPasswordRequiredState.js"; + +jest.mock("@azure/msal-browser", () => { + const actualModule = jest.requireActual("@azure/msal-browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("Sign in", () => { + let app: CustomAuthPublicClientApplication; + const correlationId = "test-correlation-id"; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create(customAuthConfig)) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app["customAuthController"] as CustomAuthStandardController; + if (controller && controller["eventHandler"] && controller["eventHandler"]["broadcastChannel"]) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + it("should sign in successfully if the challenge type is password and password is provided initially", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should sign in successfully if the challenge type is oob", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + code_length: 8, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCodeRequired()).toBe(true); + + const state = signInResult.state as SignInCodeRequiredState; + const submitCodeResult = await state.submitCode("12345678"); + + expect(submitCodeResult).toBeDefined(); + expect(submitCodeResult).toBeInstanceOf(SignInSubmitCodeResult); + expect(submitCodeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should sign in successfully if the challenge type is password", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isPasswordRequired()).toBe(true); + + const state = signInResult.state as SignInPasswordRequiredState; + + const submitCodeResult = await state.submitPassword("valid-password"); + + expect(submitCodeResult).toBeDefined(); + expect(submitCodeResult).toBeInstanceOf(SignInSubmitPasswordResult); + expect(submitCodeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should sign in failed with error if the challenge type is redirect", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + challenge_type: "redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeDefined(); + expect(signInResult.isFailed()).toBe(true); + expect(signInResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should sign in failed with error if the given user is not found", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "user_not_found", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeDefined(); + expect(signInResult.isFailed()).toBe(true); + expect(signInResult.error?.isUserNotFound()).toBe(true); + }); + + it("should sign in failed if the challenge type is password but given password is incorrect", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "AADSTS901007: Error validating credentials due to invalid username or password.", + error_codes: [50126], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + password: "invalid-password", + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeDefined(); + expect(signInResult.isFailed()).toBe(true); + expect(signInResult.error?.isPasswordIncorrect()).toBe(true); + }); + + it("should sign in failed if the challenge type is oob but given code is incorrect", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "AADSTS901007: Error validating credentials due to invalid username or password.", + error_codes: [], + suberror: "invalid_oob_value", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCodeRequired()).toBe(true); + + const state = signInResult.state as SignInCodeRequiredState; + + const submitCodeResult = await state.submitCode("invalid-code"); + + expect(submitCodeResult).toBeDefined(); + expect(submitCodeResult).toBeInstanceOf(SignInSubmitCodeResult); + expect(submitCodeResult.error).toBeDefined(); + expect(submitCodeResult.error?.isInvalidCode()).toBe(true); + }); +}); diff --git a/lib/msal-custom-auth/test/integration_tests/SignUp.spec.ts b/lib/msal-custom-auth/test/integration_tests/SignUp.spec.ts new file mode 100644 index 0000000000..180ed00b00 --- /dev/null +++ b/lib/msal-custom-auth/test/integration_tests/SignUp.spec.ts @@ -0,0 +1,651 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthPublicClientApplication } from "../../src/CustomAuthPublicClientApplication.js"; +import { SignUpSubmitCodeResult } from "../../src/sign_up/auth_flow/result/SignUpSubmitCodeResult.js"; +import { SignUpSubmitPasswordResult } from "../../src/sign_up/auth_flow/result/SignUpSubmitPasswordResult.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { SignInResult } from "../../src/sign_in/auth_flow/result/SignInResult.js"; +import { SignUpInputs } from "../../src/CustomAuthActionInputs.js"; +import { UserAccountAttributes } from "../../src/UserAccountAttributes.js"; +import { SignUpResult } from "../../src/sign_up/auth_flow/result/SignUpResult.js"; +import { SignUpSubmitAttributesResult } from "../../src/sign_up/auth_flow/result/SignUpSubmitAttributesResult.js"; +import { CustomAuthStandardController } from "../../src/controller/CustomAuthStandardController.js"; +import { SignUpCodeRequiredState } from "../../src/sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +import { SignUpCompletedState } from "../../src/sign_up/auth_flow/state/SignUpCompletedState.js"; +import { SignUpPasswordRequiredState } from "../../src/sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +import { SignUpAttributesRequiredState } from "../../src/sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; + +jest.mock("@azure/msal-browser", () => { + const actualModule = jest.requireActual("@azure/msal-browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + idToken: "test-id-token", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("Sign up", () => { + let app: CustomAuthPublicClientApplication; + const correlationId = "test-correlation-id"; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create(customAuthConfig)) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app["customAuthController"] as CustomAuthStandardController; + if (controller && controller["eventHandler"] && controller["eventHandler"]["broadcastChannel"]) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + it("should sign up successfully if no password is provided when starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-6", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes = new UserAccountAttributes(); + attributes.setCity("test-city"); + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await (startResult.state as SignUpCodeRequiredState).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await (submitCodeResult.state as SignUpPasswordRequiredState).submitPassword( + "valid-password", + ); + + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + const signInResult = await (submitPasswordResult.state as SignUpCompletedState).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual("test-id-token"); + }); + + it("should sign up successfully if attributes are required after starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "attributes_required", + error_description: "User attributes required", + error_codes: [55106], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "test-continuation-token-3", + required_attributes: [ + { + name: "displayName", + type: "string", + required: true, + options: { + regex: ".*@.**$", + }, + }, + { + name: "extension_2588abcdwhtfeehjjeeqwertc_age", + type: "string", + required: true, + }, + { + name: "postalCode", + type: "string", + required: true, + options: { + regex: "^[1-9][0-9]*$", + }, + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes = new UserAccountAttributes(); + attributes.setCity("test-city"); + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await (startResult.state as SignUpCodeRequiredState).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isAttributesRequired()).toBe(true); + expect((submitCodeResult.state as SignUpAttributesRequiredState)?.getRequiredAttributes().length).toBe(3); + + const requiredAttributes = new UserAccountAttributes(); + requiredAttributes.setDisplayName("test-display-name"); + const submitAttributesResult = await (submitCodeResult.state as SignUpAttributesRequiredState).submitAttributes( + requiredAttributes, + ); + + expect(submitAttributesResult).toBeInstanceOf(SignUpSubmitAttributesResult); + expect(submitAttributesResult.error).toBeUndefined(); + expect(submitAttributesResult.isCompleted()).toBe(true); + + const signInResult = await (submitAttributesResult.state as SignUpCompletedState).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual("test-id-token"); + }); + + it("should sign up successfully if password and attributes are required after starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "attributes_required", + error_description: "User attributes required", + error_codes: [55106], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "test-continuation-token-5", + required_attributes: [ + { + name: "displayName", + type: "string", + required: true, + options: { + regex: ".*@.**$", + }, + }, + { + name: "extension_2588abcdwhtfeehjjeeqwertc_age", + type: "string", + required: true, + }, + { + name: "postalCode", + type: "string", + required: true, + options: { + regex: "^[1-9][0-9]*$", + }, + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-6", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes = new UserAccountAttributes(); + attributes.setCity("test-city"); + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await (startResult.state as SignUpCodeRequiredState).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await (submitCodeResult.state as SignUpPasswordRequiredState).submitPassword( + "valid-password", + ); + + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isAttributesRequired()).toBe(true); + + const requiredAttributes = new UserAccountAttributes(); + requiredAttributes.setDisplayName("test-display-name"); + const submitAttributesResult = await ( + submitPasswordResult.state as SignUpAttributesRequiredState + ).submitAttributes(requiredAttributes); + + expect(submitAttributesResult).toBeInstanceOf(SignUpSubmitAttributesResult); + expect(submitAttributesResult.error).toBeUndefined(); + expect(submitAttributesResult.isCompleted()).toBe(true); + + const signInResult = await (submitAttributesResult.state as SignUpCompletedState).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual("test-id-token"); + }); + + it("should sign up successfully if the password and attributes are provided when starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes = new UserAccountAttributes(); + attributes.setCity("test-city"); + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + password: "valid-password", + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await (startResult.state as SignUpCodeRequiredState).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isCompleted()).toBe(true); + + const signInResult = await (submitCodeResult.state as SignUpCompletedState).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual("test-id-token"); + }); + + it("should sign up failed if the redirect challenge returned", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + challenge_type: "redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signUpInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should sign up failed if the given user is not found", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "user_already_exists", + error_description: "It looks like you may already have an account.", + error_codes: [1003037], + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signUpInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isUserAlreadyExists()).toBe(true); + }); +}); diff --git a/lib/msal-custom-auth/test/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts b/lib/msal-custom-auth/test/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts new file mode 100644 index 0000000000..3b32d848ee --- /dev/null +++ b/lib/msal-custom-auth/test/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts @@ -0,0 +1,114 @@ +import { CustomAuthApiError, RedirectError } from "../../../../src/core/error/CustomAuthApiError.js"; +import { + CustomAuthApiErrorCode, + CustomAuthApiSuberror, +} from "../../../../src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { InvalidArgumentError } from "../../../../src/index.js"; +import { + ResetPasswordError, + ResetPasswordResendCodeError, + ResetPasswordSubmitCodeError, + ResetPasswordSubmitPasswordError, +} from "../../../../src/reset_password/auth_flow/error_type/ResetPasswordError.js"; + +describe("ResetPasswordError", () => { + it("should correctly identify user not found error", () => { + const error = new CustomAuthApiError("user_not_found", "User not found"); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isUserNotFound()).toBe(true); + }); + + it("should correctly identify invalid username error", () => { + const error = new InvalidArgumentError("Invalid username"); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isInvalidUsername()).toBe(true); + + const error2 = new CustomAuthApiError("Some Error", "username parameter is empty or not valid", undefined, [ + 90100, + ]); + const resetPasswordError2 = new ResetPasswordError(error2); + expect(resetPasswordError2.isInvalidUsername()).toBe(true); + }); + + it("should correctly identify unsupported challenge type error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "The challenge_type list parameter contains an unsupported challenge type", + ); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isUnsupportedChallengeType()).toBe(true); + + const error2 = new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "Unsupported challenge type", + ); + const resetPasswordError2 = new ResetPasswordError(error2); + expect(resetPasswordError2.isUnsupportedChallengeType()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isRedirectRequired()).toBe(true); + }); +}); + +describe("ResetPasswordSubmitPasswordError", () => { + it("should correctly identify invalid password error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid password", + undefined, + undefined, + CustomAuthApiSuberror.PASSWORD_IS_INVALID, + ); + const resetPasswordError = new ResetPasswordSubmitPasswordError(error); + expect(resetPasswordError.isInvalidPassword()).toBe(true); + + const error2 = new InvalidArgumentError("password is required"); + const resetPasswordError2 = new ResetPasswordSubmitPasswordError(error2); + expect(resetPasswordError2.isInvalidPassword()).toBe(true); + }); + + it("should correctly identify password reset failed error", () => { + const error1 = new CustomAuthApiError("password_reset_timeout", "Password reset timeout"); + const resetPasswordError1 = new ResetPasswordSubmitPasswordError(error1); + expect(resetPasswordError1.isPasswordResetFailed()).toBe(true); + + const error2 = new CustomAuthApiError("password_change_failed", "Password reset is failed"); + const resetPasswordError2 = new ResetPasswordSubmitPasswordError(error2); + expect(resetPasswordError2.isPasswordResetFailed()).toBe(true); + }); +}); + +describe("ResetPasswordSubmitCodeError", () => { + it("should correctly identify invalid code error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid code", + undefined, + undefined, + CustomAuthApiSuberror.INVALID_OOB_VALUE, + ); + const resetPasswordError = new ResetPasswordSubmitCodeError(error); + expect(resetPasswordError.isInvalidCode()).toBe(true); + + const error2 = new InvalidArgumentError("Invalid code"); + const resetPasswordError2 = new ResetPasswordSubmitCodeError(error2); + expect(resetPasswordError2.isInvalidCode()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const resetPasswordError = new ResetPasswordSubmitCodeError(error); + expect(resetPasswordError.isRedirectRequired()).toBe(true); + }); +}); + +describe("ResetPasswordResendCodeError", () => { + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const resetPasswordError = new ResetPasswordResendCodeError(error); + expect(resetPasswordError.isRedirectRequired()).toBe(true); + }); +}); diff --git a/lib/msal-custom-auth/test/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts b/lib/msal-custom-auth/test/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts new file mode 100644 index 0000000000..543ba72cf2 --- /dev/null +++ b/lib/msal-custom-auth/test/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts @@ -0,0 +1,133 @@ +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../src/core/error/InvalidArgumentError.js"; +import { ResetPasswordSubmitCodeError } from "../../../../src/reset_password/auth_flow/error_type/ResetPasswordError.js"; +import { ResetPasswordResendCodeResult } from "../../../../src/reset_password/auth_flow/result/ResetPasswordResendCodeResult.js"; +import { ResetPasswordSubmitCodeResult } from "../../../../src/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.js"; +import { ResetPasswordCodeRequiredState } from "../../../../src/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { + ResetPasswordCodeRequiredResult, + ResetPasswordPasswordRequiredResult, +} from "../../../../src/reset_password/interaction_client/result/ResetPasswordActionResult.js"; +import { ResetPasswordClient } from "../../../../src/reset_password/interaction_client/ResetPasswordClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("ResetPasswordCodeRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code"] }, + } as unknown as jest.Mocked; + + const mockResetPasswordClient = { + submitCode: jest.fn(), + resendCode: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: ResetPasswordCodeRequiredState; + + beforeEach(() => { + state = new ResetPasswordCodeRequiredState({ + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + resetPasswordClient: mockResetPasswordClient, + signInClient: mockSignInClient, + cacheClient: {} as unknown as jest.Mocked, + username: username, + codeLength: 8, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitCode", () => { + it("should return an error result if code is empty", async () => { + const result = await state.submitCode(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(ResetPasswordSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("code"); + }); + + it("should successfully submit a code and return password required state", async () => { + mockResetPasswordClient.submitCode.mockResolvedValue( + new ResetPasswordPasswordRequiredResult(correlationId, "continuation-token"), + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitCodeResult); + expect(result.isPasswordRequired()).toBe(true); + expect(mockResetPasswordClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should successfully submit a code and return password-required state if password is required", async () => { + mockResetPasswordClient.submitCode.mockResolvedValue( + new ResetPasswordPasswordRequiredResult(correlationId, "new-continuation-token"), + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitCodeResult); + expect(result.isPasswordRequired()).toBe(true); + expect(mockResetPasswordClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + }); + + describe("resendCode", () => { + it("should successfully resend a code and return a code required state", async () => { + mockResetPasswordClient.resendCode.mockResolvedValue( + new ResetPasswordCodeRequiredResult( + correlationId, + "new-continuation-token", + "code", + "email", + 6, + "email-otp", + ), + ); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordResendCodeResult); + expect(result.data).toBeUndefined(); + expect(result.isCodeRequired()).toBeTruthy(); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts b/lib/msal-custom-auth/test/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts new file mode 100644 index 0000000000..4127e706b3 --- /dev/null +++ b/lib/msal-custom-auth/test/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts @@ -0,0 +1,108 @@ +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../src/core/error/InvalidArgumentError.js"; +import { ResetPasswordSubmitPasswordError } from "../../../../src/reset_password/auth_flow/error_type/ResetPasswordError.js"; +import { ResetPasswordSubmitPasswordResult } from "../../../../src/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.js"; +import { ResetPasswordCompletedResult } from "../../../../src/reset_password/interaction_client/result/ResetPasswordActionResult.js"; +import { ResetPasswordClient } from "../../../../src/reset_password/interaction_client/ResetPasswordClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { ResetPasswordPasswordRequiredState } from "../../../../src/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; +import { CustomAuthApiError } from "../../../../src/core/error/CustomAuthApiError.js"; + +describe("ResetPasswordPasswordRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["password"] }, + } as unknown as jest.Mocked; + + const mockResetPasswordClient = { + submitNewPassword: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: ResetPasswordPasswordRequiredState; + + beforeEach(() => { + state = new ResetPasswordPasswordRequiredState({ + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + resetPasswordClient: mockResetPasswordClient, + signInClient: mockSignInClient, + cacheClient: {} as unknown as jest.Mocked, + username: username, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitPassword", () => { + it("should return an error result if password is empty", async () => { + const result = await state.submitNewPassword(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(ResetPasswordSubmitPasswordError); + expect(result.error?.isInvalidPassword()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("password"); + }); + + it("should successfully submit a password and return completed state", async () => { + mockResetPasswordClient.submitNewPassword.mockResolvedValue( + new ResetPasswordCompletedResult(correlationId, "new-continuation-token"), + ); + + const result = await state.submitNewPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitPasswordResult); + expect(result.isCompleted()).toBe(true); + expect(mockResetPasswordClient.submitNewPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + newPassword: "valid-password", + username: username, + }); + }); + + it("should successfully submit a password and return completed state", async () => { + mockResetPasswordClient.submitNewPassword.mockRejectedValue( + new CustomAuthApiError("invalid_grant", "Invalid grant", correlationId, [], "password_too_weak"), + ); + + const result = await state.submitNewPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitPasswordResult); + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(ResetPasswordSubmitPasswordError); + expect(result.error?.isInvalidPassword()).toBe(true); + expect(mockResetPasswordClient.submitNewPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + newPassword: "valid-password", + username: username, + }); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/reset_password/interaction_client/ResetPasswordClient.spec.ts b/lib/msal-custom-auth/test/reset_password/interaction_client/ResetPasswordClient.spec.ts new file mode 100644 index 0000000000..3f400ed05a --- /dev/null +++ b/lib/msal-custom-auth/test/reset_password/interaction_client/ResetPasswordClient.spec.ts @@ -0,0 +1,360 @@ +jest.mock("../../../src/CustomAuthConstants.js", () => ({ + PasswordResetPollingTimeoutInMs: 5000, + ChallengeType: { + PASSWORD: "password", + OOB: "oob", + REDIRECT: "redirect", + }, + ResetPasswordPollStatus: { + IN_PROGRESS: "in_progress", + SUCCEEDED: "succeeded", + FAILED: "failed", + NOT_STARTED: "not_started", + }, +})); + +import { + BrowserCacheManager, + BrowserConfiguration, + EventHandler, + ICrypto, + INavigationClient, + INetworkModule, + IPerformanceClient, + Logger, +} from "@azure/msal-browser"; +import { ResetPasswordClient } from "../../../src/reset_password/interaction_client/ResetPasswordClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../src/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../src/CustomAuthConstants.js"; +import { + ResetPasswordCodeRequiredResult, + ResetPasswordCompletedResult, + ResetPasswordPasswordRequiredResult, +} from "../../../src/reset_password/interaction_client/result/ResetPasswordActionResult.js"; +import { CustomAuthApiError } from "../../../src/index.js"; +import { CustomAuthApiErrorCode } from "../../../src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import exp from "constants"; + +jest.mock("../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOob: jest.fn(), + signInWithContinuationToken: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { mockedApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient }; +}); + +describe("ResetPasswordClient", () => { + let client: ResetPasswordClient; + let authority: CustomAuthAuthority; + const { mockedApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient } = jest.requireMock( + "../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", + ); + const mockConfig = { + auth: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } as unknown as jest.Mocked; + + beforeEach(() => { + jest.resetAllMocks(); + const mockBrowserConfiguration = { + system: { + networkClient: { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn(), + } as unknown as jest.Mocked, + }, + auth: { + clientId: customAuthConfig.auth.clientId, + }, + } as unknown as jest.Mocked; + + const mockCacheManager = { + getWrapperMetadata: jest.fn(), + getServerTelemetry: jest.fn(), + generateAuthorityMetadataCacheKey: jest.fn(), + setAuthorityMetadata: jest.fn(), + } as unknown as jest.Mocked; + mockCacheManager.getWrapperMetadata.mockReturnValue(["", ""]); + mockCacheManager.getServerTelemetry.mockReturnValue(null); + + const mockCrypto = { + createNewGuid: jest.fn(), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockNavigationClient = {} as unknown as jest.Mocked; + const mockPerformanceClient = {} as unknown as jest.Mocked; + const mockNetworkModule = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + infoPii: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + mockLogger.clone.mockReturnValue(mockLogger); + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl, + ); + + client = new ResetPasswordClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("start", () => { + it("should return ResetPasswordCodeRequiredResult suceesfully", async () => { + resetPasswordApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + binding_method: "email", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(ResetPasswordCodeRequiredResult); + const codeSendResult = result as ResetPasswordCodeRequiredResult; + expect(codeSendResult.correlationId).toBe("corr123"); + expect(codeSendResult.continuationToken).toBe("continuation_token_2"); + expect(codeSendResult.codeLength).toBe(6); + expect(codeSendResult.challengeChannel).toBe("email"); + expect(codeSendResult.challengeTargetLabel).toBe("email"); + expect(codeSendResult.bindingMethod).toBe("email"); + }); + + it("should return ResetPasswordPasswordRequiredResult with error when challenge type is not OOB", async () => { + resetPasswordApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + await expect( + client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }), + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + errorDescription: "Unsupported challenge type 'password'.", + correlationId: "corr123", + }); + }); + }); + + describe("submitCode", () => { + it("should return ResetPasswordPasswordRequiredResult successfully", async () => { + resetPasswordApiClient.continueWithCode.mockResolvedValue({ + continuation_token: "continuation_token_2", + correlation_id: "corr123", + expires_in: 3600, + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(ResetPasswordPasswordRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + }); + + describe("submitNewPassword", () => { + it("should return ResetPasswordCompletedResult for valid password", async () => { + resetPasswordApiClient.submitNewPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + poll_interval: 1, + correlation_id: "corr123", + }); + + resetPasswordApiClient.pollCompletion + .mockResolvedValueOnce({ + status: "in-progress", + correlation_id: "corr123", + }) + .mockResolvedValueOnce({ + status: "in-progress", + correlation_id: "corr123", + }) + .mockResolvedValueOnce({ + status: "succeeded", + continuation_token: "continuation_token_3", + correlation_id: "corr123", + }); + + const result = await client.submitNewPassword({ + newPassword: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(ResetPasswordCompletedResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_3"); + expect(resetPasswordApiClient.pollCompletion).toHaveBeenCalledTimes(3); + }, 5000); + + it("should return ResetPasswordCompletedResult with error if the password-change is failed", async () => { + resetPasswordApiClient.submitNewPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + poll_interval: 1, + correlation_id: "corr123", + }); + + resetPasswordApiClient.pollCompletion.mockResolvedValue({ + status: "failed", + correlation_id: "corr123", + }); + + await expect( + client.submitNewPassword({ + newPassword: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }), + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.PASSWORD_CHANGE_FAILED, + errorDescription: "Password is failed to be reset.", + correlationId: "corr123", + }); + }, 5000); + + it("should return ResetPasswordCompletedResult with error if the reset password is timeout", async () => { + resetPasswordApiClient.submitNewPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + poll_interval: 1, + correlation_id: "corr123", + }); + + resetPasswordApiClient.pollCompletion.mockResolvedValue({ + status: "in-progress", + correlation_id: "corr123", + }); + + await expect( + client.submitNewPassword({ + newPassword: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }), + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.PASSWORD_RESET_TIMEOUT, + errorDescription: "Password reset flow has timed out.", + correlationId: "corr123", + }); + }, 10000); + }); + + describe("resendCode", () => { + it("should return ResetPasswordCodeRequiredResult", async () => { + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + binding_method: "email", + }); + + const result = await client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(ResetPasswordCodeRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_in/auth_flow/error_type/SignInError.spec.ts b/lib/msal-custom-auth/test/sign_in/auth_flow/error_type/SignInError.spec.ts new file mode 100644 index 0000000000..4aa7cc3c85 --- /dev/null +++ b/lib/msal-custom-auth/test/sign_in/auth_flow/error_type/SignInError.spec.ts @@ -0,0 +1,104 @@ +import { CustomAuthApiError, RedirectError } from "../../../../src/core/error/CustomAuthApiError.js"; +import { InvalidArgumentError } from "../../../../src/index.js"; +import { + SignInError, + SignInSubmitCodeError, + SignInSubmitPasswordError, +} from "../../../../src/sign_in/auth_flow/error_type/SignInError.js"; + +describe("SignInError", () => { + const mockErrorData = { + error: "", + errorDescription: "", + }; + + it("should return true for isUserNotFound when error is USER_NOT_FOUND", () => { + const errorData = { ...mockErrorData, error: "user_not_found" }; + const signInError = new SignInError(errorData as any); + expect(signInError.isUserNotFound()).toBe(true); + }); + + it("should return true for isInvalidUsername when errorDescription mentions username", () => { + const errorData = new CustomAuthApiError( + "invalid_request", + "username parameter is empty or not valid", + "correlation-id", + [90100], + ); + + const signInError = new SignInError(errorData as any); + expect(signInError.isInvalidUsername()).toBe(true); + }); + + it("should return true for isInvalidPassword when error matches INVALID_GRANT with 50126", () => { + const errorData = new CustomAuthApiError("invalid_grant", "Invalid grant", "correlation-id", [50126]); + const signInError = new SignInError(errorData); + expect(signInError.isPasswordIncorrect()).toBe(true); + }); + + it("should return true for isInvalidPassword when error is InvalidArgumentError and message includes 'password'", () => { + const errorData = new InvalidArgumentError("password"); + const signInError = new SignInError(errorData); + expect(signInError.isPasswordIncorrect()).toBe(true); + }); + + it("should return true for isUnsupportedChallengeType when error matches unsupported types", () => { + const errorData = { + ...mockErrorData, + error: "unsupported_challenge_type", + }; + const signInError = new SignInError(errorData as any); + expect(signInError.isUnsupportedChallengeType()).toBe(true); + }); + + it("should return true for isRedirect when error is an instance of RedirectError", () => { + const redirectError = new RedirectError(mockErrorData as any); + const signInError = new SignInError(redirectError as any); + expect(signInError.isRedirectRequired()).toBe(true); + }); + + it("should return false for all methods when error data does not match any condition", () => { + const errorData = { ...mockErrorData, error: "some_other_error" }; + const signInError = new SignInError(errorData as any); + + expect(signInError.isUserNotFound()).toBe(false); + expect(signInError.isInvalidUsername()).toBe(false); + expect(signInError.isPasswordIncorrect()).toBe(false); + expect(signInError.isUnsupportedChallengeType()).toBe(false); + expect(signInError.isRedirectRequired()).toBe(false); + }); +}); + +describe("SignInSubmitPasswordError", () => { + it("should return true for isInvalidPassword when error matches INVALID_GRANT with 50126", () => { + const errorData = new CustomAuthApiError("invalid_grant", "Invalid grant", "correlation-id", [50126]); + const submitPasswordError = new SignInSubmitPasswordError(errorData); + expect(submitPasswordError.isInvalidPassword()).toBe(true); + }); + + it("should return true for isInvalidPassword when error is InvalidArgumentError and message includes 'password'", () => { + const errorData = new InvalidArgumentError("password"); + const submitPasswordError = new SignInSubmitPasswordError(errorData); + expect(submitPasswordError.isInvalidPassword()).toBe(true); + }); +}); + +describe("SignInSubmitCodeError", () => { + it("should return true for isInvalidCode when error matches INVALID_GRANT and INVALID_OOB_VALUE", () => { + const errorData = new CustomAuthApiError( + "invalid_grant", + "Invalid grant", + "correlation-id", + [], + "invalid_oob_value", + ); + const submitCodeError = new SignInSubmitCodeError(errorData); + expect(submitCodeError.isInvalidCode()).toBe(true); + }); + + it("should return true for isInvalidCode when error is InvalidArgumentError and message includes 'code'", () => { + const errorData = new InvalidArgumentError("code"); + const submitCodeError = new SignInSubmitCodeError(errorData); + expect(submitCodeError.isInvalidCode()).toBe(true); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts b/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts new file mode 100644 index 0000000000..aaf0a976cf --- /dev/null +++ b/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts @@ -0,0 +1,154 @@ +import { CustomAuthAccountData } from "../../../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../src/core/error/InvalidArgumentError.js"; +import { + SignInResendCodeError, + SignInSubmitCodeError, +} from "../../../../src/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInResendCodeResult } from "../../../../src/sign_in/auth_flow/result/SignInResendCodeResult.js"; +import { SignInSubmitCodeResult } from "../../../../src/sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +import { + SignInCodeSendResult, + SignInCompletedResult, +} from "../../../../src/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { Logger } from "@azure/msal-browser"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { SignInCodeRequiredState } from "../../../../src/sign_in/auth_flow/state/SignInCodeRequiredState.js"; + +describe("SignInCodeRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code"] }, + } as unknown as jest.Mocked; + + const mockSignInClient = { + submitCode: jest.fn(), + resendCode: jest.fn(), + } as unknown as jest.Mocked; + + const mockCacheClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignInCodeRequiredState; + + beforeEach(() => { + state = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: ["scope1", "scope2"], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitCode", () => { + it("should return an error result if code is empty", async () => { + const result = await state.submitCode(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignInSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("code"); + }); + + it("should successfully submit a code and return a result", async () => { + mockSignInClient.submitCode.mockResolvedValue( + new SignInCompletedResult(correlationId, { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }), + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect(mockSignInClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should return an error result if submitCode throws an error", async () => { + const mockError = new Error("Submission failed"); + mockSignInClient.submitCode.mockRejectedValue(mockError); + + const result = await state.submitCode("valid-code"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInSubmitCodeError); + }); + }); + + describe("resendCode", () => { + it("should successfully resend a code and return a result", async () => { + mockSignInClient.resendCode.mockResolvedValue( + new SignInCodeSendResult(correlationId, "new-continuation-token", "code", "email", 6, "email-otp"), + ); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResendCodeResult); + expect(result.data).toBeUndefined(); + expect(result.isCodeRequired()).toBeTruthy(); + }); + + it("should return an error result if resendCode throws an error", async () => { + const mockError = new Error("Resend code failed"); + mockSignInClient.resendCode.mockRejectedValue(mockError); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResendCodeResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInResendCodeError); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInContinuationState.spec.ts b/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInContinuationState.spec.ts new file mode 100644 index 0000000000..fda97b8706 --- /dev/null +++ b/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInContinuationState.spec.ts @@ -0,0 +1,106 @@ +import { Logger } from "@azure/msal-browser"; +import { CustomAuthAccountData } from "../../../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { SignInError } from "../../../../src/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInResult } from "../../../../src/sign_in/auth_flow/result/SignInResult.js"; +import { SignInContinuationState } from "../../../../src/sign_in/auth_flow/state/SignInContinuationState.js"; +import { SignInCompletedResult } from "../../../../src/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { SignInScenario } from "../../../../src/sign_in/auth_flow/SignInScenario.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignInContinuationState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code", "password", "redirect"] }, + } as unknown as jest.Mocked; + + const mockSignInClient = { + signInWithContinuationToken: jest.fn(), + } as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const mockCacheClient = {} as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignInContinuationState; + + beforeEach(() => { + state = new SignInContinuationState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + signInScenario: SignInScenario.SignInAfterSignUp, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should successfully sign in and return a result", async () => { + mockSignInClient.signInWithContinuationToken.mockResolvedValue( + new SignInCompletedResult(correlationId, { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }), + ); + + const result = await state.signIn({ scopes: ["scope1", "scope2"] }); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect(mockSignInClient.signInWithContinuationToken).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code", "password", "redirect"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + username: username, + signInScenario: SignInScenario.SignInAfterSignUp, + }); + }); + + it("should return an error result if signIn throws an error", async () => { + const mockError = new Error("Sign in failed"); + mockSignInClient.signInWithContinuationToken.mockRejectedValue(mockError); + + const result = await state.signIn(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts b/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts new file mode 100644 index 0000000000..b4f88d052b --- /dev/null +++ b/lib/msal-custom-auth/test/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts @@ -0,0 +1,116 @@ +import { Logger } from "@azure/msal-browser"; +import { CustomAuthAccountData } from "../../../../src/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../src/core/error/InvalidArgumentError.js"; +import { SignInSubmitPasswordError } from "../../../../src/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInSubmitPasswordResult } from "../../../../src/sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; +import { SignInPasswordRequiredState } from "../../../../src/sign_in/auth_flow/state/SignInPasswordRequiredState.js"; +import { SignInCompletedResult } from "../../../../src/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignInPasswordRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["password"] }, + } as unknown as jest.Mocked; + + const mockSignInClient = { + submitPassword: jest.fn(), + } as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const mockCacheClient = {} as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignInPasswordRequiredState; + + beforeEach(() => { + state = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + scopes: ["scope1", "scope2"], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return an error result if password is empty", async () => { + const result = await state.submitPassword(""); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("password"); + }); + + it("should successfully submit a password and return a result", async () => { + mockSignInClient.submitPassword.mockResolvedValue( + new SignInCompletedResult(correlationId, { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }), + ); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + }); + }); + + it("should return an error result if submitPassword throws an error", async () => { + const mockError = new Error("Submission failed"); + mockSignInClient.submitPassword.mockRejectedValue(mockError); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_in/interation_client/SignInClient.spec.ts b/lib/msal-custom-auth/test/sign_in/interation_client/SignInClient.spec.ts new file mode 100644 index 0000000000..7e768ca259 --- /dev/null +++ b/lib/msal-custom-auth/test/sign_in/interation_client/SignInClient.spec.ts @@ -0,0 +1,348 @@ +import { + BrowserCacheManager, + BrowserConfiguration, + EventHandler, + ICrypto, + INavigationClient, + INetworkModule, + IPerformanceClient, + Logger, +} from "@azure/msal-browser"; +import { SignInClient } from "../../../src/sign_in/interaction_client/SignInClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../src/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../src/CustomAuthConstants.js"; +import { + SignInCodeSendResult, + SignInCompletedResult, + SignInPasswordRequiredResult, +} from "../../../src/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInScenario } from "../../../src/sign_in/auth_flow/SignInScenario.js"; + +jest.mock("../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOob: jest.fn(), + requestTokenWithContinuationToken: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + // Set up the prototype or instance methods/properties + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { mockedApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient }; +}); + +describe("SignInClient", () => { + let client: SignInClient; + let authority: CustomAuthAuthority; + const { mockedApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient } = jest.requireMock( + "../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", + ); + + beforeEach(() => { + jest.resetAllMocks(); + const mockBrowserConfiguration = { + system: { + networkClient: { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn(), + } as unknown as jest.Mocked, + }, + auth: { + clientId: customAuthConfig.auth.clientId, + }, + } as unknown as jest.Mocked; + + const mockCacheManager = { + getWrapperMetadata: jest.fn(), + getServerTelemetry: jest.fn(), + generateAuthorityMetadataCacheKey: jest.fn(), + setAuthorityMetadata: jest.fn(), + } as unknown as jest.Mocked; + mockCacheManager.getWrapperMetadata.mockReturnValue(["", ""]); + mockCacheManager.getServerTelemetry.mockReturnValue(null); + const mockNetworkModule = {} as unknown as jest.Mocked; + + const mockCrypto = { + createNewGuid: jest.fn(), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockNavigationClient = {} as unknown as jest.Mocked; + const mockPerformanceClient = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + mockLogger.clone.mockReturnValue(mockLogger); + + const mockConfig = { + auth: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } as unknown as jest.Mocked; + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl, + ); + + client = new SignInClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority, + ); + + (client as any).tokenResponseHandler = { + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "abc@abc.com", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + tokenType: "Bearer", + authority: "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/", + }), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("start", () => { + it("should return SignInCodeSendResult when challenge type is OOB", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignInCodeSendResult); + + const codeSendResult = result as SignInCodeSendResult; + expect(codeSendResult.correlationId).toBe("corr123"); + expect(codeSendResult.continuationToken).toBe("continuation_token_2"); + expect(codeSendResult.codeLength).toBe(6); + expect(codeSendResult.challengeChannel).toBe("email"); + expect(codeSendResult.challengeTargetLabel).toBe("email"); + }); + + it("should return SignInContinuationTokenResult when challenge type is PASSWORD", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignInPasswordRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + }); + + describe("submitCode", () => { + it("should return SignInCompleteResult for valid code", async () => { + signInApiClient.requestTokensWithOob.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + scopes: [], + }); + + expect(result).toBeInstanceOf(SignInCompletedResult); + expect(result.correlationId).toBe("test-correlation-id"); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe("test-access-token"); + expect(result.authenticationResult.idToken).toBe("test-id-token"); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe("Bearer"); + expect(result.authenticationResult.authority).toBe(authority.canonicalAuthority); + expect(result.authenticationResult.tenantId).toBe("test-tenant-id"); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe("abc@abc.com"); + }); + }); + + describe("submitPassword", () => { + it("should return SignInCompleteResult for valid password", async () => { + signInApiClient.requestTokensWithPassword.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + scopes: [], + }); + + expect(result).toBeInstanceOf(SignInCompletedResult); + expect(result.correlationId).toBe("test-correlation-id"); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe("test-access-token"); + expect(result.authenticationResult.idToken).toBe("test-id-token"); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe("Bearer"); + expect(result.authenticationResult.authority).toBe(authority.canonicalAuthority); + expect(result.authenticationResult.tenantId).toBe("test-tenant-id"); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe("abc@abc.com"); + }); + }); + + describe("resendCode", () => { + it("should return SignInCodeSendResult", async () => { + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignInCodeSendResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + }); + }); + + describe("signInWithContinuationToken", () => { + it("should return SignInCompleteResult", async () => { + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const result = await client.signInWithContinuationToken({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + scopes: [], + signInScenario: SignInScenario.SignInAfterSignUp, + }); + + expect(result).toBeInstanceOf(SignInCompletedResult); + expect(result.correlationId).toBe("test-correlation-id"); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe("test-access-token"); + expect(result.authenticationResult.idToken).toBe("test-id-token"); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe("Bearer"); + expect(result.authenticationResult.authority).toBe(authority.canonicalAuthority); + expect(result.authenticationResult.tenantId).toBe("test-tenant-id"); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe("abc@abc.com"); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_up/auth_flow/error_type/SignUpError.spec.ts b/lib/msal-custom-auth/test/sign_up/auth_flow/error_type/SignUpError.spec.ts new file mode 100644 index 0000000000..c577dc6e6b --- /dev/null +++ b/lib/msal-custom-auth/test/sign_up/auth_flow/error_type/SignUpError.spec.ts @@ -0,0 +1,173 @@ +import { CustomAuthApiError, RedirectError } from "../../../../src/core/error/CustomAuthApiError.js"; +import { + CustomAuthApiErrorCode, + CustomAuthApiSuberror, +} from "../../../../src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { InvalidArgumentError } from "../../../../src/index.js"; +import { + SignUpError, + SignUpResendCodeError, + SignUpSubmitAttributesError, + SignUpSubmitCodeError, + SignUpSubmitPasswordError, +} from "../../../../src/sign_up/auth_flow/error_type/SignUpError.js"; + +describe("SignUpError", () => { + it("should correctly identify user already exists error", () => { + const error = new CustomAuthApiError(CustomAuthApiErrorCode.USER_ALREADY_EXISTS, "User already exists"); + const signUpError = new SignUpError(error); + expect(signUpError.isUserAlreadyExists()).toBe(true); + }); + + it("should correctly identify invalid username error", () => { + const error = new InvalidArgumentError("Invalid username"); + const signUpError = new SignUpError(error); + expect(signUpError.isInvalidUsername()).toBe(true); + + const error2 = new CustomAuthApiError("Some Error", "username parameter is empty or not valid", undefined, [ + 90100, + ]); + const signUpError2 = new SignUpError(error2); + expect(signUpError2.isInvalidUsername()).toBe(true); + }); + + it("should correctly identify invalid password error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid password", + undefined, + undefined, + CustomAuthApiSuberror.PASSWORD_IS_INVALID, + ); + const signUpError = new SignUpError(error); + expect(signUpError.isInvalidPassword()).toBe(true); + }); + + it("should correctly identify missing required attributes error", () => { + const error = new CustomAuthApiError(CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, "Attributes required"); + const signUpError = new SignUpError(error); + expect(signUpError.isMissingRequiredAttributes()).toBe(true); + }); + + it("should correctly identify attributes validation failed error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Attributes validation failed", + undefined, + undefined, + CustomAuthApiSuberror.ATTRIBUTE_VALIATION_FAILED, + ); + const signUpError = new SignUpError(error); + expect(signUpError.isAttributesValidationFailed()).toBe(true); + }); + + it("should correctly identify unsupported challenge type error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "The challenge_type list parameter contains an unsupported challenge type", + ); + const signUpError = new SignUpError(error); + expect(signUpError.isUnsupportedChallengeType()).toBe(true); + + const error2 = new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "Unsupported challenge type", + ); + const signUpError2 = new SignUpError(error2); + expect(signUpError2.isUnsupportedChallengeType()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpSubmitPasswordError", () => { + it("should correctly identify invalid password error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid password", + undefined, + undefined, + CustomAuthApiSuberror.PASSWORD_IS_INVALID, + ); + const signUpError = new SignUpSubmitPasswordError(error); + expect(signUpError.isInvalidPassword()).toBe(true); + + const error2 = new CustomAuthApiError(CustomAuthApiErrorCode.INVALID_GRANT, "Incorrect password", undefined, [ + 50126, + ]); + const signUpError2 = new SignUpSubmitPasswordError(error2); + expect(signUpError2.isInvalidPassword()).toBe(true); + + const error3 = new InvalidArgumentError("password is required"); + const signUpError3 = new SignUpSubmitPasswordError(error3); + expect(signUpError3.isInvalidPassword()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpSubmitPasswordError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpSubmitCodeError", () => { + it("should correctly identify invalid code error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid code", + undefined, + undefined, + CustomAuthApiSuberror.INVALID_OOB_VALUE, + ); + const signUpError = new SignUpSubmitCodeError(error); + expect(signUpError.isInvalidCode()).toBe(true); + + const error2 = new InvalidArgumentError("Invalid code"); + const signUpError2 = new SignUpSubmitCodeError(error2); + expect(signUpError2.isInvalidCode()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpSubmitCodeError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpSubmitAttributesError", () => { + it("should correctly identify missing required attributes error", () => { + const error = new CustomAuthApiError(CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, "Attributes required"); + const signUpError = new SignUpSubmitAttributesError(error); + expect(signUpError.isMissingRequiredAttributes()).toBe(true); + }); + + it("should correctly identify attributes validation failed error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Attributes validation failed", + undefined, + undefined, + CustomAuthApiSuberror.ATTRIBUTE_VALIATION_FAILED, + ); + const signUpError = new SignUpSubmitAttributesError(error); + expect(signUpError.isAttributesValidationFailed()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpSubmitAttributesError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpResendCodeError", () => { + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpResendCodeError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts b/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts new file mode 100644 index 0000000000..e0ead25e04 --- /dev/null +++ b/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts @@ -0,0 +1,97 @@ +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { SignUpSubmitAttributesError } from "../../../../src/sign_up/auth_flow/error_type/SignUpError.js"; +import { SignUpSubmitAttributesResult } from "../../../../src/sign_up/auth_flow/result/SignUpSubmitAttributesResult.js"; +import { SignUpAttributesRequiredState } from "../../../../src/sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; +import { SignUpCompletedResult } from "../../../../src/sign_up/interaction_client/result/SignUpActionResult.js"; +import { SignUpClient } from "../../../../src/sign_up/interaction_client/SignUpClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { UserAccountAttributes } from "../../../../src/UserAccountAttributes.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignUpAttributesRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["attributes"] }, + } as unknown as jest.Mocked; + + const mockSignUpClient = { + submitAttributes: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + const requiredAttributes = new UserAccountAttributes(); + requiredAttributes.setDisplayName("test-value"); + + let state: SignUpAttributesRequiredState; + + beforeEach(() => { + state = new SignUpAttributesRequiredState({ + username: username, + signUpClient: mockSignUpClient, + signInClient: mockSignInClient, + cacheClient: {} as unknown as jest.Mocked, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + requiredAttributes: [ + { + name: "name", + type: "string", + }, + ], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitAttributes", () => { + it("should return an error result if attributes is empty", async () => { + const result1 = await state.submitAttributes(null as unknown as UserAccountAttributes); + + expect(result1.isFailed()).toBeTruthy(); + expect(result1.error).toBeInstanceOf(SignUpSubmitAttributesError); + expect(result1.error?.isAttributesValidationFailed()).toBe(true); + + const result2 = await state.submitAttributes(new UserAccountAttributes()); + + expect(result2.isFailed()).toBeTruthy(); + expect(result2.error).toBeInstanceOf(SignUpSubmitAttributesError); + expect(result2.error?.isAttributesValidationFailed()).toBe(true); + }); + + it("should successfully submit a attributes and return completed state if no credentail required", async () => { + mockSignUpClient.submitAttributes.mockResolvedValue( + new SignUpCompletedResult(correlationId, "continuation-token"), + ); + + const result = await state.submitAttributes(requiredAttributes); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitAttributesResult); + expect(result.isCompleted()).toBe(true); + expect(mockSignUpClient.submitAttributes).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["attributes"], + continuationToken: continuationToken, + attributes: requiredAttributes.toRecord(), + username: username, + }); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts b/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts new file mode 100644 index 0000000000..0397dd6577 --- /dev/null +++ b/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts @@ -0,0 +1,162 @@ +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../src/core/error/InvalidArgumentError.js"; +import { SignUpSubmitCodeError } from "../../../../src/sign_up/auth_flow/error_type/SignUpError.js"; +import { SignUpResendCodeResult } from "../../../../src/sign_up/auth_flow/result/SignUpResendCodeResult.js"; +import { SignUpSubmitCodeResult } from "../../../../src/sign_up/auth_flow/result/SignUpSubmitCodeResult.js"; +import { SignUpCodeRequiredState } from "../../../../src/sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +import { + SignUpAttributesRequiredResult, + SignUpCodeRequiredResult, + SignUpCompletedResult, + SignUpPasswordRequiredResult, +} from "../../../../src/sign_up/interaction_client/result/SignUpActionResult.js"; +import { SignUpClient } from "../../../../src/sign_up/interaction_client/SignUpClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignUpCodeRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code"] }, + } as unknown as jest.Mocked; + + const mockSignUpClient = { + submitCode: jest.fn(), + resendCode: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignUpCodeRequiredState; + + beforeEach(() => { + state = new SignUpCodeRequiredState({ + username: username, + signUpClient: mockSignUpClient, + signInClient: mockSignInClient, + cacheClient: {} as unknown as jest.Mocked, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + codeResendInterval: 60, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitCode", () => { + it("should return an error result if code is empty", async () => { + const result = await state.submitCode(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignUpSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("code"); + }); + + it("should successfully submit a code and return completed state if no credentail required", async () => { + mockSignUpClient.submitCode.mockResolvedValue( + new SignUpCompletedResult(correlationId, "continuation-token"), + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitCodeResult); + expect(result.isCompleted()).toBe(true); + expect(mockSignUpClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should successfully submit a code and return password-required state if password is required", async () => { + mockSignUpClient.submitCode.mockResolvedValue( + new SignUpPasswordRequiredResult(correlationId, "continuation-token"), + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitCodeResult); + expect(result.isPasswordRequired()).toBe(true); + expect(mockSignUpClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should successfully submit a code and return attributes-required state if attributes are required", async () => { + mockSignUpClient.submitCode.mockResolvedValue( + new SignUpAttributesRequiredResult(correlationId, "continuation-token", [ + { + name: "name", + type: "string", + }, + ]), + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitCodeResult); + expect(result.isAttributesRequired()).toBe(true); + expect(mockSignUpClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + }); + + describe("resendCode", () => { + it("should successfully resend a code and return a code required state", async () => { + mockSignUpClient.resendCode.mockResolvedValue( + new SignUpCodeRequiredResult( + correlationId, + "new-continuation-token", + "code", + "email", + 6, + 60, + "email-otp", + ), + ); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpResendCodeResult); + expect(result.data).toBeUndefined(); + expect(result.isCodeRequired()).toBeTruthy(); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts b/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts new file mode 100644 index 0000000000..75d9d5d71b --- /dev/null +++ b/lib/msal-custom-auth/test/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts @@ -0,0 +1,114 @@ +import { CustomAuthBrowserConfiguration } from "../../../../src/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../src/core/error/InvalidArgumentError.js"; +import { SignUpSubmitPasswordError } from "../../../../src/sign_up/auth_flow/error_type/SignUpError.js"; +import { SignUpSubmitPasswordResult } from "../../../../src/sign_up/auth_flow/result/SignUpSubmitPasswordResult.js"; +import { SignUpPasswordRequiredState } from "../../../../src/sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +import { + SignUpAttributesRequiredResult, + SignUpCodeRequiredResult, + SignUpCompletedResult, +} from "../../../../src/sign_up/interaction_client/result/SignUpActionResult.js"; +import { SignUpClient } from "../../../../src/sign_up/interaction_client/SignUpClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../src/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignUpPasswordRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["password"] }, + } as unknown as jest.Mocked; + + const mockSignUpClient = { + submitPassword: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignUpPasswordRequiredState; + + beforeEach(() => { + state = new SignUpPasswordRequiredState({ + username: username, + signUpClient: mockSignUpClient, + signInClient: mockSignInClient, + cacheClient: {} as unknown as jest.Mocked, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitPassword", () => { + it("should return an error result if password is empty", async () => { + const result = await state.submitPassword(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignUpSubmitPasswordError); + expect(result.error?.isInvalidPassword()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("password"); + }); + + it("should successfully submit a password and return completed state if no credentail required", async () => { + mockSignUpClient.submitPassword.mockResolvedValue( + new SignUpCompletedResult(correlationId, "continuation-token"), + ); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(result.isCompleted()).toBe(true); + expect(mockSignUpClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + }); + }); + + it("should successfully submit a password and return attributes-required state if attributes are required", async () => { + mockSignUpClient.submitPassword.mockResolvedValue( + new SignUpAttributesRequiredResult(correlationId, "continuation-token", [ + { + name: "name", + type: "string", + }, + ]), + ); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(result.isAttributesRequired()).toBe(true); + expect(mockSignUpClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + }); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/sign_up/interaction_client/SignUpClient.spec.ts b/lib/msal-custom-auth/test/sign_up/interaction_client/SignUpClient.spec.ts new file mode 100644 index 0000000000..788e266b8e --- /dev/null +++ b/lib/msal-custom-auth/test/sign_up/interaction_client/SignUpClient.spec.ts @@ -0,0 +1,599 @@ +import { + BrowserCacheManager, + BrowserConfiguration, + EventHandler, + ICrypto, + INavigationClient, + INetworkModule, + IPerformanceClient, + Logger, +} from "@azure/msal-browser"; +import { SignUpClient } from "../../../src/sign_up/interaction_client/SignUpClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../src/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../src/CustomAuthConstants.js"; +import { + SignUpAttributesRequiredResult, + SignUpCodeRequiredResult, + SignUpCompletedResult, + SignUpPasswordRequiredResult, +} from "../../../src/sign_up/interaction_client/result/SignUpActionResult.js"; +import { CustomAuthApiError } from "../../../src/index.js"; +import { CustomAuthApiErrorCode } from "../../../src/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +jest.mock("../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOob: jest.fn(), + signInWithContinuationToken: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { mockedApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient }; +}); + +describe("SignUpClient", () => { + let client: SignUpClient; + let authority: CustomAuthAuthority; + const { mockedApiClient, signInApiClient, signUpApiClient, resetPasswordApiClient } = jest.requireMock( + "../../../src/core/network_client/custom_auth_api/CustomAuthApiClient.js", + ); + beforeEach(() => { + jest.resetAllMocks(); + const mockBrowserConfiguration = { + system: { + networkClient: { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn(), + } as unknown as jest.Mocked, + }, + auth: { + clientId: customAuthConfig.auth.clientId, + }, + } as unknown as jest.Mocked; + + const mockCacheManager = { + getWrapperMetadata: jest.fn(), + getServerTelemetry: jest.fn(), + generateAuthorityMetadataCacheKey: jest.fn(), + setAuthorityMetadata: jest.fn(), + } as unknown as jest.Mocked; + mockCacheManager.getWrapperMetadata.mockReturnValue(["", ""]); + mockCacheManager.getServerTelemetry.mockReturnValue(null); + + const mockCrypto = { + createNewGuid: jest.fn(), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockNavigationClient = {} as unknown as jest.Mocked; + const mockPerformanceClient = {} as unknown as jest.Mocked; + const mockNetworkModule = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + mockLogger.clone.mockReturnValue(mockLogger); + + const mockConfig = { + auth: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } as unknown as jest.Mocked; + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl, + ); + + client = new SignUpClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("start", () => { + it("should return SignUpCodeRequiredResult when challenge type is OOB", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCodeRequiredResult); + const codeSendResult = result as SignUpCodeRequiredResult; + expect(codeSendResult.correlationId).toBe("corr123"); + expect(codeSendResult.continuationToken).toBe("continuation_token_2"); + expect(codeSendResult.codeLength).toBe(6); + expect(codeSendResult.challengeChannel).toBe("email"); + expect(codeSendResult.challengeTargetLabel).toBe("email"); + }); + + it("should return SignUpPasswordRequiredResult when challenge type is PASSWORD", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpPasswordRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + }); + + describe("submitCode", () => { + it("should return SignUpCompletedResult for valid code", async () => { + signUpApiClient.continueWithCode.mockResolvedValue({ + continuation_token: "continuation_token_2", + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCompletedResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should return SignUpPasswordRequiredResult if password is required", async () => { + signUpApiClient.continueWithCode.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "Password required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1", + ), + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpPasswordRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }), + ); + }); + + it("should throw error if credential is required but challenge type password isn't supported", async () => { + signUpApiClient.continueWithCode.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "Password required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1", + ), + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: "passkey", + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + await expect( + client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }), + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + errorDescription: "Unsupported challenge type 'passkey'.", + correlationId: "corr123", + }); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }), + ); + }); + + it("should return SignUpAttributesRequiredResult if attributes are required", async () => { + signUpApiClient.continueWithCode.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + "corr123", + [55106], + undefined, + [ + { + name: "name", + type: "string", + }, + ], + "continuation_token_1", + ), + ); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpAttributesRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_1"); + }); + }); + + describe("submitPassword", () => { + it("should return SignUpCompletedResult for valid password", async () => { + signUpApiClient.continueWithPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + }); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCompletedResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should return SignUpCodeRequiredResult if oob is required", async () => { + signUpApiClient.continueWithPassword.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "credential required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1", + ), + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCodeRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }), + ); + }); + + it("should return SignUpAttributesRequiredResult if attributes are required", async () => { + signUpApiClient.continueWithPassword.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + "corr123", + [55106], + undefined, + [ + { + name: "name", + type: "string", + }, + ], + "continuation_token_1", + ), + ); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpAttributesRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_1"); + }); + }); + + describe("submitAttributes", () => { + it("should return SignUpCompletedResult for valid password", async () => { + signUpApiClient.continueWithAttributes.mockResolvedValue({ + continuation_token: "continuation_token_2", + }); + + const result = await client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCompletedResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should return SignUpCodeRequiredResult if oob is required", async () => { + signUpApiClient.continueWithAttributes.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "credential required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1", + ), + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + target_challenge_label: "email", + }); + + const result = await client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCodeRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }), + ); + }); + + it("should return SignUpPasswordRequiredResult if password is required", async () => { + signUpApiClient.continueWithAttributes.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "Password required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1", + ), + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpPasswordRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }), + ); + }); + + it("should throw error if some required attributes are missing", async () => { + signUpApiClient.continueWithAttributes.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + "corr123", + [55106], + undefined, + [ + { + name: "name", + type: "string", + }, + ], + "continuation_token_1", + ), + ); + + await expect( + client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }), + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + errorDescription: "User attributes required", + correlationId: "corr123", + errorCodes: [], + subError: "", + attributes: [ + { + name: "name", + type: "string", + }, + ], + continuationToken: "continuation_token_1", + }); + }); + }); + + describe("resendCode", () => { + it("should return SignUpCodeRequiredResult", async () => { + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.OOB, ChallengeType.PASSWORD, ChallengeType.REDIRECT], + correlationId: "corr123", + }); + + expect(result).toBeInstanceOf(SignUpCodeRequiredResult); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + }); + }); +}); diff --git a/lib/msal-custom-auth/test/test_resources/CustomAuthConfig.ts b/lib/msal-custom-auth/test/test_resources/CustomAuthConfig.ts new file mode 100644 index 0000000000..efb59ac563 --- /dev/null +++ b/lib/msal-custom-auth/test/test_resources/CustomAuthConfig.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { LogLevel } from "@azure/msal-browser"; +import { CustomAuthConfiguration } from "../../src/configuration/CustomAuthConfiguration.js"; + +export const customAuthConfig: CustomAuthConfiguration = { + customAuth: { + challengeTypes: ["password", "oob", "redirect"], + authApiProxyUrl: "https://myspafunctiont1.azurewebsites.net/api/ReverseProxy/", + }, + auth: { + clientId: "d5e97fb9-24bb-418d-8e7a-4e1918303c92", + authority: "https://spasamples.ciamlogin.com/", + redirectUri: "/", + }, + cache: { + cacheLocation: "sessionStorage", + storeAuthStateInCookie: false, + }, + system: { + loggerOptions: { + loggerCallback: (level, message, containsPii) => { + if (containsPii) { + return; + } + switch (level) { + case LogLevel.Error: + console.info(`[Error] ${message}`); + return; + case LogLevel.Info: + console.info(`[Info] ${message}`); + return; + case LogLevel.Verbose: + console.info(`[Verbose] ${message}`); + return; + case LogLevel.Warning: + console.info(`[Warning] ${message}`); + return; + default: + return; + } + }, + }, + }, +}; diff --git a/lib/msal-custom-auth/test/test_resources/TestConstants.ts b/lib/msal-custom-auth/test/test_resources/TestConstants.ts new file mode 100644 index 0000000000..5b2e154372 --- /dev/null +++ b/lib/msal-custom-auth/test/test_resources/TestConstants.ts @@ -0,0 +1,51 @@ +export const TestTokenResponse = { + ACCESS_TOKEN: "fake-access-token", + REFRESH_TOKEN: "fake-refresh-token", + // This is a mock id token with a valid signature (signed by HS265 with a fake secret key), but the claims are not real. + ID_TOKEN: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiI4ZDljNzYzNS0wOTMzLTRiOTctYjJhZC03YzUzZDkxZGY1ZGEiLCJpc3MiOiJodHRwczovL2QzN2U1NjQ1LTQxNzAtNGNlMC1hNjE4LTFiOTAwOGIxNGU1OC5jaWFtbG9naW4uY29tL2QzN2U1NjQ1LTQxNzAtNGNlMC1hNjE4LTFiOTAwOGIxNGU1OC92Mi4wIiwiaWF0IjoxNzQwMDQ5Mjg4LCJuYmYiOjE3NDAwNDkyODgsImV4cCI6MTc0MDA1MzE4OCwiYWlvIjoiQVdRQW0vOFpBQUFBM1phQmdmWkRhaGhUOGVadThTUzhtUHFxelRIbjk5QjBIMmlUa3NvZW9mbW9pMTIya2ZvaXNqZmVnREVUVTFSczc0TkNUMDlUeUVWWjM0c3NNVnVmaHFDTVRYYjFnTUlLSFBUdEF2MlVBa2p1akZuZCtaZE8iLCJpZHAiOiJtYWlsIiwibmFtZSI6InVua25vd24iLCJvaWQiOiJkOGRjY2VlOC1iOGJjLTQ1MmMtOGJjYy1hNmViOTUzZGI0NTkiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhYmNAdGVzdC5jb20iLCJyaCI6IjEuQWM4QXpYUzVIc1VOcHNmZXdmZWtDZmFKU3huMVUxeFVCTHhzZmV3ZnNmZVBBSTdQQUEuIiwic2lkIjoiZGNiMDQ4NjItZjk1Ni00MzAxLWIzZmMtMGZkMzhmYTViZTdmIiwic3ViIjoiYkh5VlVkUHNmc2Fmc2RmZU16TDdhM1JYdklVbGJlSVVZQVoxMm8iLCJ0aWQiOiJkMzdlNTY0NS00MTcwLTRjZTAtYTYxOC0xYjkwMDhiMTRlNTgiLCJ1dGkiOiJZWDFPREZKX3NlZnVFbUhaZGZodWVKRERCbFFEQUEiLCJ2ZXIiOiIyLjAifQ.M0FBAIMmwwGTGpVbGFEWBy3vUfBEqNdem9MT2L5r39Y", + CLIENT_INFO: + "eyJ1aWQiOiI1MTIyZWZiMS1mM2EzLTRhNWQtYjVhZS1jNTQ3NGVhMWM3YmQiLCJ1dGlkIjoiZDM3ZTU2NDUtNDE3MC00Y2UwLWE2MTgtMWI5MDA4YjE0ZTU4In0=", +} as const; + +export const TestHomeAccountId = "5122efb1-f3a3-4a5d-b5ae-c5474ea1c7bd.d37e5645-4170-4ce0-a618-1b9008b14e58"; // fake homeAccountId +export const TestTenantId = "d37e5645-4170-4ce0-a618-1b9008b14e58"; // fake tenantId +export const TestUsername = "abc@test.com"; // fake username + +export const TestAccounDetails = { + homeAccountId: TestHomeAccountId, + environment: "spasamples.ciamlogin.com", + tenantId: TestTenantId, + username: TestUsername, + localAccountId: "d8dcce8-b8bc-452c-8bcc-a6eb953db459", + idTokenClaims: { + tid: TestTenantId, + oid: "dcb04862-f956-4301-b3fc-0fd38fa5be7f", + preferred_username: TestUsername, + }, + name: "Test User", + idToken: TestTokenResponse.ID_TOKEN, +}; + +// mock response of POST /token endpoint when renew access token +export const TestServerTokenResponse = { + status: 200, + token_type: "Bearer", + scope: "openid profile User.Read email", + expires_in: 3600, + access_token: TestTokenResponse.ACCESS_TOKEN, + refresh_token: TestTokenResponse.REFRESH_TOKEN, + id_token: TestTokenResponse.ID_TOKEN, + client_info: TestTokenResponse.CLIENT_INFO, + correlation_id: "correlation-id", +}; + +// // mock decoded id token claims +export const TestIdTokenClaims = { + name: "unknown", +}; + +export const RenewedTokens = { + ACCESS_TOKEN: "renewed-access-token", + REFRESH_TOKEN: "renewed-refresh-token", +}; diff --git a/lib/msal-custom-auth/tsconfig.base.json b/lib/msal-custom-auth/tsconfig.base.json new file mode 100644 index 0000000000..70d3879b3d --- /dev/null +++ b/lib/msal-custom-auth/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "sourceMap": true, + "declaration": true, + "experimentalDecorators": true, + "importHelpers": true, + "noImplicitAny": true, + "downlevelIteration": true, + "strict": true, + "composite": true, + "declarationMap": true + }, + "compileOnSave": false, + "buildOnSave": false +} diff --git a/lib/msal-custom-auth/tsconfig.build.json b/lib/msal-custom-auth/tsconfig.build.json new file mode 100644 index 0000000000..02b03ef71f --- /dev/null +++ b/lib/msal-custom-auth/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": [ + "src" + ] +} diff --git a/lib/msal-custom-auth/tsconfig.json b/lib/msal-custom-auth/tsconfig.json new file mode 100644 index 0000000000..62b3828397 --- /dev/null +++ b/lib/msal-custom-auth/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "moduleResolution": "nodenext", + "module": "nodenext", + "target": "es2020", + "lib": [ + "es2020", + "dom", + "es2020.promise" + ], + "outDir": "./dist", + "allowUnusedLabels": false, + "composite": false, + "noImplicitReturns": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": [ + "node", + "jest" + ] + }, + "include": [ + "src", + "test", + ], + "exclude": [ + "node_modules" + ] +} diff --git a/lib/msal-custom-auth/typedoc.json b/lib/msal-custom-auth/typedoc.json new file mode 100644 index 0000000000..fa42dab983 --- /dev/null +++ b/lib/msal-custom-auth/typedoc.json @@ -0,0 +1,10 @@ +{ + "extends": [ + "../../typedoc.base.json" + ], + "entryPoints": [ + "src/index.ts" + ], + "tsconfig": "tsconfig.build.json", + "readme": "./README.md" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e4b0e557bc..b44e443a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "lib/msal-node", "lib/msal-angular", "lib/msal-react", + "lib/msal-custom-auth", "extensions/msal-node-extensions", "extensions/samples/*", "shared-test-utils", @@ -22,6 +23,7 @@ "samples/msal-angular-samples/*", "samples/msal-react-samples/*", "samples/msal-node-samples/*", + "samples/msal-custom-auth-samples/*", "regression-tests/msal-node/client-credential" ], "devDependencies": { @@ -498,6 +500,237 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "lib/msal-custom-auth": { + "name": "@azure/msal-custom-auth", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@azure/msal-browser": "^4.5.0" + }, + "devDependencies": { + "@azure/storage-blob": "^12.26.0", + "@babel/core": "^7.26.0", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/preset-env": "^7.26.0", + "@babel/preset-typescript": "^7.26.0", + "@microsoft/api-extractor": "^7.48.1", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.2", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.2", + "dotenv": "^16.4.7", + "eslint-config-msal": "file:../../shared-configs/eslint-config-msal", + "fake-indexeddb": "^6.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.4.2", + "rimraf": "^5.0.10", + "rollup": "^4.29.1", + "rollup-msal": "file:../../shared-configs/rollup-msal", + "shx": "^0.3.4", + "ssri": "^12.0.0", + "ts-jest": "^29.2.5", + "ts-jest-resolver": "^2.0.1", + "tslib": "^2.8.1", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=10" + } + }, + "lib/msal-custom-auth/node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", + "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "lib/msal-custom-auth/node_modules/@rollup/plugin-typescript": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.1.2.tgz", + "integrity": "sha512-cdtSp154H5sv637uMr1a8OTWB0L1SWDSm1rDGiyfcGcvQ6cuTs4MDk2BVEBGysUWago4OJN4EQZqOTl/QY3Jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "lib/msal-custom-auth/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "lib/msal-custom-auth/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "lib/msal-custom-auth/node_modules/fake-indexeddb": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz", + "integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "lib/msal-custom-auth/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "lib/msal-custom-auth/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "lib/msal-custom-auth/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "lib/msal-custom-auth/node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "lib/msal-custom-auth/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "lib/msal-custom-auth/node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "lib/msal-custom-auth/node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "lib/msal-node": { "name": "@azure/msal-node", "version": "3.4.1", @@ -2804,6 +3037,10 @@ "resolved": "lib/msal-common", "link": true }, + "node_modules/@azure/msal-custom-auth": { + "resolved": "lib/msal-custom-auth", + "link": true + }, "node_modules/@azure/msal-node": { "resolved": "lib/msal-node", "link": true @@ -39873,6 +40110,10 @@ "resolved": "https://registry.npmjs.org/safevalues/-/safevalues-0.3.4.tgz", "integrity": "sha512-LRneZZRXNgjzwG4bDQdOTSbze3fHm1EAKN/8bePxnlEZiBmkYEDggaHbuvHI9/hoqHbGfsEA7tWS9GhYHZBBsw==" }, + "node_modules/sample-sample": { + "resolved": "samples/msal-custom-auth-samples/sample-sample", + "link": true + }, "node_modules/sanitize.css": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", @@ -44259,11 +44500,10 @@ } }, "node_modules/vite": { - "version": "4.5.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.11.tgz", - "integrity": "sha512-4mVdhLkZ0vpqZLGJhNm+X1n7juqXApEMGlUXcOQawA45UmpxivOYaMBkI/Js3FlBsNA8hCgEnX5X04moFitSGw==", + "version": "4.5.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.13.tgz", + "integrity": "sha512-Hgp8IF/yZDzKsN1hQWOuQZbrKiaFsbQud+07jJ8h9m9PaHWkpvZ5u55Xw5yYjWRXwRQ4jwFlJvY7T7FUJG9MCA==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "esbuild": "^0.18.10", @@ -59993,6 +60233,144 @@ } } }, + "samples/msal-custom-auth-samples/sample-sample": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@azure/msal-browser": "^2.0.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "dotenv": "^16.4.7", + "e2e-test-utils": "^0.0.1", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "rimraf": "^5.0.10", + "ts-jest": "^29.2.5", + "typescript": "^5.7.2" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/@azure/msal-browser": { + "version": "2.39.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.39.0.tgz", + "integrity": "sha512-kks/n2AJzKUk+DBqZhiD+7zeQGBl+WpSOQYzWy6hff3bU0ZrYFqr4keFLlzB5VKuKZog0X59/FGHb1RPBDZLVg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "13.3.3" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/@azure/msal-common": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-13.3.3.tgz", + "integrity": "sha512-n278DdCXKeiWhLwhEL7/u9HRMyzhUXLefeajiknf6AmEedoiOiv2r5aRJ7LXdT3NGPyubkdIbthaJlVtmuEqvA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "samples/msal-custom-auth-samples/sample-sample/node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "samples/msal-node-samples/auth-code": { "name": "msal-node-auth-code", "version": "1.0.0", diff --git a/package.json b/package.json index 4dfb9578fe..a241a0ee43 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lib/msal-node", "lib/msal-angular", "lib/msal-react", + "lib/msal-custom-auth", "extensions/msal-node-extensions", "extensions/samples/*", "shared-test-utils", @@ -61,6 +62,7 @@ "samples/msal-angular-samples/*", "samples/msal-react-samples/*", "samples/msal-node-samples/*", + "samples/msal-custom-auth-samples/*", "regression-tests/msal-node/client-credential" ] -} \ No newline at end of file +} diff --git a/samples/msal-custom-auth-samples/sample-sample/.beachballrc b/samples/msal-custom-auth-samples/sample-sample/.beachballrc new file mode 100644 index 0000000000..eaa7c78ebe --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/.beachballrc @@ -0,0 +1,3 @@ +{ + "shouldPublish": false +} diff --git a/samples/msal-custom-auth-samples/sample-sample/.gitignore b/samples/msal-custom-auth-samples/sample-sample/.gitignore new file mode 100644 index 0000000000..8558d9874d --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +tsconfig.tsbuildinfo + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/samples/msal-custom-auth-samples/sample-sample/.npmrc b/samples/msal-custom-auth-samples/sample-sample/.npmrc new file mode 100644 index 0000000000..43c97e719a --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/samples/msal-custom-auth-samples/sample-sample/index.js b/samples/msal-custom-auth-samples/sample-sample/index.js new file mode 100644 index 0000000000..d83a7554d2 --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/index.js @@ -0,0 +1,7 @@ +function sample() { + return "Here is the sample function"; +} + +module.exports = { + sample +}; diff --git a/samples/msal-custom-auth-samples/sample-sample/jest.config.js b/samples/msal-custom-auth-samples/sample-sample/jest.config.js new file mode 100644 index 0000000000..a05a5201f0 --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/jest.config.js @@ -0,0 +1,9 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +module.exports = { + displayName: "Test Sample", + preset: "../../e2eTestUtils/jest-puppeteer-utils/jest-preset-no-setup.js", +}; diff --git a/samples/msal-custom-auth-samples/sample-sample/package.json b/samples/msal-custom-auth-samples/sample-sample/package.json new file mode 100644 index 0000000000..6d8a36e2d2 --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/package.json @@ -0,0 +1,30 @@ +{ + "name": "sample-sample", + "version": "1.0.0", + "description": "Sample for end-to-end test sample project", + "main": "index.js", + "private": true, + "scripts": { + "start": "node ./index.js", + "build": "npm run clean && tsc", + "clean": "rimraf ./build", + "test:e2e": "jest --passWithNoTests", + "build:package": "cd ../../.. && npm run build:all --workspace=lib/msal-custom-auth", + "start:build": "npm run build:package && npm start" + }, + "author": "Microsoft", + "license": "MIT", + "dependencies": { + "@azure/msal-browser": "^2.0.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "dotenv": "^16.4.7", + "e2e-test-utils": "^0.0.1", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "rimraf": "^5.0.10", + "ts-jest": "^29.2.5", + "typescript": "^5.7.2" + } +} diff --git a/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.js b/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.js new file mode 100644 index 0000000000..2c4ba57b87 --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.js @@ -0,0 +1,6 @@ +describe('Sample Test Suite', () => { + test('always passing test', () => { + expect(true).toBe(true); + }); +}); +//# sourceMappingURL=sample.spec.js.map \ No newline at end of file diff --git a/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.js.map b/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.js.map new file mode 100644 index 0000000000..fc0fedb24c --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sample.spec.js","sourceRoot":"","sources":["sample.spec.ts"],"names":[],"mappings":"AAAA,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IAC/B,IAAI,CAAC,qBAAqB,EAAE,GAAG,EAAE;QAC7B,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.ts b/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.ts new file mode 100644 index 0000000000..919c980824 --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/test/sample.spec.ts @@ -0,0 +1,5 @@ +describe('Sample Test Suite', () => { + test('always passing test', () => { + expect(true).toBe(true); + }); +}); diff --git a/samples/msal-custom-auth-samples/sample-sample/tsconfig.json b/samples/msal-custom-auth-samples/sample-sample/tsconfig.json new file mode 100644 index 0000000000..226fedd503 --- /dev/null +++ b/samples/msal-custom-auth-samples/sample-sample/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "module": "commonjs", + "target": "es6", + "lib": [ + "ES2021", + "dom", + "es2015.promise" + ], + "allowUnusedLabels": false, + "noImplicitReturns": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "sourceMap": true, + "experimentalDecorators": true, + "importHelpers": true, + "noImplicitAny": true, + "downlevelIteration": true, + "types": [ + "node", + "jest" + ], + }, + "include": [ + "**/test/*.spec.ts" + ], + "exclude": [ + "node_modules" + ], + "compileOnSave": false, + "buildOnSave": false, +}